ZHIWEI666 commited on
Commit
8541ba4
·
verified ·
1 Parent(s): eaf4673

公测更新,大量优化

Browse files
.dockerignore CHANGED
@@ -4,4 +4,9 @@ __pycache__
4
  /cache
5
  /tmp
6
  *.zip
7
- *.log
 
 
 
 
 
 
4
  /cache
5
  /tmp
6
  *.zip
7
+ *.log
8
+ data/
9
+ .env
10
+ *.bak
11
+ backups/
12
+ 一次性文件已完成/
Dockerfile CHANGED
@@ -18,5 +18,12 @@ RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
18
  # 复制应用代码
19
  COPY . .
20
 
 
 
 
 
 
 
 
21
  # 启动命令
22
  CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
 
18
  # 复制应用代码
19
  COPY . .
20
 
21
+ # 安全加固:以非root用户运行
22
+ RUN useradd -m -s /bin/bash appuser && chown -R appuser:appuser /code
23
+ USER appuser
24
+
25
+ # 健康检查
26
+ HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:7860/health')" || exit 1
27
+
28
  # 启动命令
29
  CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
app.py CHANGED
@@ -69,7 +69,7 @@ limiter = Limiter(key_func=get_remote_address)
69
  app = FastAPI(
70
  title="ComfyUI Ranking API",
71
  description="ComfyUI 社区排名系统 API",
72
- version="1.0.0",
73
  docs_url="/api/docs",
74
  redoc_url="/api/redoc"
75
  )
@@ -246,9 +246,13 @@ async def on_shutdown():
246
  # 这里可以添加其他清理逻辑(如关闭连接池等)
247
  logger.info("✅ 关闭完成")
248
 
 
 
 
 
249
  app.add_middleware(
250
  CORSMiddleware,
251
- allow_origins=["*"],
252
  allow_credentials=False,
253
  allow_methods=["*"],
254
  allow_headers=["*"],
@@ -289,6 +293,10 @@ def proxy_hf_image(url: str = None, path: str = None):
289
 
290
  if not path:
291
  raise HTTPException(status_code=400, detail="缺少路径参数")
 
 
 
 
292
 
293
  # 🛡️ 绝对的安全红线:限制只能代理下载图片目录,严禁黑客通过此接口下载 users.json 或账本数据!
294
  allowed_dirs = ["uploads/", "avatars/", "covers/"]
@@ -341,6 +349,9 @@ def upload_file(file: UploadFile = File(...), file_type: str = Form(...), curren
341
  if ext not in allowed_exts:
342
  raise HTTPException(status_code=400, detail=f"不支持的文件格式,{file_type} 类型仅支持 {', '.join(allowed_exts)}")
343
 
 
 
 
344
  # 🔒 P0安全优化:图片内容审核
345
  if ext in ["jpg", "jpeg", "png", "gif", "webp"]:
346
  moderation_result = moderate_image_sync(content, ext)
@@ -467,18 +478,12 @@ def validate_resource(req_data: ValidateResourceRequest, sql_db: Session = Depen
467
  # ==========================================
468
  # 🔒 管理员:系统配置 API
469
  # ==========================================
470
- from 安全认证 import require_auth
471
-
472
- # 🔒 管理员账号列表(从环境变量读取)
473
- ADMIN_ACCOUNTS = set(
474
- acc.strip()
475
- for acc in os.environ.get("ADMIN_ACCOUNTS", "").split(",")
476
- if acc.strip()
477
- )
478
 
479
  def _is_admin(account: str) -> bool:
480
  """检查账号是否为管理员"""
481
- return account in ADMIN_ACCOUNTS
 
482
 
483
  # 配置存储文件
484
  SYSTEM_CONFIG_FILE = "/tmp/system_config.json"
@@ -551,8 +556,8 @@ async def get_system_config(config_key: str, current_user: str = Depends(require
551
 
552
  # 🏷️ 版本配置默认值
553
  DEFAULT_VERSION_CONFIG = {
554
- "stage": "alpha",
555
- "major": 1,
556
  "minor": 0,
557
  "patch": 0
558
  }
 
69
  app = FastAPI(
70
  title="ComfyUI Ranking API",
71
  description="ComfyUI 社区排名系统 API",
72
+ version="2.0.0",
73
  docs_url="/api/docs",
74
  redoc_url="/api/redoc"
75
  )
 
246
  # 这里可以添加其他清理逻辑(如关闭连接池等)
247
  logger.info("✅ 关闭完成")
248
 
249
+ # 🛡️ CORS 安全配置:从环境变量读取允许的域名,收紧通配符
250
+ ALLOWED_ORIGINS = os.environ.get("ALLOWED_ORIGINS", "https://zhiwei666-comfyui-ranking-api.hf.space").split(",")
251
+ logger.info(f"CORS 允许域名: {ALLOWED_ORIGINS}")
252
+
253
  app.add_middleware(
254
  CORSMiddleware,
255
+ allow_origins=ALLOWED_ORIGINS,
256
  allow_credentials=False,
257
  allow_methods=["*"],
258
  allow_headers=["*"],
 
293
 
294
  if not path:
295
  raise HTTPException(status_code=400, detail="缺少路径参数")
296
+
297
+ # 🛡️ 路径穿越防护:禁止包含 .. 和绝对路径
298
+ if ".." in path or path.startswith("/"):
299
+ raise HTTPException(status_code=400, detail="非法路径")
300
 
301
  # 🛡️ 绝对的安全红线:限制只能代理下载图片目录,严禁黑客通过此接口下载 users.json 或账本数据!
302
  allowed_dirs = ["uploads/", "avatars/", "covers/"]
 
349
  if ext not in allowed_exts:
350
  raise HTTPException(status_code=400, detail=f"不支持的文件格式,{file_type} 类型仅支持 {', '.join(allowed_exts)}")
351
 
352
+ # 📝 审计日志:记录上传操作
353
+ logger.info(f"UPLOAD | account={current_user} | filename={file.filename} | file_type={file_type}")
354
+
355
  # 🔒 P0安全优化:图片内容审核
356
  if ext in ["jpg", "jpeg", "png", "gif", "webp"]:
357
  moderation_result = moderate_image_sync(content, ext)
 
478
  # ==========================================
479
  # 🔒 管理员:系统配置 API
480
  # ==========================================
481
+ from 安全认证 import require_auth, ADMIN_ACCOUNTS
 
 
 
 
 
 
 
482
 
483
  def _is_admin(account: str) -> bool:
484
  """检查账号是否为管理员"""
485
+ from 安全认证 import is_admin as _check_admin
486
+ return _check_admin(account)
487
 
488
  # 配置存储文件
489
  SYSTEM_CONFIG_FILE = "/tmp/system_config.json"
 
556
 
557
  # 🏷️ 版本配置默认值
558
  DEFAULT_VERSION_CONFIG = {
559
+ "stage": "beta",
560
+ "major": 2,
561
  "minor": 0,
562
  "patch": 0
563
  }
database_sql.py CHANGED
@@ -110,6 +110,7 @@ def _auto_migrate_p7_fields():
110
  """
111
  🔄 P7后悔模式:自动迁移新增字段
112
  检查并添加 ownerships 表和 transactions 表的新字段
 
113
  """
114
  from sqlalchemy import inspect
115
 
@@ -122,22 +123,34 @@ def _auto_migrate_p7_fields():
122
 
123
  with engine.connect() as conn:
124
  if 'price_paid' not in columns:
125
- if 'sqlite' in SQLALCHEMY_DATABASE_URL:
126
- conn.execute(text("ALTER TABLE ownerships ADD COLUMN price_paid INTEGER DEFAULT 0"))
127
- else:
128
- conn.execute(text("ALTER TABLE ownerships ADD COLUMN price_paid INTEGER DEFAULT 0"))
129
- logger.info("迁移完成: 添加 ownerships.price_paid 字段")
 
 
 
130
 
131
  if 'is_refunded' not in columns:
132
- if 'sqlite' in SQLALCHEMY_DATABASE_URL:
133
- conn.execute(text("ALTER TABLE ownerships ADD COLUMN is_refunded BOOLEAN DEFAULT 0"))
134
- else:
135
- conn.execute(text("ALTER TABLE ownerships ADD COLUMN is_refunded BOOLEAN DEFAULT FALSE"))
136
- logger.info("迁移完成: 添加 ownerships.is_refunded 字段")
 
 
 
137
 
138
  if 'refunded_at' not in columns:
139
- conn.execute(text("ALTER TABLE ownerships ADD COLUMN refunded_at TIMESTAMP"))
140
- logger.info("迁移完成: 添加 ownerships.refunded_at 字段")
 
 
 
 
 
 
141
 
142
  conn.commit()
143
 
@@ -147,11 +160,14 @@ def _auto_migrate_p7_fields():
147
 
148
  with engine.connect() as conn:
149
  if 'task_balance' not in columns:
150
- if 'sqlite' in SQLALCHEMY_DATABASE_URL:
151
- conn.execute(text("ALTER TABLE wallets ADD COLUMN task_balance INTEGER DEFAULT 0"))
152
- else:
153
- conn.execute(text("ALTER TABLE wallets ADD COLUMN task_balance INTEGER DEFAULT 0"))
154
- logger.info("[DB Migration] 添加列 wallets.task_balance")
 
 
 
155
 
156
  conn.commit()
157
 
@@ -175,12 +191,21 @@ def _auto_migrate_p7_fields():
175
 
176
  for col_name, col_type in new_columns.items():
177
  if col_name not in columns:
178
- # VARCHAR 类型添加 DEFAULT NULL 避免 PostgreSQL NOT NULL 冲突
179
- if col_type == 'VARCHAR':
180
- conn.execute(text(f"ALTER TABLE transactions ADD COLUMN {col_name} {col_type} DEFAULT NULL"))
181
- else:
182
- conn.execute(text(f"ALTER TABLE transactions ADD COLUMN {col_name} {col_type}"))
183
- logger.info(f"[DB Migration] 添加列 transactions.{col_name}")
 
 
 
 
 
 
 
 
 
184
 
185
  conn.commit()
186
 
 
110
  """
111
  🔄 P7后悔模式:自动迁移新增字段
112
  检查并添加 ownerships 表和 transactions 表的新字段
113
+ 所有 ALTER TABLE ADD COLUMN 均使用 IF NOT EXISTS 或 inspector 预检,避免重复执行报错
114
  """
115
  from sqlalchemy import inspect
116
 
 
123
 
124
  with engine.connect() as conn:
125
  if 'price_paid' not in columns:
126
+ try:
127
+ if 'sqlite' in SQLALCHEMY_DATABASE_URL:
128
+ conn.execute(text("ALTER TABLE ownerships ADD COLUMN price_paid INTEGER DEFAULT 0"))
129
+ else:
130
+ conn.execute(text("ALTER TABLE ownerships ADD COLUMN IF NOT EXISTS price_paid INTEGER DEFAULT 0"))
131
+ logger.info("迁移完成: 添加 ownerships.price_paid 字段")
132
+ except Exception as e:
133
+ logger.debug(f"跳过 ownerships.price_paid (可能已存在): {e}")
134
 
135
  if 'is_refunded' not in columns:
136
+ try:
137
+ if 'sqlite' in SQLALCHEMY_DATABASE_URL:
138
+ conn.execute(text("ALTER TABLE ownerships ADD COLUMN is_refunded BOOLEAN DEFAULT 0"))
139
+ else:
140
+ conn.execute(text("ALTER TABLE ownerships ADD COLUMN IF NOT EXISTS is_refunded BOOLEAN DEFAULT FALSE"))
141
+ logger.info("迁移完成: 添加 ownerships.is_refunded 字段")
142
+ except Exception as e:
143
+ logger.debug(f"跳过 ownerships.is_refunded (可能已存在): {e}")
144
 
145
  if 'refunded_at' not in columns:
146
+ try:
147
+ if 'sqlite' in SQLALCHEMY_DATABASE_URL:
148
+ conn.execute(text("ALTER TABLE ownerships ADD COLUMN refunded_at TIMESTAMP"))
149
+ else:
150
+ conn.execute(text("ALTER TABLE ownerships ADD COLUMN IF NOT EXISTS refunded_at TIMESTAMP"))
151
+ logger.info("迁移完成: 添加 ownerships.refunded_at 字段")
152
+ except Exception as e:
153
+ logger.debug(f"跳过 ownerships.refunded_at (可能已存在): {e}")
154
 
155
  conn.commit()
156
 
 
160
 
161
  with engine.connect() as conn:
162
  if 'task_balance' not in columns:
163
+ try:
164
+ if 'sqlite' in SQLALCHEMY_DATABASE_URL:
165
+ conn.execute(text("ALTER TABLE wallets ADD COLUMN task_balance INTEGER DEFAULT 0"))
166
+ else:
167
+ conn.execute(text("ALTER TABLE wallets ADD COLUMN IF NOT EXISTS task_balance INTEGER DEFAULT 0"))
168
+ logger.info("[DB Migration] 添加列 wallets.task_balance")
169
+ except Exception as e:
170
+ logger.debug(f"跳过 wallets.task_balance (可能已存在): {e}")
171
 
172
  conn.commit()
173
 
 
191
 
192
  for col_name, col_type in new_columns.items():
193
  if col_name not in columns:
194
+ try:
195
+ # VARCHAR 类型添加 DEFAULT NULL 避免 PostgreSQL NOT NULL 冲突
196
+ if col_type == 'VARCHAR':
197
+ if 'sqlite' in SQLALCHEMY_DATABASE_URL:
198
+ conn.execute(text(f"ALTER TABLE transactions ADD COLUMN {col_name} {col_type} DEFAULT NULL"))
199
+ else:
200
+ conn.execute(text(f"ALTER TABLE transactions ADD COLUMN IF NOT EXISTS {col_name} {col_type} DEFAULT NULL"))
201
+ else:
202
+ if 'sqlite' in SQLALCHEMY_DATABASE_URL:
203
+ conn.execute(text(f"ALTER TABLE transactions ADD COLUMN {col_name} {col_type}"))
204
+ else:
205
+ conn.execute(text(f"ALTER TABLE transactions ADD COLUMN IF NOT EXISTS {col_name} {col_type}"))
206
+ logger.info(f"[DB Migration] 添加列 transactions.{col_name}")
207
+ except Exception as e:
208
+ logger.debug(f"跳过 transactions.{col_name} (可能已存在): {e}")
209
 
210
  conn.commit()
211
 
image_moderation.py CHANGED
@@ -221,9 +221,9 @@ async def moderate_image_tencent(image_content: bytes) -> ModerationResult:
221
 
222
  except Exception as e:
223
  logger.error(f"腾讯云审核异常: {str(e)}")
224
- # 审核服务异常时默认放行避免影响正常业务
225
- return ModerationResult(passed=True, suggestion="pass",
226
- details={"error": str(e)})
227
 
228
  # ==========================================
229
  # 🟠 阿里云图片审核
@@ -367,9 +367,9 @@ async def moderate_image_aliyun(image_content: bytes) -> ModerationResult:
367
 
368
  except Exception as e:
369
  logger.error(f"阿里云审核异常: {str(e)}")
370
- # 审核服务异常时默认放行
371
- return ModerationResult(passed=True, suggestion="pass",
372
- details={"error": str(e)})
373
 
374
  # ==========================================
375
  # 🎯 统一审核入口(智能额度管理)
 
221
 
222
  except Exception as e:
223
  logger.error(f"腾讯云审核异常: {str(e)}")
224
+ # 审核服务异常时拒绝需要人工审核
225
+ return ModerationResult(passed=False, suggestion="review",
226
+ details={"error": "审核服务暂时不可用,需人工审核"})
227
 
228
  # ==========================================
229
  # 🟠 阿里云图片审核
 
367
 
368
  except Exception as e:
369
  logger.error(f"阿里云审核异常: {str(e)}")
370
+ # 审核服务异常时拒绝,需要人工审核
371
+ return ModerationResult(passed=False, suggestion="review",
372
+ details={"error": "审核服务暂时不可用,需人工审核"})
373
 
374
  # ==========================================
375
  # 🎯 统一审核入口(智能额度管理)
models.py CHANGED
@@ -1,7 +1,22 @@
1
  # models.py
 
2
  from pydantic import BaseModel, Field, validator
3
  from typing import Optional, List
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  class SendCodeRequest(BaseModel):
6
  contact: str
7
  contact_type: str
@@ -21,11 +36,16 @@ class UserRegister(BaseModel):
21
  country: Optional[str] = None
22
  region: Optional[str] = None
23
  intro: Optional[str] = None
 
 
 
24
 
25
  class UserLogin(BaseModel):
26
  account: str
27
  password: str
28
  remember: Optional[bool] = True # 保持登录选项:True=30天, False=24小时
 
 
29
 
30
  class UserUpdate(BaseModel):
31
  name: Optional[str] = None
@@ -45,6 +65,8 @@ class PasswordReset(BaseModel):
45
  verifyType: str
46
  code: str
47
  account: str
 
 
48
 
49
  class CommentCreate(BaseModel):
50
  item_id: str
 
1
  # models.py
2
+ import re
3
  from pydantic import BaseModel, Field, validator
4
  from typing import Optional, List
5
 
6
+ def _validate_email_format(email: str) -> str:
7
+ """邮箱格式校验"""
8
+ if not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', email):
9
+ raise ValueError('邮箱格式不正确')
10
+ return email
11
+
12
+ def _validate_password_length(password: str) -> str:
13
+ """密码长度校验:8-64字符"""
14
+ if len(password) < 8:
15
+ raise ValueError('密码长度不能少于8个字符')
16
+ if len(password) > 64:
17
+ raise ValueError('密码长度不能超过64个字符')
18
+ return password
19
+
20
  class SendCodeRequest(BaseModel):
21
  contact: str
22
  contact_type: str
 
36
  country: Optional[str] = None
37
  region: Optional[str] = None
38
  intro: Optional[str] = None
39
+
40
+ _validate_password = validator('password', allow_reuse=True)(_validate_password_length)
41
+ _validate_email = validator('email', allow_reuse=True)(_validate_email_format)
42
 
43
  class UserLogin(BaseModel):
44
  account: str
45
  password: str
46
  remember: Optional[bool] = True # 保持登录选项:True=30天, False=24小时
47
+
48
+ _validate_password = validator('password', allow_reuse=True)(_validate_password_length)
49
 
50
  class UserUpdate(BaseModel):
51
  name: Optional[str] = None
 
65
  verifyType: str
66
  code: str
67
  account: str
68
+
69
+ _validate_new_password = validator('new_password', allow_reuse=True)(_validate_password_length)
70
 
71
  class CommentCreate(BaseModel):
72
  item_id: str
notifications.py CHANGED
@@ -5,18 +5,27 @@ import 数据库连接 as db
5
  def add_notification(target_account: str, notif_data: dict):
6
  try:
7
  from_user = notif_data.get("from_user")
8
- if target_account == from_user or not target_account:
9
- return # 不给自己发通知
 
 
10
 
11
  users_db = db.load_data("users.json", default_data={})
12
- user_info = users_db.get(from_user, {})
 
 
 
 
 
 
 
13
 
14
  notif = {
15
  "id": f"msg_{int(time.time())}_{uuid.uuid4().hex[:6]}",
16
  "type": notif_data.get("type"),
17
  "from_user": from_user,
18
- "from_name": user_info.get("name", from_user),
19
- "from_avatar": user_info.get("avatarDataUrl", ""),
20
  "target_item_id": notif_data.get("target_item_id", ""),
21
  "target_item_title": notif_data.get("target_item_title", ""),
22
  "content": notif_data.get("content", ""),
 
5
  def add_notification(target_account: str, notif_data: dict):
6
  try:
7
  from_user = notif_data.get("from_user")
8
+ if not target_account:
9
+ return
10
+ if from_user and target_account == from_user:
11
+ return # 不给自己发通知
12
 
13
  users_db = db.load_data("users.json", default_data={})
14
+
15
+ if from_user == "system" or not from_user:
16
+ from_name = "系统通知"
17
+ from_avatar = ""
18
+ else:
19
+ user_info = users_db.get(from_user, {})
20
+ from_name = user_info.get("name", from_user)
21
+ from_avatar = user_info.get("avatarDataUrl", "")
22
 
23
  notif = {
24
  "id": f"msg_{int(time.time())}_{uuid.uuid4().hex[:6]}",
25
  "type": notif_data.get("type"),
26
  "from_user": from_user,
27
+ "from_name": from_name,
28
+ "from_avatar": from_avatar,
29
  "target_item_id": notif_data.get("target_item_id", ""),
30
  "target_item_title": notif_data.get("target_item_title", ""),
31
  "content": notif_data.get("content", ""),
rate_limiter.py CHANGED
@@ -78,6 +78,28 @@ def get_limit(action: str) -> str:
78
  user_request_counts = {}
79
  USER_LIMIT_WINDOW = 60 # 时间窗口(秒)
80
  USER_LIMIT_MAX = 100 # 每用户每分钟最大请求数
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
 
82
 
83
  def check_user_rate_limit(account: str) -> bool:
@@ -91,6 +113,10 @@ def check_user_rate_limit(account: str) -> bool:
91
  now = time.time()
92
  key = f"user:{account}"
93
 
 
 
 
 
94
  # 清理过期记录
95
  if key in user_request_counts:
96
  user_request_counts[key] = [
@@ -141,6 +167,10 @@ def record_request(ip: str, endpoint: str):
141
  now = time.time()
142
  key = f"{ip}:{endpoint}"
143
 
 
 
 
 
144
  if key not in ip_stats:
145
  ip_stats[key] = {"count": 0, "first_request": now}
146
 
 
78
  user_request_counts = {}
79
  USER_LIMIT_WINDOW = 60 # 时间窗口(秒)
80
  USER_LIMIT_MAX = 100 # 每用户每分钟最大请求数
81
+ MAX_DICT_SIZE = 10000 # 字典最大条目数
82
+
83
+
84
+ def cleanup_expired_entries():
85
+ """清理超过1小时的过期记录,防止内存泄漏"""
86
+ now = time.time()
87
+ # 清理 user_request_counts
88
+ expired_user_keys = [
89
+ k for k, v in list(user_request_counts.items())
90
+ if all(now - t > 3600 for t in v)
91
+ ]
92
+ for k in expired_user_keys:
93
+ del user_request_counts[k]
94
+ # 清理 ip_stats
95
+ expired_ip_keys = [
96
+ k for k, v in list(ip_stats.items())
97
+ if now - v.get("last_request", 0) > 3600
98
+ ]
99
+ for k in expired_ip_keys:
100
+ del ip_stats[k]
101
+ if expired_user_keys or expired_ip_keys:
102
+ logger.debug(f"CLEANUP | user_keys={len(expired_user_keys)}, ip_keys={len(expired_ip_keys)}")
103
 
104
 
105
  def check_user_rate_limit(account: str) -> bool:
 
113
  now = time.time()
114
  key = f"user:{account}"
115
 
116
+ # 超出字典上限时触发清理
117
+ if len(user_request_counts) >= MAX_DICT_SIZE:
118
+ cleanup_expired_entries()
119
+
120
  # 清理过期记录
121
  if key in user_request_counts:
122
  user_request_counts[key] = [
 
167
  now = time.time()
168
  key = f"{ip}:{endpoint}"
169
 
170
+ # 超出字典上限时触发清理
171
+ if len(ip_stats) >= MAX_DICT_SIZE:
172
+ cleanup_expired_entries()
173
+
174
  if key not in ip_stats:
175
  ip_stats[key] = {"count": 0, "first_request": now}
176
 
router_comments.py CHANGED
@@ -2,6 +2,7 @@
2
  from fastapi import APIRouter, HTTPException, Depends
3
  import time
4
  import uuid
 
5
  import 数据库连接 as db
6
  from notifications import add_notification
7
  from models import InteractionToggle, CommentCreate
@@ -10,7 +11,8 @@ from 安全认证 import require_auth, is_admin
10
  router = APIRouter()
11
 
12
  @router.post("/api/interactions/toggle")
13
- async def toggle_interaction(interaction: InteractionToggle):
 
14
  items_db = db.load_data("items.json", default_data=[])
15
  target_item = next((item for item in items_db if item["id"] == interaction.item_id), None)
16
  if not target_item: raise HTTPException(status_code=404, detail="目标存在问题")
@@ -33,7 +35,13 @@ async def toggle_interaction(interaction: InteractionToggle):
33
  return {"status": "success", "new_count": target_item[count_key]}
34
 
35
  @router.post("/api/comments")
36
- async def post_comment(comment: CommentCreate):
 
 
 
 
 
 
37
  comments_db = db.load_data("comments.json", default_data={})
38
  users_db = db.load_data("users.json", default_data={})
39
  author_info = users_db.get(comment.author, {})
@@ -147,8 +155,22 @@ async def soft_delete_comment(item_id: str, comment_id: str, account: str = Depe
147
  return True
148
  return False
149
 
150
- mark_deleted(item_comments)
151
- comments_db[item_id] = item_comments
152
- db.save_data("comments.json", comments_db)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
 
154
  return {"status": "success"}
 
2
  from fastapi import APIRouter, HTTPException, Depends
3
  import time
4
  import uuid
5
+ from html import escape
6
  import 数据库连接 as db
7
  from notifications import add_notification
8
  from models import InteractionToggle, CommentCreate
 
11
  router = APIRouter()
12
 
13
  @router.post("/api/interactions/toggle")
14
+ async def toggle_interaction(interaction: InteractionToggle, current_user: str = Depends(require_auth)):
15
+ interaction.user_id = current_user # 强制使用当前用户身份,防止伪造
16
  items_db = db.load_data("items.json", default_data=[])
17
  target_item = next((item for item in items_db if item["id"] == interaction.item_id), None)
18
  if not target_item: raise HTTPException(status_code=404, detail="目标存在问题")
 
35
  return {"status": "success", "new_count": target_item[count_key]}
36
 
37
  @router.post("/api/comments")
38
+ async def post_comment(comment: CommentCreate, current_user: str = Depends(require_auth)):
39
+ comment.author = current_user # 强制使用认证用户身份,防止伪造
40
+ # 评论内容长度校验
41
+ if not comment.content or len(comment.content) > 5000:
42
+ raise HTTPException(status_code=400, detail="评论长度必须在1-5000字符之间")
43
+ # HTML 转义,防止 XSS
44
+ comment.content = escape(comment.content)
45
  comments_db = db.load_data("comments.json", default_data={})
46
  users_db = db.load_data("users.json", default_data={})
47
  author_info = users_db.get(comment.author, {})
 
155
  return True
156
  return False
157
 
158
+ # 原子操作软删除,防止并发覆盖
159
+ max_retries = 3
160
+ for attempt in range(max_retries):
161
+ try:
162
+ comments_db = db.load_data("comments.json", default_data={})
163
+ item_comments = comments_db.get(item_id, [])
164
+ if not find_comment(item_comments):
165
+ raise HTTPException(status_code=404, detail="找不到该评论")
166
+ mark_deleted(item_comments)
167
+ comments_db[item_id] = item_comments
168
+ db.save_data("comments.json", comments_db)
169
+ break
170
+ except HTTPException:
171
+ raise
172
+ except Exception:
173
+ if attempt == max_retries - 1:
174
+ raise
175
 
176
  return {"status": "success"}
router_items.py CHANGED
@@ -16,6 +16,12 @@ from db_utils import record_view, sort_cache
16
 
17
  router = APIRouter()
18
 
 
 
 
 
 
 
19
  def _get_version_str(versions_db: dict, item_id: str) -> str:
20
  """兼容新旧格式获取版本hash字符串"""
21
  val = versions_db.get(item_id, "")
@@ -126,11 +132,14 @@ def _build_creator_trend_data(account: str, u_items: list, months: list) -> dict
126
  itype = i.get("type", "")
127
  history = i.get("use_history", {})
128
  if itype == "tool" or itype == "recommend_tool":
129
- for m in months: trend_tools[m] += history.get(m, 0)
 
130
  elif itype == "app" or itype == "recommend_app":
131
- for m in months: trend_apps[m] += history.get(m, 0)
 
132
  elif itype.startswith("recommend"):
133
- for m in months: trend_recommends[m] += history.get(m, 0)
 
134
 
135
  return {
136
  "months": months,
@@ -171,7 +180,7 @@ async def search_creators(keyword: str, sort: str = "downloads", limit: int = 50
171
  short_desc = u.get("shortDesc") or u.get("intro") or ""
172
 
173
  # 不区分大小写的子串匹配(同时覆盖 shortDesc 和 intro 两个字段)
174
- search_text = f"{name} {account} {short_desc} {u.get('intro') or ''} {u.get('shortDesc') or ''}".lower()
175
  if keyword_lower not in search_text:
176
  continue
177
 
 
16
 
17
  router = APIRouter()
18
 
19
+ def safe_str(val):
20
+ """安全转换为字符串"""
21
+ if val is None:
22
+ return ""
23
+ return str(val).lower()
24
+
25
  def _get_version_str(versions_db: dict, item_id: str) -> str:
26
  """兼容新旧格式获取版本hash字符串"""
27
  val = versions_db.get(item_id, "")
 
132
  itype = i.get("type", "")
133
  history = i.get("use_history", {})
134
  if itype == "tool" or itype == "recommend_tool":
135
+ for m, v in history.items():
136
+ if m in trend_tools: trend_tools[m] += v
137
  elif itype == "app" or itype == "recommend_app":
138
+ for m, v in history.items():
139
+ if m in trend_apps: trend_apps[m] += v
140
  elif itype.startswith("recommend"):
141
+ for m, v in history.items():
142
+ if m in trend_recommends: trend_recommends[m] += v
143
 
144
  return {
145
  "months": months,
 
180
  short_desc = u.get("shortDesc") or u.get("intro") or ""
181
 
182
  # 不区分大小写的子串匹配(同时覆盖 shortDesc 和 intro 两个字段)
183
+ search_text = f"{safe_str(name)} {safe_str(account)} {safe_str(short_desc)} {safe_str(u.get('intro'))} {safe_str(u.get('shortDesc'))}"
184
  if keyword_lower not in search_text:
185
  continue
186
 
router_messages.py CHANGED
@@ -102,14 +102,19 @@ async def run_admin_script(req: AdminScriptRequest, current_user: str = Depends(
102
  }
103
 
104
  try:
105
- # 执行脚本设置超时 60 秒
 
 
 
 
106
  result = subprocess.run(
107
  ["python", script_path],
108
  capture_output=True,
109
  text=True,
110
- timeout=60,
111
  cwd=current_dir,
112
- encoding="utf-8"
 
113
  )
114
 
115
  output = ""
@@ -119,6 +124,10 @@ async def run_admin_script(req: AdminScriptRequest, current_user: str = Depends(
119
  output += f"\n⚠️ 错误输出:\n{result.stderr}"
120
  if not output:
121
  output = "✅ 脚本执行完成,无输出"
 
 
 
 
122
 
123
  return {
124
  "status": "success" if result.returncode == 0 else "error",
@@ -129,7 +138,7 @@ async def run_admin_script(req: AdminScriptRequest, current_user: str = Depends(
129
  except subprocess.TimeoutExpired:
130
  return {
131
  "status": "error",
132
- "output": "❌ 脚本执行超时 (60秒)"
133
  }
134
  except Exception as e:
135
  return {
@@ -141,7 +150,8 @@ async def run_admin_script(req: AdminScriptRequest, current_user: str = Depends(
141
  # 原有功能:私信与聊天
142
  # ==========================================
143
  @router.post("/api/messages/private")
144
- async def send_private_message(msg: PrivateMessage):
 
145
  chats_db = db.load_data("chats.json", default_data={})
146
  conv_id = f"{min(msg.sender, msg.receiver)}_{max(msg.sender, msg.receiver)}"
147
  if conv_id not in chats_db: chats_db[conv_id] = []
@@ -154,7 +164,9 @@ async def send_private_message(msg: PrivateMessage):
154
  return {"status": "success"}
155
 
156
  @router.get("/api/chats/{account}")
157
- async def get_chat_list(account: str):
 
 
158
  chats_db = db.load_data("chats.json", default_data={})
159
  users_db = db.load_data("users.json", default_data={})
160
 
@@ -179,7 +191,9 @@ async def get_chat_list(account: str):
179
  return {"status": "success", "data": chat_list}
180
 
181
  @router.get("/api/chats/{account}/{target_account}")
182
- async def get_chat_history(account: str, target_account: str):
 
 
183
  chats_db = db.load_data("chats.json", default_data={})
184
  conv_id = f"{min(account, target_account)}_{max(account, target_account)}"
185
  msgs = chats_db.get(conv_id, [])
 
102
  }
103
 
104
  try:
105
+ # 清理敏感环境变量防止泄露到子进程
106
+ sensitive_prefixes = ('GITHUB', 'JWT', 'HF_', 'DATABASE', 'ALIPAY', 'PASSWORD')
107
+ clean_env = {k: v for k, v in os.environ.items()
108
+ if not any(k.startswith(prefix) for prefix in sensitive_prefixes)}
109
+ # 执行脚本,超时缩短至30秒
110
  result = subprocess.run(
111
  ["python", script_path],
112
  capture_output=True,
113
  text=True,
114
+ timeout=30,
115
  cwd=current_dir,
116
+ encoding="utf-8",
117
+ env=clean_env
118
  )
119
 
120
  output = ""
 
124
  output += f"\n⚠️ 错误输出:\n{result.stderr}"
125
  if not output:
126
  output = "✅ 脚本执行完成,无输出"
127
+
128
+ # 限制输出大小防止返回大量数据
129
+ if len(output) > 1000:
130
+ output = output[:1000] + "\n...(输出已截断)"
131
 
132
  return {
133
  "status": "success" if result.returncode == 0 else "error",
 
138
  except subprocess.TimeoutExpired:
139
  return {
140
  "status": "error",
141
+ "output": "❌ 脚本执行超时 (30秒)"
142
  }
143
  except Exception as e:
144
  return {
 
150
  # 原有功能:私信与聊天
151
  # ==========================================
152
  @router.post("/api/messages/private")
153
+ async def send_private_message(msg: PrivateMessage, current_user: str = Depends(require_auth)):
154
+ msg.sender = current_user # 强制发送者身份,防止伪造
155
  chats_db = db.load_data("chats.json", default_data={})
156
  conv_id = f"{min(msg.sender, msg.receiver)}_{max(msg.sender, msg.receiver)}"
157
  if conv_id not in chats_db: chats_db[conv_id] = []
 
164
  return {"status": "success"}
165
 
166
  @router.get("/api/chats/{account}")
167
+ async def get_chat_list(account: str, current_user: str = Depends(require_auth)):
168
+ if account != current_user:
169
+ raise HTTPException(status_code=403, detail="无权访问他人私信")
170
  chats_db = db.load_data("chats.json", default_data={})
171
  users_db = db.load_data("users.json", default_data={})
172
 
 
191
  return {"status": "success", "data": chat_list}
192
 
193
  @router.get("/api/chats/{account}/{target_account}")
194
+ async def get_chat_history(account: str, target_account: str, current_user: str = Depends(require_auth)):
195
+ if account != current_user:
196
+ raise HTTPException(status_code=403, detail="无权访问他人聊天记录")
197
  chats_db = db.load_data("chats.json", default_data={})
198
  conv_id = f"{min(account, target_account)}_{max(account, target_account)}"
199
  msgs = chats_db.get(conv_id, [])
router_posts.py CHANGED
@@ -425,6 +425,9 @@ async def tip_post(post_id: str, amount: int, is_anon: bool = False, current_use
425
  """
426
  if amount <= 0:
427
  raise HTTPException(status_code=400, detail="打赏金额必须大于0")
 
 
 
428
 
429
  result_container = [None]
430
  author_account = [None] # 用于在原子操作外获取作者账号
 
425
  """
426
  if amount <= 0:
427
  raise HTTPException(status_code=400, detail="打赏金额必须大于0")
428
+ MAX_TIP_AMOUNT = 100000
429
+ if amount > MAX_TIP_AMOUNT:
430
+ raise HTTPException(status_code=400, detail=f"打赏金额必须在1-{MAX_TIP_AMOUNT}之间")
431
 
432
  result_container = [None]
433
  author_account = [None] # 用于在原子操作外获取作者账号
router_proxy.py CHANGED
@@ -4,6 +4,8 @@ from fastapi.responses import StreamingResponse, JSONResponse, Response
4
  from sqlalchemy.orm import Session
5
  from pydantic import BaseModel, Field, field_validator, HttpUrl
6
  from typing import Optional
 
 
7
  import httpx
8
  import os
9
  import re
@@ -145,10 +147,14 @@ async def proxy_github_zip(req_data: ProxyGithubZipRequest, db: Session = Depend
145
  # 3. 异步请求 GitHub API 并以流形式透传回客户端 (防内存打爆)
146
  client = httpx.AsyncClient(follow_redirects=True)
147
  try:
148
- response = await client.send(
149
- client.build_request("GET", github_zip_api, headers=headers),
150
- stream=True
151
- )
 
 
 
 
152
 
153
  # 非 200 直接返回错误,避免 Content-Length 不匹配导致 h11 协议错误
154
  if response.status_code != 200:
@@ -215,9 +221,24 @@ class ProxyDownloadRequest(BaseModel):
215
  @field_validator('url')
216
  @classmethod
217
  def validate_url(cls, v: str) -> str:
218
- """验证URL格式"""
219
  if not v.startswith(('http://', 'https://')):
220
  raise ValueError('url必须是有效的HTTP或HTTPS地址')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  return v
222
 
223
  @router.post("/api/proxy_download")
 
4
  from sqlalchemy.orm import Session
5
  from pydantic import BaseModel, Field, field_validator, HttpUrl
6
  from typing import Optional
7
+ from urllib.parse import urlparse
8
+ import ipaddress
9
  import httpx
10
  import os
11
  import re
 
147
  # 3. 异步请求 GitHub API 并以流形式透传回客户端 (防内存打爆)
148
  client = httpx.AsyncClient(follow_redirects=True)
149
  try:
150
+ try:
151
+ response = await client.send(
152
+ client.build_request("GET", github_zip_api, headers=headers),
153
+ stream=True
154
+ )
155
+ except Exception as send_err:
156
+ await client.aclose()
157
+ return JSONResponse(content={"error": f"代理下载时发生网络异常:{str(send_err)}"}, status_code=500)
158
 
159
  # 非 200 直接返回错误,避免 Content-Length 不匹配导致 h11 协议错误
160
  if response.status_code != 200:
 
221
  @field_validator('url')
222
  @classmethod
223
  def validate_url(cls, v: str) -> str:
224
+ """验证URL格式并阻止内网地址"""
225
  if not v.startswith(('http://', 'https://')):
226
  raise ValueError('url必须是有效的HTTP或HTTPS地址')
227
+
228
+ parsed = urlparse(v)
229
+ # 阻止内网地址
230
+ dangerous_hosts = ['localhost', '127.0.0.1', '0.0.0.0', '::1']
231
+ if parsed.hostname in dangerous_hosts:
232
+ raise ValueError('不允许访问本地地址')
233
+
234
+ # 检查是否是私有IP
235
+ try:
236
+ ip = ipaddress.ip_address(parsed.hostname)
237
+ if ip.is_private or ip.is_loopback or ip.is_reserved:
238
+ raise ValueError('不允许访问内网地址')
239
+ except ValueError:
240
+ pass # hostname不是IP,允许继续
241
+
242
  return v
243
 
244
  @router.post("/api/proxy_download")
router_tasks.py CHANGED
@@ -20,6 +20,7 @@ from notifications import add_notification
20
  from database_sql import get_db
21
  from models_sql import Wallet, Transaction
22
  from db_utils import record_view, sort_cache
 
23
  import time
24
  import uuid
25
  import hashlib
@@ -78,7 +79,7 @@ def create_task_transaction(db_session: Session, account: str, tx_type: str, amo
78
  )
79
  db_session.add(new_tx)
80
 
81
- logger.info(f"TASK_TX | type={tx_type} | account={account} | amount={amount} | task={task_id} | tx={tx_id}")
82
  return tx_id
83
 
84
  # ==========================================
@@ -218,7 +219,7 @@ def check_and_update_expired_tasks(tasks_db, db_session=None):
218
  "target_item_title": item["title"],
219
  "content": f"💰 任务《{item['title']}》已过期自动取消,{item['amount']}积分已退还"
220
  })
221
- logger.info(f"TASK_REFUND | publisher={item['publisher']} | task={item['task_id']} | amount={item['amount']}")
222
  except Exception as e:
223
  logger.warning(f"TASK_REFUND_NOTIFY_ERROR | task={item['task_id']} | error={str(e)}")
224
 
@@ -383,8 +384,8 @@ async def create_task(task: TaskCreate, current_user: str = Depends(require_auth
383
 
384
  new_task = {
385
  "id": task_id,
386
- "title": task.title,
387
- "description": task.description,
388
  "reference_images": (task.referenceImages or [])[:6],
389
  "reference_link": task.referenceLink,
390
  "total_price": task.totalPrice,
@@ -435,7 +436,7 @@ async def create_task(task: TaskCreate, current_user: str = Depends(require_auth
435
  )
436
  db_session.commit()
437
 
438
- logger.info(f"TASK_CREATE | publisher={current_user} | task={task_id} | price={task.totalPrice} | frozen={task.totalPrice}")
439
 
440
  return {"status": "success", "data": new_task, "frozen_amount": task.totalPrice}
441
 
@@ -543,7 +544,7 @@ async def cancel_task(task_id: str, current_user: str = Depends(require_auth), d
543
  )
544
  db_session.commit()
545
  refund_success = True
546
- logger.info(f"TASK_CANCEL_REFUND | user={current_user} | task={task_id} | amount={frozen_amount}")
547
  except Exception as e:
548
  db_session.rollback()
549
  logger.error(f"TASK_CANCEL_REFUND_ERROR | task={task_id} | error={str(e)}")
@@ -708,7 +709,7 @@ async def assign_task(task_id: str, assignee: str, current_user: str = Depends(r
708
  "content": f"您已被选为任务《{task.get('title', '')}》的接单者,订金{deposit}积分已缓冲"
709
  })
710
 
711
- logger.info(f"TASK_ASSIGN | publisher={current_user} | assignee={assignee} | task={task_id} | deposit={deposit}")
712
 
713
  return {"status": "success", "message": f"已指派 {assignee} 接单,订金 {deposit} 积分已开始缓冲"}
714
 
@@ -843,7 +844,7 @@ async def accept_task(task_id: str, is_accepted: bool, feedback: str = None, cur
843
  except Exception as e:
844
  logger.warning(f"TASK_COMPLETE_JSON_SAVE_FAILED | 资金已结算但任务状态未更新,需手动修复: {str(e)}")
845
 
846
- logger.info(f"TASK_COMPLETE | publisher={current_user} | assignee={assignee_account} | task={task_id} | total={total_price}")
847
  # 🗂️ 清除排序缓存(任务状态变化)
848
  sort_cache.invalidate("tasks:")
849
  message = f"验收通过,已支付 {total_price} 积分给接单者"
@@ -1584,7 +1585,7 @@ async def tip_task(task_id: str, amount: int, is_anon: bool = False, current_use
1584
  db_session.commit()
1585
 
1586
  # 📝 审计日志
1587
- logger.info(f"TASK_TIP | from={current_user} | to={publisher} | amount={amount} | task={task_id} | anon={is_anon}")
1588
 
1589
  # 🔔 打赏通知(考虑匿名)
1590
  if not is_anon:
@@ -1612,7 +1613,7 @@ async def tip_task(task_id: str, amount: int, is_anon: bool = False, current_use
1612
  raise
1613
  except Exception as e:
1614
  db_session.rollback()
1615
- logger.error(f"TASK_TIP_ERROR | from={current_user} | task={task_id} | amount={amount} | error={str(e)}")
1616
  raise HTTPException(status_code=500, detail="打赏处理失败,请稍后重试")
1617
 
1618
  return result
 
20
  from database_sql import get_db
21
  from models_sql import Wallet, Transaction
22
  from db_utils import record_view, sort_cache
23
+ from html import escape
24
  import time
25
  import uuid
26
  import hashlib
 
79
  )
80
  db_session.add(new_tx)
81
 
82
+ logger.info(f"TASK_TX | type={tx_type} | account_hash={hash(account)} | amount={amount} | task={task_id} | tx={tx_id}")
83
  return tx_id
84
 
85
  # ==========================================
 
219
  "target_item_title": item["title"],
220
  "content": f"💰 任务《{item['title']}》已过期自动取消,{item['amount']}积分已退还"
221
  })
222
+ logger.info(f"TASK_REFUND | task={item['task_id']} | amount={item['amount']}")
223
  except Exception as e:
224
  logger.warning(f"TASK_REFUND_NOTIFY_ERROR | task={item['task_id']} | error={str(e)}")
225
 
 
384
 
385
  new_task = {
386
  "id": task_id,
387
+ "title": escape(task.title),
388
+ "description": escape(task.description),
389
  "reference_images": (task.referenceImages or [])[:6],
390
  "reference_link": task.referenceLink,
391
  "total_price": task.totalPrice,
 
436
  )
437
  db_session.commit()
438
 
439
+ logger.info(f"TASK_CREATE | task={task_id} | price={task.totalPrice} | frozen={task.totalPrice}")
440
 
441
  return {"status": "success", "data": new_task, "frozen_amount": task.totalPrice}
442
 
 
544
  )
545
  db_session.commit()
546
  refund_success = True
547
+ logger.info(f"TASK_CANCEL_REFUND | task={task_id} | amount={frozen_amount}")
548
  except Exception as e:
549
  db_session.rollback()
550
  logger.error(f"TASK_CANCEL_REFUND_ERROR | task={task_id} | error={str(e)}")
 
709
  "content": f"您已被选为任务《{task.get('title', '')}》的接单者,订金{deposit}积分已缓冲"
710
  })
711
 
712
+ logger.info(f"TASK_ASSIGN | task={task_id} | deposit={deposit}")
713
 
714
  return {"status": "success", "message": f"已指派 {assignee} 接单,订金 {deposit} 积分已开始缓冲"}
715
 
 
844
  except Exception as e:
845
  logger.warning(f"TASK_COMPLETE_JSON_SAVE_FAILED | 资金已结算但任务状态未更新,需手动修复: {str(e)}")
846
 
847
+ logger.info(f"TASK_COMPLETE | task={task_id} | total={total_price}")
848
  # 🗂️ 清除排序缓存(任务状态变化)
849
  sort_cache.invalidate("tasks:")
850
  message = f"验收通过,已支付 {total_price} 积分给接单者"
 
1585
  db_session.commit()
1586
 
1587
  # 📝 审计日志
1588
+ logger.info(f"TASK_TIP | task={task_id} | amount={amount} | anon={is_anon}")
1589
 
1590
  # 🔔 打赏通知(考虑匿名)
1591
  if not is_anon:
 
1613
  raise
1614
  except Exception as e:
1615
  db_session.rollback()
1616
+ logger.error(f"TASK_TIP_ERROR | task={task_id} | amount={amount} | error={str(e)}")
1617
  raise HTTPException(status_code=500, detail="打赏处理失败,请稍后重试")
1618
 
1619
  return result
router_users_auth.py CHANGED
@@ -11,6 +11,7 @@
11
  # ==========================================
12
 
13
  from fastapi import APIRouter, HTTPException, BackgroundTasks, Request
 
14
  import time
15
  import re
16
  import random
@@ -22,6 +23,9 @@ from verify_code_engine import VERIFY_CODES, send_email_code, send_sms_code, cle
22
  # 🔒 P0安全增强:导入密码哈希和 JWT 工具
23
  from 安全认证 import hash_password, verify_password, create_token, require_password_match
24
 
 
 
 
25
  # 🚀 P2优化:速率限制
26
  from slowapi import Limiter
27
  from slowapi.util import get_remote_address
@@ -160,6 +164,11 @@ async def register_user(request: Request, user: UserRegister):
160
  if user.account in users_db:
161
  raise HTTPException(status_code=400, detail="该账号已被注册,请更换一个")
162
 
 
 
 
 
 
163
  # 检查邮箱和手机号是否已被其他用户绑定
164
  for existing_user in users_db.values():
165
  if user.email and existing_user.get("email") == user.email:
@@ -167,11 +176,12 @@ async def register_user(request: Request, user: UserRegister):
167
  if user.phone and existing_user.get("phone") == user.phone:
168
  raise HTTPException(status_code=400, detail="该手机号已被绑定")
169
 
170
- # ========== 第二步:验证码校验(原子性获取+删除) ==========
171
  # 根据注册方式构建缓存键
172
  cache_key = f"{user.email}_register" if user.email else f"{user.phone}_register"
173
- # 🔒 P0安全修复:验证码一次性使用,原子性pop防止并发重用
174
- cached = VERIFY_CODES.pop(cache_key, None)
 
175
 
176
  # 兼容新老缓存格式(expires_at 或 expires)
177
  expire_time = cached.get("expires_at", cached.get("expires", 0)) if cached else 0
@@ -243,14 +253,15 @@ async def login_user(request: Request, user: UserLogin):
243
  raise HTTPException(status_code=404, detail="账号不存在")
244
 
245
  user_data = users_db[user.account]
246
- stored_password = user_data.get("password", "")
247
 
248
  # 🔒 P0安全增强:密码哈希验证(使用统一验证函数)
249
  if not require_password_match(stored_password, user.password):
250
  raise HTTPException(status_code=401, detail="密码错误")
251
 
252
  # 🔒 P0安全增强:登录成功后,检查是否需要迁移旧密码为bcrypt
253
- if not user_data["password"].startswith('$2b$') and not user_data["password"].startswith('$2a$'):
 
254
  # 旧版SHA256密码,自动迁移为bcrypt
255
  user_data["password"] = hash_password(user.password)
256
  db.save_data("users.json", users_db)
 
11
  # ==========================================
12
 
13
  from fastapi import APIRouter, HTTPException, BackgroundTasks, Request
14
+ import asyncio
15
  import time
16
  import re
17
  import random
 
23
  # 🔒 P0安全增强:导入密码哈希和 JWT 工具
24
  from 安全认证 import hash_password, verify_password, create_token, require_password_match
25
 
26
+ # 🔒 验证码并发保护锁
27
+ _verify_code_lock = asyncio.Lock()
28
+
29
  # 🚀 P2优化:速率限制
30
  from slowapi import Limiter
31
  from slowapi.util import get_remote_address
 
164
  if user.account in users_db:
165
  raise HTTPException(status_code=400, detail="该账号已被注册,请更换一个")
166
 
167
+ # 🔒 禁止保留账号名
168
+ RESERVED_ACCOUNTS = {"admin", "system", "root", "test", "anonymous", "administrator"}
169
+ if user.account.lower() in RESERVED_ACCOUNTS:
170
+ raise HTTPException(status_code=400, detail="该账号名不可用")
171
+
172
  # 检查邮箱和手机号是否已被其他用户绑定
173
  for existing_user in users_db.values():
174
  if user.email and existing_user.get("email") == user.email:
 
176
  if user.phone and existing_user.get("phone") == user.phone:
177
  raise HTTPException(status_code=400, detail="该手机号已被绑定")
178
 
179
+ # ========== 第二步:验证码校验(并发安全) ==========
180
  # 根据注册方式构建缓存键
181
  cache_key = f"{user.email}_register" if user.email else f"{user.phone}_register"
182
+ # 🔒 P0安全修复:验证码一次性使用,并发安全键防止并发重用
183
+ async with _verify_code_lock:
184
+ cached = VERIFY_CODES.pop(cache_key, None)
185
 
186
  # 兼容新老缓存格式(expires_at 或 expires)
187
  expire_time = cached.get("expires_at", cached.get("expires", 0)) if cached else 0
 
253
  raise HTTPException(status_code=404, detail="账号不存在")
254
 
255
  user_data = users_db[user.account]
256
+ stored_password = user_data.get("password") or ""
257
 
258
  # 🔒 P0安全增强:密码哈希验证(使用统一验证函数)
259
  if not require_password_match(stored_password, user.password):
260
  raise HTTPException(status_code=401, detail="密码错误")
261
 
262
  # 🔒 P0安全增强:登录成功后,检查是否需要迁移旧密码为bcrypt
263
+ # 只在旧格式密码验证成功后才进行迁移(require_password_match已通过)
264
+ if stored_password and not stored_password.startswith('$2b$') and not stored_password.startswith('$2a$'):
265
  # 旧版SHA256密码,自动迁移为bcrypt
266
  user_data["password"] = hash_password(user.password)
267
  db.save_data("users.json", users_db)
router_users_profile.py CHANGED
@@ -12,9 +12,10 @@
12
  # - 个人设置表单组件.js (更新用户资料)
13
  # ==========================================
14
 
15
- from fastapi import APIRouter, HTTPException
16
  import 数据库连接 as db
17
  from models import UserUpdate
 
18
 
19
  # 创建子路由实例
20
  router = APIRouter()
@@ -83,7 +84,7 @@ async def get_user_profile(account: str):
83
  # 前端调用:
84
  # - 个人设置表单组件.js 的 handleSaveProfile()
85
  @router.put("/api/users/{account}")
86
- async def update_user_profile(account: str, update_data: UserUpdate):
87
  """
88
  更新用户资料接口
89
 
@@ -98,6 +99,10 @@ async def update_user_profile(account: str, update_data: UserUpdate):
98
  - country: 国家
99
  - region: 地区
100
  """
 
 
 
 
101
  # 加载用户数据库
102
  users_db = db.load_data("users.json", default_data={})
103
 
 
12
  # - 个人设置表单组件.js (更新用户资料)
13
  # ==========================================
14
 
15
+ from fastapi import APIRouter, HTTPException, Depends
16
  import 数据库连接 as db
17
  from models import UserUpdate
18
+ from 安全认证 import require_auth
19
 
20
  # 创建子路由实例
21
  router = APIRouter()
 
84
  # 前端调用:
85
  # - 个人设置表单组件.js 的 handleSaveProfile()
86
  @router.put("/api/users/{account}")
87
+ async def update_user_profile(account: str, update_data: UserUpdate, current_user: str = Depends(require_auth)):
88
  """
89
  更新用户资料接口
90
 
 
99
  - country: 国家
100
  - region: 地区
101
  """
102
+ # 🔒 越权校验:只能更新自己的资料
103
+ if account != current_user:
104
+ raise HTTPException(status_code=403, detail="只能更新自己的资料")
105
+
106
  # 加载用户数据库
107
  users_db = db.load_data("users.json", default_data={})
108
 
router_users_social.py CHANGED
@@ -13,10 +13,11 @@
13
  # - 个人设置表单组件.js (隐私设置)
14
  # ==========================================
15
 
16
- from fastapi import APIRouter, HTTPException
17
  import 数据库连接 as db
18
  from notifications import add_notification
19
  from models import FollowToggle, PrivacySettings
 
20
 
21
  # 创建子路由实例
22
  router = APIRouter()
@@ -35,7 +36,7 @@ router = APIRouter()
35
  # 前端调用:
36
  # - 个人中心视图.js 的 handleFollowToggle()
37
  @router.post("/api/users/follow")
38
- async def toggle_follow(follow: FollowToggle):
39
  """
40
  关注/取消关注接口
41
 
@@ -44,6 +45,9 @@ async def toggle_follow(follow: FollowToggle):
44
  - target_account: 被关注/取关的目标用户账号
45
  - is_active: True=关注, False=取消关注
46
  """
 
 
 
47
  # 加载用户数据库
48
  users_db = db.load_data("users.json", default_data={})
49
 
@@ -103,7 +107,7 @@ async def toggle_follow(follow: FollowToggle):
103
  # 前端调用:
104
  # - 个人设置表单组件.js 的隐私设置区域
105
  @router.put("/api/users/{account}/privacy")
106
- async def update_privacy(account: str, privacy: PrivacySettings):
107
  """
108
  更新隐私设置接口
109
 
@@ -115,6 +119,10 @@ async def update_privacy(account: str, privacy: PrivacySettings):
115
  - favorites: 是否隐藏收藏记录 (True=隐藏)
116
  - downloads: 是否隐藏下载记录 (True=隐藏)
117
  """
 
 
 
 
118
  # 加载用户数据库
119
  users_db = db.load_data("users.json", default_data={})
120
 
 
13
  # - 个人设置表单组件.js (隐私设置)
14
  # ==========================================
15
 
16
+ from fastapi import APIRouter, HTTPException, Depends
17
  import 数据库连接 as db
18
  from notifications import add_notification
19
  from models import FollowToggle, PrivacySettings
20
+ from 安全认证 import require_auth
21
 
22
  # 创建子路由实例
23
  router = APIRouter()
 
36
  # 前端调用:
37
  # - 个人中心视图.js 的 handleFollowToggle()
38
  @router.post("/api/users/follow")
39
+ async def toggle_follow(follow: FollowToggle, current_user: str = Depends(require_auth)):
40
  """
41
  关注/取消关注接口
42
 
 
45
  - target_account: 被关注/取关的目标用户账号
46
  - is_active: True=关注, False=取消关注
47
  """
48
+ # 🔒 安全防护:强制使用当前认证用户,防止伪造身份
49
+ follow.user_id = current_user
50
+
51
  # 加载用户数据库
52
  users_db = db.load_data("users.json", default_data={})
53
 
 
107
  # 前端调用:
108
  # - 个人设置表单组件.js 的隐私设置区域
109
  @router.put("/api/users/{account}/privacy")
110
+ async def update_privacy(account: str, privacy: PrivacySettings, current_user: str = Depends(require_auth)):
111
  """
112
  更新隐私设置接口
113
 
 
119
  - favorites: 是否隐藏收藏记录 (True=隐藏)
120
  - downloads: 是否隐藏下载记录 (True=隐藏)
121
  """
122
+ # 🔒 越权校验:只能修改自己的隐私设置
123
+ if account != current_user:
124
+ raise HTTPException(status_code=403, detail="只能修改自己的隐私设置")
125
+
126
  # 加载用户数据库
127
  users_db = db.load_data("users.json", default_data={})
128
 
router_wallet.py CHANGED
@@ -107,7 +107,7 @@ try:
107
  # 3. 完美加载
108
  alipay = AliPay(
109
  appid=raw_appid,
110
- app_notify_url="https://zhiwei666-comfyui-ranking-api.hf.space/api/wallet/alipay_notify",
111
  app_private_key_string=priv_key_formatted,
112
  alipay_public_key_string=pub_key_formatted,
113
  sign_type="RSA2",
@@ -127,11 +127,11 @@ def calculate_tx_hash(tx_id, account, tx_type, amount, prev_hash):
127
  async def create_recharge_order(req: RechargeRequest, current_user: str = Depends(require_auth)):
128
  if not alipay:
129
  # 这里会将真实的错误原因直接弹窗发给前端!
130
- raise HTTPException(status_code=500, detail=f"支付网关配置错误: {alipay_error_msg}")
131
 
132
  # 🔒 使用已认证用户账号,忽略客户端传入的 account
133
  authenticated_account = current_user
134
- order_id = f"PAY_{int(time.time())}_{uuid.uuid4().hex[:6]}"
135
  subject = f"ComfyUI Community Points - {authenticated_account}"
136
 
137
  order_string = alipay.api_alipay_trade_precreate(
@@ -174,7 +174,7 @@ async def alipay_notify(request: Request, db: Session = Depends(get_db)):
174
  wallet = Wallet(account=account, balance=0, earn_balance=0, tip_balance=0, frozen_balance=0)
175
  db.add(wallet)
176
 
177
- wallet.balance = (wallet.balance or 0) + amount
178
 
179
  last_tx = db.query(Transaction).filter(Transaction.account == account).order_by(Transaction.created_at.desc()).first()
180
  prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
@@ -198,7 +198,7 @@ async def alipay_notify(request: Request, db: Session = Depends(get_db)):
198
  return Response(content="success", media_type="text/plain")
199
 
200
  @router.get("/api/wallet/check_order/{order_id}")
201
- async def check_order(order_id: str, account: str = None, db: Session = Depends(get_db)):
202
  # 防线 1:先查本地数据库(如果 Webhook 成功了,这里就能查到)
203
  tx = db.query(Transaction).filter(Transaction.tx_id == order_id).first()
204
  if tx:
@@ -224,7 +224,7 @@ async def check_order(order_id: str, account: str = None, db: Session = Depends(
224
  wallet = Wallet(account=account, balance=0, earn_balance=0, tip_balance=0, frozen_balance=0)
225
  db.add(wallet)
226
 
227
- wallet.balance = (wallet.balance or 0) + amount
228
 
229
  last_tx = db.query(Transaction).filter(Transaction.account == account).order_by(Transaction.created_at.desc()).first()
230
  prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
@@ -240,6 +240,7 @@ async def check_order(order_id: str, account: str = None, db: Session = Depends(
240
 
241
  return {"status": "SUCCESS"}
242
  except Exception as e:
 
243
  print(f"主动查单发生异常: {e}")
244
 
245
  # 如果没查到支付成功状态,继续让前端等
@@ -357,9 +358,9 @@ async def purchase_item(request: Request, req: PurchaseRequest, current_user: st
357
  seller_wallet = Wallet(account=seller_account, balance=0, earn_balance=0, tip_balance=0, frozen_balance=0)
358
  db.add(seller_wallet)
359
 
360
- buyer_wallet.balance = (buyer_wallet.balance or 0) - price
361
- seller_wallet.balance = (seller_wallet.balance or 0) + price # 实际收入进统一余额
362
- seller_wallet.earn_balance = (seller_wallet.earn_balance or 0) + price # 累计销售收益统计(只增不减)
363
 
364
  # 🔄 P7后悔模式:记录购买价格
365
  new_ownership = Ownership(account=req.account, item_id=req.item_id, price_paid=price)
@@ -438,13 +439,14 @@ async def purchase_item(request: Request, req: PurchaseRequest, current_user: st
438
  async def tip_user(request: Request, req: TipRequest, current_user: str = Depends(require_auth), db: Session = Depends(get_db)):
439
  # 🔒 使用已认证用户账号,忽略客户端传入的 sender_account
440
  req.sender_account = current_user
441
- if req.amount <= 0:
442
- raise HTTPException(status_code=400, detail="打赏金额必须大于0")
 
443
  if req.sender_account == req.target_account:
444
  raise HTTPException(status_code=400, detail="不能打赏给自己")
445
 
446
- # 🔒 P1幂等性防护:检查最近5秒内是否存在相同交易
447
- recent_cutoff = datetime.datetime.utcnow() - datetime.timedelta(seconds=5)
448
  duplicate_tx = db.query(Transaction).filter(
449
  Transaction.account == req.sender_account,
450
  Transaction.tx_type == "TIP_OUT",
@@ -466,9 +468,9 @@ async def tip_user(request: Request, req: TipRequest, current_user: str = Depend
466
  target_wallet = Wallet(account=req.target_account, balance=0, earn_balance=0, tip_balance=0, frozen_balance=0)
467
  db.add(target_wallet)
468
 
469
- sender_wallet.balance -= req.amount
470
- target_wallet.balance += req.amount # 实际收入进统一余额
471
- target_wallet.tip_balance += req.amount # 累计打赏收益统计(只增不减)
472
 
473
  tx_id_sender = f"TIP_OUT_{int(time.time())}_{uuid.uuid4().hex[:6]}"
474
  tx_id_target = f"TIP_IN_{int(time.time())}_{uuid.uuid4().hex[:6]}"
@@ -556,11 +558,19 @@ async def tip_user(request: Request, req: TipRequest, current_user: str = Depend
556
  u["tip_history"][current_month] = u["tip_history"].get(current_month, 0) + req.amount
557
 
558
  if "tip_board" not in u: u["tip_board"] = []
559
- sender_entry = next((x for x in u["tip_board"] if x["account"] == req.sender_account), None)
 
 
 
560
  if sender_entry:
561
  sender_entry["amount"] += req.amount
562
- else:
563
- u["tip_board"].append({"account": req.sender_account, "amount": req.amount, "is_anon": req.is_anonymous})
 
 
 
 
 
564
  u["tip_board"] = sorted(u["tip_board"], key=lambda x: x["amount"], reverse=True)
565
  json_db.save_data("users.json", users_db)
566
 
@@ -572,11 +582,19 @@ async def tip_user(request: Request, req: TipRequest, current_user: str = Depend
572
  item["tip_history"][current_month] = item["tip_history"].get(current_month, 0) + req.amount
573
 
574
  if "tip_board" not in item: item["tip_board"] = []
575
- sender_entry = next((x for x in item["tip_board"] if x["account"] == req.sender_account), None)
 
 
 
576
  if sender_entry:
577
  sender_entry["amount"] += req.amount
578
- else:
579
- item["tip_board"].append({"account": req.sender_account, "amount": req.amount, "is_anon": req.is_anonymous})
 
 
 
 
 
580
  item["tip_board"] = sorted(item["tip_board"], key=lambda x: x["amount"], reverse=True)
581
  json_db.save_data("items.json", items_db)
582
  break
@@ -611,11 +629,13 @@ async def withdraw(request: Request, req: WithdrawRequest, current_user: str = D
611
  raise HTTPException(status_code=400, detail="最小提现金额为1积分")
612
 
613
  # 🔒 验证码缓存键格式:{contact}_{action_type},与 send_code 接口一致
614
- # 先通过账号查询用户邮箱,再用邮箱构建缓存键
615
  users_db = json_db.load_data("users.json", default_data={})
616
  user_info = users_db.get(req.account, {})
617
- user_email = user_info.get("email", "")
618
- key = f"{user_email}_withdraw" if user_email else f"{req.account}_withdraw"
 
 
619
  code_data = VERIFY_CODES.get(key)
620
  # 🔒 P0安全修复:统一使用 expires_at 字段,兼容旧版 expires
621
  expire_time = code_data.get("expires_at", code_data.get("expires", 0)) if code_data else 0
@@ -1057,14 +1077,14 @@ async def refund_purchase(request: Request, item_id: str, current_user: str = De
1057
  seller_wallet = db.query(Wallet).filter(Wallet.account == seller_account).with_for_update().first()
1058
 
1059
  if seller_wallet:
1060
- # 先检查余额是否充足,再扣
1061
- if seller_wallet.balance < refund_amount:
1062
- raise HTTPException(status_code=400, detail="卖家余额不足,无法处理退款")
1063
- seller_wallet.balance -= refund_amount # 卖家扣回
1064
  # 不修改 earn_balance(保持"累计销售收益只增不减"语义)
1065
 
1066
  if buyer_wallet:
1067
- buyer_wallet.balance += refund_amount # 买家退款
1068
  else:
1069
  buyer_wallet = Wallet(account=account, balance=refund_amount)
1070
  db.add(buyer_wallet)
 
107
  # 3. 完美加载
108
  alipay = AliPay(
109
  appid=raw_appid,
110
+ app_notify_url=os.environ.get("ALIPAY_NOTIFY_URL", "https://zhiwei666-comfyui-ranking-api.hf.space/api/wallet/alipay_notify"),
111
  app_private_key_string=priv_key_formatted,
112
  alipay_public_key_string=pub_key_formatted,
113
  sign_type="RSA2",
 
127
  async def create_recharge_order(req: RechargeRequest, current_user: str = Depends(require_auth)):
128
  if not alipay:
129
  # 这里会将真实的错误原因直接弹窗发给前端!
130
+ raise HTTPException(status_code=500, detail="支付网关配置错误,请联系管理员")
131
 
132
  # 🔒 使用已认证用户账号,忽略客户端传入的 account
133
  authenticated_account = current_user
134
+ order_id = f"PAY_{uuid.uuid4().hex}"
135
  subject = f"ComfyUI Community Points - {authenticated_account}"
136
 
137
  order_string = alipay.api_alipay_trade_precreate(
 
174
  wallet = Wallet(account=account, balance=0, earn_balance=0, tip_balance=0, frozen_balance=0)
175
  db.add(wallet)
176
 
177
+ wallet.balance = int(wallet.balance or 0) + int(amount)
178
 
179
  last_tx = db.query(Transaction).filter(Transaction.account == account).order_by(Transaction.created_at.desc()).first()
180
  prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
 
198
  return Response(content="success", media_type="text/plain")
199
 
200
  @router.get("/api/wallet/check_order/{order_id}")
201
+ async def check_order(order_id: str, account: str = None, current_user: str = Depends(require_auth), db: Session = Depends(get_db)):
202
  # 防线 1:先查本地数据库(如果 Webhook 成功了,这里就能查到)
203
  tx = db.query(Transaction).filter(Transaction.tx_id == order_id).first()
204
  if tx:
 
224
  wallet = Wallet(account=account, balance=0, earn_balance=0, tip_balance=0, frozen_balance=0)
225
  db.add(wallet)
226
 
227
+ wallet.balance = int(wallet.balance or 0) + int(amount)
228
 
229
  last_tx = db.query(Transaction).filter(Transaction.account == account).order_by(Transaction.created_at.desc()).first()
230
  prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
 
240
 
241
  return {"status": "SUCCESS"}
242
  except Exception as e:
243
+ db.rollback()
244
  print(f"主动查单发生异常: {e}")
245
 
246
  # 如果没查到支付成功状态,继续让前端等
 
358
  seller_wallet = Wallet(account=seller_account, balance=0, earn_balance=0, tip_balance=0, frozen_balance=0)
359
  db.add(seller_wallet)
360
 
361
+ buyer_wallet.balance = int(buyer_wallet.balance or 0) - int(price)
362
+ seller_wallet.balance = int(seller_wallet.balance or 0) + int(price) # 实际收入进统一余额
363
+ seller_wallet.earn_balance = int(seller_wallet.earn_balance or 0) + int(price) # 累计销售收益统计(只增不减)
364
 
365
  # 🔄 P7后悔模式:记录购买价格
366
  new_ownership = Ownership(account=req.account, item_id=req.item_id, price_paid=price)
 
439
  async def tip_user(request: Request, req: TipRequest, current_user: str = Depends(require_auth), db: Session = Depends(get_db)):
440
  # 🔒 使用已认证用户账号,忽略客户端传入的 sender_account
441
  req.sender_account = current_user
442
+ MAX_TIP_AMOUNT = 100000
443
+ if req.amount <= 0 or req.amount > MAX_TIP_AMOUNT:
444
+ raise HTTPException(status_code=400, detail=f"打赏金额必须在1-{MAX_TIP_AMOUNT}之间")
445
  if req.sender_account == req.target_account:
446
  raise HTTPException(status_code=400, detail="不能打赏给自己")
447
 
448
+ # 🔒 P1幂等性防护:检查最近30秒内是否存在相同交易
449
+ recent_cutoff = datetime.datetime.utcnow() - datetime.timedelta(seconds=30)
450
  duplicate_tx = db.query(Transaction).filter(
451
  Transaction.account == req.sender_account,
452
  Transaction.tx_type == "TIP_OUT",
 
468
  target_wallet = Wallet(account=req.target_account, balance=0, earn_balance=0, tip_balance=0, frozen_balance=0)
469
  db.add(target_wallet)
470
 
471
+ sender_wallet.balance = int(sender_wallet.balance or 0) - int(req.amount)
472
+ target_wallet.balance = int(target_wallet.balance or 0) + int(req.amount) # 实际收入进统一余额
473
+ target_wallet.tip_balance = int(target_wallet.tip_balance or 0) + int(req.amount) # 累计打赏收益统计(只增不减)
474
 
475
  tx_id_sender = f"TIP_OUT_{int(time.time())}_{uuid.uuid4().hex[:6]}"
476
  tx_id_target = f"TIP_IN_{int(time.time())}_{uuid.uuid4().hex[:6]}"
 
558
  u["tip_history"][current_month] = u["tip_history"].get(current_month, 0) + req.amount
559
 
560
  if "tip_board" not in u: u["tip_board"] = []
561
+ if req.is_anonymous:
562
+ sender_entry = next((x for x in u["tip_board"] if x.get("account") is None and x.get("is_anon")), None)
563
+ else:
564
+ sender_entry = next((x for x in u["tip_board"] if x.get("account") == req.sender_account), None)
565
  if sender_entry:
566
  sender_entry["amount"] += req.amount
567
+ else:
568
+ tip_entry = {"amount": req.amount, "is_anon": req.is_anonymous}
569
+ if not req.is_anonymous:
570
+ tip_entry["account"] = req.sender_account
571
+ else:
572
+ tip_entry["account"] = None
573
+ u["tip_board"].append(tip_entry)
574
  u["tip_board"] = sorted(u["tip_board"], key=lambda x: x["amount"], reverse=True)
575
  json_db.save_data("users.json", users_db)
576
 
 
582
  item["tip_history"][current_month] = item["tip_history"].get(current_month, 0) + req.amount
583
 
584
  if "tip_board" not in item: item["tip_board"] = []
585
+ if req.is_anonymous:
586
+ sender_entry = next((x for x in item["tip_board"] if x.get("account") is None and x.get("is_anon")), None)
587
+ else:
588
+ sender_entry = next((x for x in item["tip_board"] if x.get("account") == req.sender_account), None)
589
  if sender_entry:
590
  sender_entry["amount"] += req.amount
591
+ else:
592
+ tip_entry = {"amount": req.amount, "is_anon": req.is_anonymous}
593
+ if not req.is_anonymous:
594
+ tip_entry["account"] = req.sender_account
595
+ else:
596
+ tip_entry["account"] = None
597
+ item["tip_board"].append(tip_entry)
598
  item["tip_board"] = sorted(item["tip_board"], key=lambda x: x["amount"], reverse=True)
599
  json_db.save_data("items.json", items_db)
600
  break
 
629
  raise HTTPException(status_code=400, detail="最小提现金额为1积分")
630
 
631
  # 🔒 验证码缓存键格式:{contact}_{action_type},与 send_code 接口一致
632
+ # 先通过账号查询用户邮箱或手机号,再用构建缓存键(不 fallback 到 account)
633
  users_db = json_db.load_data("users.json", default_data={})
634
  user_info = users_db.get(req.account, {})
635
+ user_email = user_info.get("email") or user_info.get("phone", "")
636
+ if not user_email:
637
+ raise HTTPException(status_code=400, detail="请先绑定邮箱或手机号")
638
+ key = f"{user_email}_withdraw"
639
  code_data = VERIFY_CODES.get(key)
640
  # 🔒 P0安全修复:统一使用 expires_at 字段,兼容旧版 expires
641
  expire_time = code_data.get("expires_at", code_data.get("expires", 0)) if code_data else 0
 
1077
  seller_wallet = db.query(Wallet).filter(Wallet.account == seller_account).with_for_update().first()
1078
 
1079
  if seller_wallet:
1080
+ # 直接扣减,允许余额为负(退优先于卖家余额安全)
1081
+ seller_wallet.balance = int(seller_wallet.balance or 0) - int(refund_amount) # 卖家扣回
1082
+ if seller_wallet.balance < 0:
1083
+ logger.warning(f"NEGATIVE_BALANCE | seller balance went negative after refund | seller={seller_account}")
1084
  # 不修改 earn_balance(保持"累计销售收益只增不减"语义)
1085
 
1086
  if buyer_wallet:
1087
+ buyer_wallet.balance = int(buyer_wallet.balance or 0) + int(refund_amount) # 买家退款
1088
  else:
1089
  buyer_wallet = Wallet(account=account, balance=refund_amount)
1090
  db.add(buyer_wallet)
云端_定时版本检测引擎.py CHANGED
@@ -93,7 +93,6 @@ async def trigger_update_notifications(updated_items: list):
93
  if not updated_items:
94
  return
95
 
96
- session = None
97
  try:
98
  # 加载已通知记录,防止重复通知
99
  update_notifications_db = db.load_data("update_notifications.json", default_data={})
@@ -114,63 +113,67 @@ async def trigger_update_notifications(updated_items: list):
114
  if not items_to_notify:
115
  print("📢 所有插件更新已通知过,无需重复发送")
116
  return
117
-
118
  # 从 SQL 数据库查询购买记录
119
- session = SessionLocal()
120
-
121
- # 为每个需要通知的插件,查找已购买用户并发送通知
122
- total_notified = 0
123
- batch_size = 50 # 分批发送,每批50个用户
124
-
125
- for item in items_to_notify:
126
- item_id = item["id"]
127
- title = item.get("title", "未知插件")
128
- version_hash = item["version_hash"]
129
-
130
- # 从 SQL ownerships 表查询已购买该插件的用户(排除已退款记录)
131
- ownerships = session.query(Ownership).filter(
132
- Ownership.item_id == item_id,
133
- Ownership.is_refunded == False
134
- ).all()
135
-
136
- # 使用 set 去重用户账号
137
- target_users = list(set(record.account for record in ownerships if record.account))
138
-
139
- if not target_users:
140
- # 无人购买,但仍记录已通知版本
141
- update_notifications_db[item_id] = version_hash
142
- continue
143
-
144
- # 准备通知数据
145
- notif_data = {
146
- "type": "plugin_update",
147
- "from_user": "system",
148
- "target_item_id": item_id,
149
- "target_item_title": title,
150
- "content": f"您已安装的插件 [{title}] 有新版本可用"
151
- }
152
-
153
- # 分批发送通知
154
- notified_count = 0
155
- for i in range(0, len(target_users), batch_size):
156
- batch = target_users[i:i + batch_size]
157
 
158
- for account in batch:
159
- try:
160
- add_notification(account, notif_data)
161
- notified_count += 1
162
- except Exception as e:
163
- logger.error(f"发送更新通知失败 (user={account}, item={item_id}): {e}")
164
- # 单个失败不影响其他用户
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
 
166
- # 每批之间短暂休眠,避免通知风暴
167
- if i + batch_size < len(target_users):
168
- await asyncio.sleep(0.5)
169
-
170
- # 记录该插件已通知的版本
171
- update_notifications_db[item_id] = version_hash
172
- total_notified += notified_count
173
- print(f"📢 插件 [{title}] 已向 {notified_count} 位用户发送更新通知")
174
 
175
  # 保存已通知记录
176
  db.save_data("update_notifications.json", update_notifications_db)
@@ -179,10 +182,6 @@ async def trigger_update_notifications(updated_items: list):
179
  except Exception as e:
180
  logger.error(f"触发更新通知时发生错误: {e}")
181
  # 不抛出异常,让主流程继续
182
- finally:
183
- # 确保 session 正确关闭
184
- if session:
185
- session.close()
186
 
187
 
188
  async def precache_github_zip(repo_url: str, token: Optional[str], item_id: str, version_hash: str):
@@ -258,6 +257,23 @@ async def precache_github_zip(repo_url: str, token: Optional[str], item_id: str,
258
  os.remove(temp_path)
259
  return False
260
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  zip_size = os.path.getsize(temp_path)
262
  logger.info(f"[预缓存] ZIP 下载完成: {item_id}, 大小 {zip_size} 字节")
263
 
 
93
  if not updated_items:
94
  return
95
 
 
96
  try:
97
  # 加载已通知记录,防止重复通知
98
  update_notifications_db = db.load_data("update_notifications.json", default_data={})
 
113
  if not items_to_notify:
114
  print("📢 所有插件更新已通知过,无需重复发送")
115
  return
116
+
117
  # 从 SQL 数据库查询购买记录
118
+ with SessionLocal() as session:
119
+ # 为每个需要通知的插件,查找已购买用户并发送通知
120
+ total_notified = 0
121
+ batch_size = 50 # 分批发送,每扐50个用户
122
+
123
+ for item in items_to_notify:
124
+ item_id = item["id"]
125
+ title = item.get("title", "未知插件")
126
+ version_hash = item["version_hash"]
127
+
128
+ # 从 SQL ownerships 表查询已购买该插件的用户(排除已退款记录)
129
+ ownerships = session.query(Ownership).filter(
130
+ Ownership.item_id == item_id,
131
+ Ownership.is_refunded == False
132
+ ).all()
133
+
134
+ # 使用 set 去重用户账号
135
+ target_users = list(set(record.account for record in ownerships if record.account))
136
+
137
+ if not target_users:
138
+ # 无人购买,但仍记录已通知版本
139
+ update_notifications_db[item_id] = version_hash
140
+ continue
141
+
142
+ # 准备通知数据
143
+ notif_data = {
144
+ "type": "plugin_update",
145
+ "from_user": "system",
146
+ "target_item_id": item_id,
147
+ "target_item_title": title,
148
+ "content": f"您已安装的插件 [{title}] 有新版本可用"
149
+ }
 
 
 
 
 
 
150
 
151
+ # 分批发送通知
152
+ notified_count = 0
153
+ failed_users = []
154
+ for i in range(0, len(target_users), batch_size):
155
+ batch = target_users[i:i + batch_size]
156
+
157
+ for account in batch:
158
+ try:
159
+ add_notification(account, notif_data)
160
+ notified_count += 1
161
+ except Exception as e:
162
+ failed_users.append(account)
163
+ logger.error(f"发送更新通知失败 (user={account}, item={item_id}): {e}")
164
+ # 单个失败不影响其他用户
165
+
166
+ if failed_users:
167
+ logger.warning(f"NOTIFY_FAILED | count={len(failed_users)} | users={failed_users[:10]}")
168
+
169
+ # 每批之间短暂休眠,避免通知风暴
170
+ if i + batch_size < len(target_users):
171
+ await asyncio.sleep(0.5)
172
 
173
+ # 记录该插件已通知的版本
174
+ update_notifications_db[item_id] = version_hash
175
+ total_notified += notified_count
176
+ print(f"📢 插件 [{title}] 已向 {notified_count} 位用户发送更新通知")
 
 
 
 
177
 
178
  # 保存已通知记录
179
  db.save_data("update_notifications.json", update_notifications_db)
 
182
  except Exception as e:
183
  logger.error(f"触发更新通知时发生错误: {e}")
184
  # 不抛出异常,让主流程继续
 
 
 
 
185
 
186
 
187
  async def precache_github_zip(repo_url: str, token: Optional[str], item_id: str, version_hash: str):
 
257
  os.remove(temp_path)
258
  return False
259
 
260
+ # 🔒 ZIP 膨胀比检查(防止 zip bomb)
261
+ try:
262
+ with zipfile.ZipFile(temp_path, 'r') as zf:
263
+ uncompressed_size = sum(f.file_size for f in zf.infolist())
264
+ zip_size_check = os.path.getsize(temp_path)
265
+ if uncompressed_size > zip_size_check * 10:
266
+ logger.warning(
267
+ f"[预缓存] ZIP 膨胀比过高,疑似 zip bomb: {item_id}, "
268
+ f"压缩={zip_size_check}字节, 未压缩={uncompressed_size}字节"
269
+ )
270
+ os.remove(temp_path)
271
+ return False
272
+ except zipfile.BadZipFile:
273
+ logger.warning(f"[预缓存] ZIP 文件损坏: {item_id}")
274
+ os.remove(temp_path)
275
+ return False
276
+
277
  zip_size = os.path.getsize(temp_path)
278
  logger.info(f"[预缓存] ZIP 下载完成: {item_id}, 大小 {zip_size} 字节")
279
 
安全认证.py CHANGED
@@ -27,8 +27,12 @@ import bcrypt
27
  # ==========================================
28
  # JWT_SECRET: 用于签名 Token,生产环境必须设置环境变量
29
  # PASSWORD_SALT: 密码哈希加盐,增强安全性
30
- JWT_SECRET = os.environ.get("JWT_SECRET", "ComfyUI-Ranking-Default-Secret-Key-2024")
31
- PASSWORD_SALT = os.environ.get("PASSWORD_SALT", "ComfyUI-Ranking-Salt-v1")
 
 
 
 
32
 
33
  # Token 有效期配置(单位:秒)
34
  TOKEN_EXPIRE_SECONDS = 7 * 24 * 60 * 60 # 默认7天(向后兼容)
@@ -366,11 +370,14 @@ def verify_token_with_fallback(token: str) -> Tuple[bool, Optional[str], str]:
366
  # 作用:提供所有者、管理员等权限检查功能
367
  # 用法:减少路由中的重复权限检查代码
368
 
369
- import os
370
  from functools import wraps
371
 
372
- # 管理员账号列表
373
- ADMIN_ACCOUNTS = [a.strip() for a in os.getenv("ADMIN_ACCOUNTS", "admin").split(",")]
 
 
 
 
374
 
375
 
376
  def is_admin(account: str) -> bool:
 
27
  # ==========================================
28
  # JWT_SECRET: 用于签名 Token,生产环境必须设置环境变量
29
  # PASSWORD_SALT: 密码哈希加盐,增强安全性
30
+ JWT_SECRET = os.environ.get("JWT_SECRET")
31
+ if not JWT_SECRET:
32
+ raise RuntimeError("必须设置 JWT_SECRET 环境变量")
33
+ PASSWORD_SALT = os.environ.get("PASSWORD_SALT")
34
+ if not PASSWORD_SALT:
35
+ raise RuntimeError("必须设置 PASSWORD_SALT 环境变量")
36
 
37
  # Token 有效期配置(单位:秒)
38
  TOKEN_EXPIRE_SECONDS = 7 * 24 * 60 * 60 # 默认7天(向后兼容)
 
370
  # 作用:提供所有者、管理员等权限检查功能
371
  # 用法:减少路由中的重复权限检查代码
372
 
 
373
  from functools import wraps
374
 
375
+ # 管理员账号列表(从环境变量读取,唯一统一定义)
376
+ ADMIN_ACCOUNTS = set(
377
+ acc.strip()
378
+ for acc in os.getenv("ADMIN_ACCOUNTS", "").split(",")
379
+ if acc.strip()
380
+ )
381
 
382
 
383
  def is_admin(account: str) -> bool:
数据库连接.py CHANGED
@@ -191,17 +191,29 @@ if sys.platform == "win32":
191
  # Windows 文件锁
192
  import msvcrt
193
 
 
 
 
194
  def _lock_file(file_obj, exclusive=True):
195
- """Windows 文件锁:锁定整个文件"""
196
- try:
197
- # LOCK_EX = 独占锁,LOCK_SH = 共享锁
198
- mode = msvcrt.LK_NBLCK if exclusive else msvcrt.LK_NBRLCK
199
- file_obj.seek(0)
200
- msvcrt.locking(file_obj.fileno(), mode, 1)
201
- except IOError:
202
- # 无法获取锁时等待重试
203
- time.sleep(0.1)
204
- msvcrt.locking(file_obj.fileno(), mode, 1)
 
 
 
 
 
 
 
 
 
205
 
206
  def _unlock_file(file_obj):
207
  """Windows 文件锁:释放锁"""
@@ -404,6 +416,9 @@ def save_data(file_name: str, data: Union[Dict, List]) -> bool:
404
  os.remove(local_path)
405
  os.rename(temp_path, local_path)
406
 
 
 
 
407
  except Exception as e:
408
  # 写入失败,清理临时文件
409
  if os.path.exists(temp_path):
@@ -411,9 +426,6 @@ def save_data(file_name: str, data: Union[Dict, List]) -> bool:
411
  print(f"🚨 保存 {file_name} 失败: {e}")
412
  raise
413
 
414
- # ========== 🚀 P1优化:更新内存缓存 ==========
415
- _set_to_cache(file_name, data, local_path)
416
-
417
  # ========== 第五步:标记文件脏,等待批量同步 ==========
418
  if HF_TOKEN:
419
  _mark_dirty(file_name)
@@ -706,15 +718,15 @@ def atomic_update(file_name: str, updater, default_data=None):
706
  os.remove(local_path)
707
  os.rename(temp_path, local_path)
708
 
 
 
 
709
  except Exception as e:
710
  if os.path.exists(temp_path):
711
  os.remove(temp_path)
712
  logger.error(f"保存 {file_name} 失败: {e}")
713
  raise
714
 
715
- # ========== 第四步:更新内存缓存 ==========
716
- _set_to_cache(file_name, data, local_path)
717
-
718
  # ========== 第五步:标记文件脏,等待批量同步 ==========
719
  if HF_TOKEN:
720
  _mark_dirty(file_name)
 
191
  # Windows 文件锁
192
  import msvcrt
193
 
194
+ _WIN_LOCK_MAX_RETRIES = 5 # 最大重试次数
195
+ _WIN_LOCK_BASE_DELAY = 0.1 # 基础等待秒数
196
+
197
  def _lock_file(file_obj, exclusive=True):
198
+ """Windows 文件锁:锁定整个文件,带重试逻辑"""
199
+ mode = msvcrt.LK_NBLCK if exclusive else msvcrt.LK_NBRLCK
200
+ file_obj.seek(0)
201
+ for attempt in range(_WIN_LOCK_MAX_RETRIES):
202
+ try:
203
+ msvcrt.locking(file_obj.fileno(), mode, 1)
204
+ return # 加锁成功
205
+ except IOError:
206
+ if attempt < _WIN_LOCK_MAX_RETRIES - 1:
207
+ wait = _WIN_LOCK_BASE_DELAY * (2 ** attempt) # 指数退避
208
+ time.sleep(wait)
209
+ else:
210
+ # 最后一次仍失败,独占锁抛出异常,共享锁降级为无锁读取
211
+ if exclusive:
212
+ raise IOError(f"Windows 文件锁获取失败(重试{_WIN_LOCK_MAX_RETRIES}次后放弃)")
213
+ else:
214
+ # 共享锁获取失败时,记录警告但不阻塞读取
215
+ logger.warning(f"Windows 共享锁获取失败,降级为无锁读取")
216
+ return
217
 
218
  def _unlock_file(file_obj):
219
  """Windows 文件锁:释放锁"""
 
416
  os.remove(local_path)
417
  os.rename(temp_path, local_path)
418
 
419
+ # ========== 🚀 P1优化:更新内存缓存(在锁内,确保写后立即可见) ==========
420
+ _set_to_cache(file_name, data, local_path)
421
+
422
  except Exception as e:
423
  # 写入失败,清理临时文件
424
  if os.path.exists(temp_path):
 
426
  print(f"🚨 保存 {file_name} 失败: {e}")
427
  raise
428
 
 
 
 
429
  # ========== 第五步:标记文件脏,等待批量同步 ==========
430
  if HF_TOKEN:
431
  _mark_dirty(file_name)
 
718
  os.remove(local_path)
719
  os.rename(temp_path, local_path)
720
 
721
+ # ========== 更新内存缓存(在锁内,确保写后立即可见) ==========
722
+ _set_to_cache(file_name, data, local_path)
723
+
724
  except Exception as e:
725
  if os.path.exists(temp_path):
726
  os.remove(temp_path)
727
  logger.error(f"保存 {file_name} 失败: {e}")
728
  raise
729
 
 
 
 
730
  # ========== 第五步:标记文件脏,等待批量同步 ==========
731
  if HF_TOKEN:
732
  _mark_dirty(file_name)