ZHIWEI666 commited on
Commit
b267d43
·
verified ·
1 Parent(s): 7b79f99

优化安全问题

Browse files
app.py CHANGED
@@ -60,7 +60,7 @@ from router_open_api import router as open_api_router # 🌐 通用开放API(
60
  from router_admin_plugins import router as admin_plugins_router # 🔒 管理后台 - 第三方插件管理API
61
 
62
  from database_sql import init_sql_db, get_db, SessionLocal
63
- from models_sql import Ownership, SystemConfig
64
 
65
  # 🚀 P2优化:速率限制 (防止暴力攻击)
66
  from slowapi import Limiter, _rate_limit_exceeded_handler
 
60
  from router_admin_plugins import router as admin_plugins_router # 🔒 管理后台 - 第三方插件管理API
61
 
62
  from database_sql import init_sql_db, get_db, SessionLocal
63
+ from models_sql import SystemConfig # 🧹 P2清理:Ownership 在本文件无引用
64
 
65
  # 🚀 P2优化:速率限制 (防止暴力攻击)
66
  from slowapi import Limiter, _rate_limit_exceeded_handler
models.py CHANGED
@@ -1,6 +1,6 @@
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:
@@ -37,15 +37,26 @@ class UserRegister(BaseModel):
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
@@ -58,15 +69,7 @@ class UserUpdate(BaseModel):
58
  avatarDataUrl: Optional[str] = None
59
  bannerUrl: Optional[str] = None # 🚀 个人资料卡背景图云端URL
60
 
61
- class PasswordReset(BaseModel):
62
- old_password: Optional[str] = None
63
- new_password: str
64
- verifyContact: str
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
@@ -130,7 +133,8 @@ class InteractionToggle(BaseModel):
130
  class RatingRequest(BaseModel):
131
  score: int # 1-5
132
 
133
- @validator("score")
 
134
  def validate_score(cls, v):
135
  if not isinstance(v, int):
136
  raise ValueError("score must be an integer between 1 and 5")
@@ -186,57 +190,24 @@ class TaskUpdate(BaseModel):
186
  depositRatio: Optional[int] = None
187
  deadline: Optional[str] = None
188
 
189
- class TaskApply(BaseModel):
190
- """ 申请接单 """
191
- task_id: str
192
- applicant: str # 申请者账号
193
- message: Optional[str] = None # 申请留言
194
-
195
- class TaskAssign(BaseModel):
196
- """ 指派接单者 """
197
- task_id: str
198
- publisher: str # 发布者(校验权限)
199
- assignee: str # 被指派的接单者
200
-
201
  class TaskSubmit(BaseModel):
202
  """ 接单者提交成果 """
203
- task_id: str
204
- assignee: str # 接单者(校验权限)
205
- deliverables: List[str] # 交付物图片URL列表
206
- note: Optional[str] = None # 备注说明
207
 
208
- class TaskAccept(BaseModel):
209
- """ 发布者验收 """
210
- task_id: str
211
- publisher: str # 发布者(校验权限)
212
- is_accepted: bool # True=验收通过, False=验收不通过
213
- feedback: Optional[str] = None # 反馈意见
214
-
215
-
216
- # ==========================================
217
- # ⚖️ P3增强:任务申诉数据模型
218
- # ==========================================
219
  class TaskDispute(BaseModel):
220
  """ 发起申诉 """
221
- task_id: str # 任务ID
222
- initiator: str # 申诉发起人(可以是发布者或接单者)
223
  reason: str # 申诉理由
224
- evidence: Optional[List[str]] = [] # 证据图片URL列表
225
 
226
  class TaskDisputeResponse(BaseModel):
227
- """ 被申诉方回应 """
228
- dispute_id: str # 申诉ID
229
- respondent: str # 回应人
230
  response: str # 回应内容
231
- evidence: Optional[List[str]] = [] # 证据图片URL列表
232
-
233
- class TaskDisputeResolve(BaseModel):
234
- """ 管理员仲裁 """
235
- dispute_id: str # 申诉ID
236
- admin_account: str # 管理员账号
237
- result: str # 仲裁结果: support_initiator / support_respondent / split
238
- split_ratio: Optional[int] = 50 # 分成比例(申诉方获得的百分比,仅当result=split时有效)
239
- admin_note: Optional[str] = None # 管理员备注
240
 
241
  # ==========================================
242
  # 💬 讨论区数据模型(小红书风格图文社区)
 
1
  # models.py
2
  import re
3
+ from pydantic import BaseModel, Field, field_validator # 🧹 P2修复:pydantic v2 已废弃 v1 风格 validator
4
  from typing import Optional, List
5
 
6
  def _validate_email_format(email: str) -> str:
 
37
  region: Optional[str] = None
38
  intro: Optional[str] = None
39
 
40
+ # 🧹 P2修复:v2 中下划线前缀属性会被当作私有属性而非校验器,改用 field_validator 显式挂载(校验逻辑不变)
41
+ @field_validator('password')
42
+ @classmethod
43
+ def _check_password(cls, v):
44
+ return _validate_password_length(v)
45
+
46
+ @field_validator('email')
47
+ @classmethod
48
+ def _check_email(cls, v):
49
+ return _validate_email_format(v)
50
 
51
  class UserLogin(BaseModel):
52
  account: str
53
  password: str
54
  remember: Optional[bool] = True # 保持登录选项:True=30天, False=24小时
55
 
56
+ @field_validator('password')
57
+ @classmethod
58
+ def _check_password(cls, v):
59
+ return _validate_password_length(v)
60
 
61
  class UserUpdate(BaseModel):
62
  name: Optional[str] = None
 
69
  avatarDataUrl: Optional[str] = None
70
  bannerUrl: Optional[str] = None # 🚀 个人资料卡背景图云端URL
71
 
72
+ # 🧹 P2清理:移除零引用的 PasswordReset 模型(重置密码实际走 UserUpdate/专用端点)
 
 
 
 
 
 
 
 
73
 
74
  class CommentCreate(BaseModel):
75
  item_id: str
 
133
  class RatingRequest(BaseModel):
134
  score: int # 1-5
135
 
136
+ @field_validator("score")
137
+ @classmethod
138
  def validate_score(cls, v):
139
  if not isinstance(v, int):
140
  raise ValueError("score must be an integer between 1 and 5")
 
190
  depositRatio: Optional[int] = None
191
  deadline: Optional[str] = None
192
 
193
+ # 🔧 P2修复:以下三个 body 模型修复前后端参数通道不匹配
194
+ # (前端以 JSON body 提交,旧版端点用裸标量参数会被 FastAPI 解析为 query 导致 422)
 
 
 
 
 
 
 
 
 
 
195
  class TaskSubmit(BaseModel):
196
  """ 接单者提交成果 """
197
+ deliverables: List[str] # 交付成果列表
198
+ note: Optional[str] = None # 提交说明
 
 
199
 
 
 
 
 
 
 
 
 
 
 
 
200
  class TaskDispute(BaseModel):
201
  """ 发起申诉 """
 
 
202
  reason: str # 申诉理由
203
+ evidence: Optional[List[str]] = None # 证据图片URL列表(最多6张)
204
 
205
  class TaskDisputeResponse(BaseModel):
206
+ """ 被申诉方回应申诉 """
 
 
207
  response: str # 回应内容
208
+ evidence: Optional[List[str]] = None # 证据图片URL列表(最多6张)
209
+
210
+ # 🧹 P2清理:移除零引用的 TaskApply/TaskAssign/TaskAccept 模型(对应端点使用 query 参数且前端匹配)
 
 
 
 
 
 
211
 
212
  # ==========================================
213
  # 💬 讨论区数据模型(小红书风格图文社区)
models_sql.py CHANGED
@@ -55,6 +55,16 @@ class Refund(Base):
55
  refunded_at = Column(DateTime, default=datetime.datetime.utcnow)
56
  ban_until = Column(DateTime) # 禁止购买截止时间(30天后)
57
 
 
 
 
 
 
 
 
 
 
 
58
  class Transaction(Base):
59
  """交易流水表"""
60
  __tablename__ = "transactions"
 
55
  refunded_at = Column(DateTime, default=datetime.datetime.utcnow)
56
  ban_until = Column(DateTime) # 禁止购买截止时间(30天后)
57
 
58
+ class RechargeOrder(Base):
59
+ """💰 充值订单预存表(订单号→账号映射持久化,防止实例重启导致回调找不到订单)"""
60
+ __tablename__ = "recharge_orders"
61
+
62
+ order_id = Column(String, primary_key=True) # 商户订单号 PAY_xxx
63
+ account = Column(String, index=True) # 充值用户
64
+ amount = Column(Integer) # 充值金额
65
+ status = Column(String, default="pending") # pending/paid
66
+ created_at = Column(DateTime, default=datetime.datetime.utcnow)
67
+
68
  class Transaction(Base):
69
  """交易流水表"""
70
  __tablename__ = "transactions"
requirements.txt CHANGED
@@ -3,13 +3,14 @@ fastapi==0.104.1
3
  uvicorn==0.24.0
4
  pydantic==2.5.2
5
  huggingface_hub==0.19.4
6
- datasets==2.15.0
7
  python-multipart==0.0.6
8
  alibabacloud_dysmsapi20170525==2.0.24
 
 
9
  sqlalchemy==2.0.23
10
  psycopg2-binary==2.9.9
11
  httpx==0.25.2
12
- aiofiles==23.2.1
13
  # 🚀 P2优化:速率限制
14
  slowapi==0.1.9
15
  # 🔒 P0安全增强:bcrypt密码哈希
 
3
  uvicorn==0.24.0
4
  pydantic==2.5.2
5
  huggingface_hub==0.19.4
6
+ # 🧹 P2清理:datasets/aiofiles 全库零引用,已移除
7
  python-multipart==0.0.6
8
  alibabacloud_dysmsapi20170525==2.0.24
9
+ # 🧹 P2修复:verify_code_engine.py 直接导入 alibabacloud_tea_openapi,显式声明避免隐式依赖漂移
10
+ alibabacloud_tea_openapi==0.3.8
11
  sqlalchemy==2.0.23
12
  psycopg2-binary==2.9.9
13
  httpx==0.25.2
 
14
  # 🚀 P2优化:速率限制
15
  slowapi==0.1.9
16
  # 🔒 P0安全增强:bcrypt密码哈希
router_admin_plugins.py CHANGED
@@ -21,7 +21,7 @@ from typing import Optional, List
21
  from fastapi import APIRouter, Depends, HTTPException
22
  from fastapi.responses import JSONResponse
23
  from sqlalchemy.orm import Session
24
- from pydantic import BaseModel, Field, validator
25
 
26
  from database_sql import get_db
27
  from models_sql import RegisteredPlugin
@@ -120,13 +120,16 @@ class PluginCreateRequest(BaseModel):
120
  max_deduct_per_day: Optional[int] = Field(1000, ge=1, le=100000)
121
  permissions: Optional[List[str]] = Field(default=None)
122
 
123
- @validator("plugin_id")
 
124
  def _validate_plugin_id(cls, v):
125
  if not _PLUGIN_ID_PATTERN.match(v):
126
  raise ValueError("plugin_id 仅允许 [a-z0-9_],长度 3-50")
127
  return v
128
 
129
- @validator("permissions", always=True)
 
 
130
  def _validate_permissions(cls, v):
131
  if v is None:
132
  return _DEFAULT_PERMISSIONS
@@ -144,7 +147,8 @@ class PluginUpdateRequest(BaseModel):
144
  max_deduct_per_day: Optional[int] = Field(None, ge=1, le=100000)
145
  permissions: Optional[List[str]] = None
146
 
147
- @validator("permissions")
 
148
  def _validate_permissions(cls, v):
149
  if v is None:
150
  return v
 
21
  from fastapi import APIRouter, Depends, HTTPException
22
  from fastapi.responses import JSONResponse
23
  from sqlalchemy.orm import Session
24
+ from pydantic import BaseModel, Field, field_validator # 🧹 P2修复:pydantic v2 已废弃 v1 风格 validator
25
 
26
  from database_sql import get_db
27
  from models_sql import RegisteredPlugin
 
120
  max_deduct_per_day: Optional[int] = Field(1000, ge=1, le=100000)
121
  permissions: Optional[List[str]] = Field(default=None)
122
 
123
+ @field_validator("plugin_id")
124
+ @classmethod
125
  def _validate_plugin_id(cls, v):
126
  if not _PLUGIN_ID_PATTERN.match(v):
127
  raise ValueError("plugin_id 仅允许 [a-z0-9_],长度 3-50")
128
  return v
129
 
130
+ # 🔧 pydantic v2:field_validator 默认对默认值也生效,无需 v1 的 always=True
131
+ @field_validator("permissions")
132
+ @classmethod
133
  def _validate_permissions(cls, v):
134
  if v is None:
135
  return _DEFAULT_PERMISSIONS
 
147
  max_deduct_per_day: Optional[int] = Field(None, ge=1, le=100000)
148
  permissions: Optional[List[str]] = None
149
 
150
+ @field_validator("permissions")
151
+ @classmethod
152
  def _validate_permissions(cls, v):
153
  if v is None:
154
  return v
router_items.py CHANGED
@@ -95,29 +95,33 @@ async def get_items(type: str = "tool", sort: str = "time", limit: int = 50): #
95
  filtered_items = sort_cache.get_sorted(cache_key, filtered_items, sort_fn)
96
 
97
  price_updated = False
 
98
  for item in filtered_items:
99
- item["commentsData"] = comments_db.get(item["id"], [])
100
- item["comments"] = len(item["commentsData"])
101
- item["latest_version"] = _get_version_str(versions_db, item["id"])
102
-
103
- # 💰 检查延迟价格是否已生效
104
  old_price = item.get("price", 0)
105
  _apply_effective_price(item)
106
  if item.get("price", 0) != old_price:
107
  price_updated = True
108
-
 
 
 
 
 
 
109
  # 🔴 【绝对核心防线】:在下发给前端前,强行在内存中抹除敏感信息!
110
- item["has_private_token"] = bool(item.get("github_token"))
111
- item.pop("github_token", None)
112
- item.pop("netdisk_password", None) # ☁️ 网盘密码不在列表中显示
113
- item.pop("viewed_by", None) # 👁️ 访问者列表不暴露给前端
 
114
 
115
  # 如果有价格生效了,持久化回 items.json
116
  if price_updated:
117
  db.save_data("items.json", items_db)
118
  invalidate_cache("items.json")
119
 
120
- return {"status": "success", "data": filtered_items[:limit]}
121
 
122
  def _build_creator_trend_data(account: str, u_items: list, months: list) -> dict:
123
  """
@@ -559,9 +563,17 @@ async def check_github_updates():
559
  latest_sha = commits[0].get("sha", "")[:7] # 取前7位作为版本标识
560
 
561
  # 如果版本有变化,更新 versions.json
562
- old_version = versions_db.get(item_id, "")
 
 
 
 
 
 
 
563
  if latest_sha and latest_sha != old_version:
564
- versions_db[item_id] = latest_sha
 
565
  updated_count += 1
566
  print(f"[版本更新] {item['title']}: {old_version} -> {latest_sha}")
567
 
@@ -588,25 +600,28 @@ async def get_item_by_id(item_id: str):
588
 
589
  for item in items_db:
590
  if item["id"] == item_id:
591
- # 添加关联数据
592
- item["commentsData"] = comments_db.get(item_id, [])
593
- item["comments"] = len(item["commentsData"])
594
- item["latest_version"] = _get_version_str(versions_db, item_id)
595
-
596
- # 💰 检查延迟价格是否已生效
597
  old_price = item.get("price", 0)
598
  _apply_effective_price(item)
599
  if item.get("price", 0) != old_price:
600
  db.save_data("items.json", items_db)
601
  invalidate_cache("items.json")
602
 
603
- # 🔴 【安全防线】:抹除敏感信息
604
- item["has_private_token"] = bool(item.get("github_token"))
605
- item.pop("github_token", None)
606
- item.pop("netdisk_password", None)
607
- item.pop("viewed_by", None)
 
 
 
 
 
 
 
 
608
 
609
- return {"status": "success", "data": item}
610
 
611
  raise HTTPException(status_code=404, detail="资源不存在")
612
 
 
95
  filtered_items = sort_cache.get_sorted(cache_key, filtered_items, sort_fn)
96
 
97
  price_updated = False
98
+ result_items = []
99
  for item in filtered_items:
100
+ # 💰 检查延迟价格是否已生效(在原始字典上执行,确保生效后可持久化)
 
 
 
 
101
  old_price = item.get("price", 0)
102
  _apply_effective_price(item)
103
  if item.get("price", 0) != old_price:
104
  price_updated = True
105
+
106
+ # 🔒 P0数据修复:浅拷贝后再做下发加工,避免 pop/注入字段污染 items.json 源数据
107
+ item_data = {**item}
108
+ item_data["commentsData"] = comments_db.get(item_data["id"], [])
109
+ item_data["comments"] = len(item_data["commentsData"])
110
+ item_data["latest_version"] = _get_version_str(versions_db, item_data["id"])
111
+
112
  # 🔴 【绝对核心防线】:在下发给前端前,强行在内存中抹除敏感信息!
113
+ item_data["has_private_token"] = bool(item_data.get("github_token"))
114
+ item_data.pop("github_token", None)
115
+ item_data.pop("netdisk_password", None) # ☁️ 网盘密码不在列表中显示
116
+ item_data.pop("viewed_by", None) # 👁️ 访问者列表不暴露给前端
117
+ result_items.append(item_data)
118
 
119
  # 如果有价格生效了,持久化回 items.json
120
  if price_updated:
121
  db.save_data("items.json", items_db)
122
  invalidate_cache("items.json")
123
 
124
+ return {"status": "success", "data": result_items[:limit]}
125
 
126
  def _build_creator_trend_data(account: str, u_items: list, months: list) -> dict:
127
  """
 
563
  latest_sha = commits[0].get("sha", "")[:7] # 取前7位作为版本标识
564
 
565
  # 如果版本有变化,更新 versions.json
566
+ # 🧹 P2修复(格式统一):统一以新格式 dict 读写,保留 cached_at/zip_size 等缓存元字段;
567
+ # 旧逻辑拿字符串与 dict 比较会把 dict 条目永远判为"有更新"并覆盖成字符串丢失元信息
568
+ entry = versions_db.get(item_id)
569
+ if isinstance(entry, str):
570
+ entry = {"hash": entry}
571
+ elif not isinstance(entry, dict):
572
+ entry = {}
573
+ old_version = entry.get("hash", "")
574
  if latest_sha and latest_sha != old_version:
575
+ entry["hash"] = latest_sha
576
+ versions_db[item_id] = entry
577
  updated_count += 1
578
  print(f"[版本更新] {item['title']}: {old_version} -> {latest_sha}")
579
 
 
600
 
601
  for item in items_db:
602
  if item["id"] == item_id:
603
+ # 💰 检查延迟价格是否已生效(在原字典上操作,确保生效结果可持久化)
 
 
 
 
 
604
  old_price = item.get("price", 0)
605
  _apply_effective_price(item)
606
  if item.get("price", 0) != old_price:
607
  db.save_data("items.json", items_db)
608
  invalidate_cache("items.json")
609
 
610
+ # 🔒 P2修复(与 get_items 同款):浅拷贝后再做下发加工,
611
+ # 避免 pop 敏感字段污染 items.json 源数据/共享缓存(导致 github_token 永久丢失)
612
+ item_data = {**item}
613
+ # 添加关联数据
614
+ item_data["commentsData"] = comments_db.get(item_id, [])
615
+ item_data["comments"] = len(item_data["commentsData"])
616
+ item_data["latest_version"] = _get_version_str(versions_db, item_id)
617
+
618
+ # 🔴 【安全防线】:抹除敏感信息(在拷贝上操作)
619
+ item_data["has_private_token"] = bool(item_data.get("github_token"))
620
+ item_data.pop("github_token", None)
621
+ item_data.pop("netdisk_password", None)
622
+ item_data.pop("viewed_by", None)
623
 
624
+ return {"status": "success", "data": item_data}
625
 
626
  raise HTTPException(status_code=404, detail="资源不存在")
627
 
router_messages.py CHANGED
@@ -179,8 +179,18 @@ async def get_chat_list(account: str, current_user: str = Depends(require_auth))
179
  chat_list = []
180
  for conv_id, msgs in chats_db.items():
181
  if account in conv_id:
182
- other_account = conv_id.replace(account, "").replace("_", "")
183
  if not msgs: continue
 
 
 
 
 
 
 
 
 
 
 
184
  last_msg = msgs[-1]
185
  unread_count = sum(1 for m in msgs if m["receiver"] == account and not m.get("is_read"))
186
  other_user = users_db.get(other_account, {})
@@ -233,6 +243,9 @@ async def get_chat_history(account: str, target_account: str, current_user: str
233
  # ==========================================
234
  @router.get("/api/messages/{account}")
235
  async def get_messages(account: str, count_only: bool = False, current_user: str = Depends(require_auth)):
 
 
 
236
  # 🔥 count_only 模式:轻量级轮询,只返回未读数,不标记已读
237
  if count_only:
238
  messages_db = db.load_data("messages.json", default_data={})
@@ -344,10 +357,13 @@ async def get_messages(account: str, count_only: bool = False, current_user: str
344
  return result_container[0]
345
 
346
  @router.post("/api/messages/{account}/read")
347
- async def mark_messages_read(account: str):
348
  """
349
  标记消息为已读(原子操作,并发安全)
350
  """
 
 
 
351
  def updater(data):
352
  user_msgs = data.get(account, [])
353
  modified = False
 
179
  chat_list = []
180
  for conv_id, msgs in chats_db.items():
181
  if account in conv_id:
 
182
  if not msgs: continue
183
+ # 🔧 P1修复:对方账户从消息的 sender/receiver 字段推导,而非对 conv_id 做 replace
184
+ # (replace 会替换所有子串,若账户名互为子串或含下划线会解析出错误账户)
185
+ other_account = None
186
+ for m in reversed(msgs):
187
+ if m.get("sender") != account:
188
+ other_account = m.get("sender")
189
+ break
190
+ if m.get("receiver") != account:
191
+ other_account = m.get("receiver")
192
+ break
193
+ if not other_account: continue
194
  last_msg = msgs[-1]
195
  unread_count = sum(1 for m in msgs if m["receiver"] == account and not m.get("is_read"))
196
  other_user = users_db.get(other_account, {})
 
243
  # ==========================================
244
  @router.get("/api/messages/{account}")
245
  async def get_messages(account: str, count_only: bool = False, current_user: str = Depends(require_auth)):
246
+ # 🔒 P0安全加固:拦截越权读取他人消息(含 count_only 轮询)
247
+ if account != current_user:
248
+ raise HTTPException(status_code=403, detail="无权访问他人消息")
249
  # 🔥 count_only 模式:轻量级轮询,只返回未读数,不标记已读
250
  if count_only:
251
  messages_db = db.load_data("messages.json", default_data={})
 
357
  return result_container[0]
358
 
359
  @router.post("/api/messages/{account}/read")
360
+ async def mark_messages_read(account: str, current_user: str = Depends(require_auth)):
361
  """
362
  标记消息为已读(原子操作,并发安全)
363
  """
364
+ # 🔒 P0安全加固:补全鉴权,防止任意用户篡改他人消息已读状态
365
+ if account != current_user:
366
+ raise HTTPException(status_code=403, detail="无权操作他人消息")
367
  def updater(data):
368
  user_msgs = data.get(account, [])
369
  modified = False
router_posts.py CHANGED
@@ -429,53 +429,21 @@ async def tip_post(post_id: str, amount: int, is_anon: bool = False, current_use
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] # 用于在原子操作外获取作者账号
434
-
435
- def updater(data):
436
- # 在锁内查找帖子
437
- target_post = None
438
- for post in data:
439
- if post["id"] == post_id:
440
- target_post = post
441
- break
442
-
443
- if not target_post:
444
- result_container[0] = {"error": "not_found"}
445
- return
446
-
447
- # 不能打赏自己
448
- if target_post.get("author") == current_user:
449
- result_container[0] = {"error": "self_tip"}
450
- return
451
-
452
- author_account[0] = target_post.get("author")
453
-
454
- # 更新打赏榜单
455
- tip_board = target_post.get("tip_board", [])
456
- existing = next((t for t in tip_board if t["account"] == current_user), None)
457
- if existing:
458
- existing["amount"] += amount
459
- else:
460
- tip_board.append({"account": current_user, "amount": amount, "is_anon": is_anon})
461
- tip_board.sort(key=lambda x: x["amount"], reverse=True)
462
- target_post["tip_board"] = tip_board
463
-
464
- result_container[0] = {"status": "success", "message": f"成功打赏 {amount} 积分"}
465
-
466
- db.atomic_update("posts.json", updater, default_data=[])
467
-
468
- result = result_container[0]
469
- if result is None or result.get("error") == "not_found":
470
  raise HTTPException(status_code=404, detail="帖子不存在")
471
- if result.get("error") == "self_tip":
472
  raise HTTPException(status_code=400, detail="不能打赏自己的帖子")
 
 
 
 
473
 
474
- # 💳 使用 SQL Wallet 系统处理余额转账
475
  try:
476
- author = author_account[0]
477
- if not author:
478
- raise HTTPException(status_code=404, detail="作者账户不存在")
479
 
480
  # 🔒 P1幂等性防护:检查最近5秒内是否存在相同交易
481
  recent_cutoff = datetime.datetime.utcnow() - datetime.timedelta(seconds=5)
@@ -515,20 +483,13 @@ async def tip_post(post_id: str, amount: int, is_anon: bool = False, current_use
515
  prev_hash_tipper = last_tx_tipper.tx_hash if last_tx_tipper else "GENESIS_HASH"
516
  prev_hash_author = last_tx_author.tx_hash if last_tx_author else "GENESIS_HASH"
517
 
518
- # 获取用户信息和帖子标题
519
  users_db = db.load_data("users.json", default_data={})
520
- posts_db = db.load_data("posts.json", default_data=[])
521
  author_info = users_db.get(author, {})
522
  tipper_info = users_db.get(current_user, {})
523
  author_name = author_info.get("name", author)
524
  tipper_name = tipper_info.get("name", current_user)
525
 
526
- post_title = None
527
- for post in posts_db:
528
- if post["id"] == post_id:
529
- post_title = post.get("title")
530
- break
531
-
532
  # 打赏方交易记录 (TIP_OUT)
533
  tx_tipper = Transaction(
534
  tx_id=tx_id_tipper,
@@ -597,7 +558,26 @@ async def tip_post(post_id: str, amount: int, is_anon: bool = False, current_use
597
  logger.error(f"POST_TIP_ERROR | from={current_user} | post={post_id} | amount={amount} | error={str(e)}")
598
  raise HTTPException(status_code=500, detail="打赏处理失败,请稍后重试")
599
 
600
- return result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
601
 
602
  # ==========================================
603
  # 💬 评论接口(复用通用评论系统)
 
429
  if amount > MAX_TIP_AMOUNT:
430
  raise HTTPException(status_code=400, detail=f"打赏金额必须在1-{MAX_TIP_AMOUNT}之间")
431
 
432
+ # 🔒 P0数据修复(时序):先只读校验 → SQL转账扣款 → 提交成功后再写打赏榜
433
+ # 避免余额不足/重复请求时榜单已被更新的数据不一致
434
+ posts_db_ro = db.load_data("posts.json", default_data=[])
435
+ target_post = next((p for p in posts_db_ro if p["id"] == post_id), None)
436
+ if not target_post:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
437
  raise HTTPException(status_code=404, detail="帖子不存在")
438
+ if target_post.get("author") == current_user:
439
  raise HTTPException(status_code=400, detail="不能打赏自己的帖子")
440
+ author = target_post.get("author")
441
+ if not author:
442
+ raise HTTPException(status_code=404, detail="作者账户不存在")
443
+ post_title = target_post.get("title")
444
 
445
+ # 💳 使用 SQL Wallet 系统处理余额转账(先扣款)
446
  try:
 
 
 
447
 
448
  # 🔒 P1幂等性防护:检查最近5秒内是否存在相同交易
449
  recent_cutoff = datetime.datetime.utcnow() - datetime.timedelta(seconds=5)
 
483
  prev_hash_tipper = last_tx_tipper.tx_hash if last_tx_tipper else "GENESIS_HASH"
484
  prev_hash_author = last_tx_author.tx_hash if last_tx_author else "GENESIS_HASH"
485
 
486
+ # 获取用户信息(post_title 已在预校验阶段取得)
487
  users_db = db.load_data("users.json", default_data={})
 
488
  author_info = users_db.get(author, {})
489
  tipper_info = users_db.get(current_user, {})
490
  author_name = author_info.get("name", author)
491
  tipper_name = tipper_info.get("name", current_user)
492
 
 
 
 
 
 
 
493
  # 打赏方交易记录 (TIP_OUT)
494
  tx_tipper = Transaction(
495
  tx_id=tx_id_tipper,
 
558
  logger.error(f"POST_TIP_ERROR | from={current_user} | post={post_id} | amount={amount} | error={str(e)}")
559
  raise HTTPException(status_code=500, detail="打赏处理失败,请稍后重试")
560
 
561
+ # 🏆 扣款成功后再写打赏榜(写榜失败仅记录日志,不影响资金安全)
562
+ def board_updater(data):
563
+ for post in data:
564
+ if post["id"] == post_id:
565
+ tip_board = post.get("tip_board", [])
566
+ existing = next((t for t in tip_board if t["account"] == current_user), None)
567
+ if existing:
568
+ existing["amount"] += amount
569
+ else:
570
+ tip_board.append({"account": current_user, "amount": amount, "is_anon": is_anon})
571
+ tip_board.sort(key=lambda x: x["amount"], reverse=True)
572
+ post["tip_board"] = tip_board
573
+ break
574
+
575
+ try:
576
+ db.atomic_update("posts.json", board_updater, default_data=[])
577
+ except Exception as e:
578
+ logger.error(f"POST_TIP_BOARD_ERROR | post={post_id} | error={str(e)}")
579
+
580
+ return {"status": "success", "message": f"成功打赏 {amount} 积分"}
581
 
582
  # ==========================================
583
  # 💬 评论接口(复用通用评论系统)
router_prompts.py CHANGED
@@ -726,53 +726,21 @@ async def tip_prompt(prompt_id: str, amount: int, is_anon: bool = False,
726
  if amount > MAX_TIP_AMOUNT:
727
  raise HTTPException(status_code=400, detail=f"打赏金额必须在1-{MAX_TIP_AMOUNT}之间")
728
 
729
- result_container = [None]
730
- author_account = [None] # 用于在原子操作外获取作者账号
731
-
732
- def updater(data):
733
- # 在锁内查找提示词
734
- target_prompt = None
735
- for prompt in data:
736
- if prompt["id"] == prompt_id:
737
- target_prompt = prompt
738
- break
739
-
740
- if not target_prompt:
741
- result_container[0] = {"error": "not_found"}
742
- return
743
-
744
- # 不能打赏自己
745
- if target_prompt.get("author") == current_user:
746
- result_container[0] = {"error": "self_tip"}
747
- return
748
-
749
- author_account[0] = target_prompt.get("author")
750
-
751
- # 更新打赏榜单
752
- tip_board = target_prompt.get("tip_board", [])
753
- existing = next((t for t in tip_board if t["account"] == current_user), None)
754
- if existing:
755
- existing["amount"] += amount
756
- else:
757
- tip_board.append({"account": current_user, "amount": amount, "is_anon": is_anon})
758
- tip_board.sort(key=lambda x: x["amount"], reverse=True)
759
- target_prompt["tip_board"] = tip_board
760
-
761
- result_container[0] = {"status": "success", "message": f"成功打赏 {amount} 积分"}
762
-
763
- db.atomic_update("prompts.json", updater, default_data=[])
764
-
765
- result = result_container[0]
766
- if result is None or result.get("error") == "not_found":
767
  raise HTTPException(status_code=404, detail="提示词不存在")
768
- if result.get("error") == "self_tip":
769
  raise HTTPException(status_code=400, detail="不能打赏自己的提示词")
 
 
 
 
770
 
771
- # 💳 使用 SQL Wallet 系统处理余额转账
772
  try:
773
- author = author_account[0]
774
- if not author:
775
- raise HTTPException(status_code=404, detail="作者账户不存在")
776
 
777
  # 🔒 P1幂等性防护:检查最近5秒内是否存在相同交易
778
  recent_cutoff = datetime.datetime.utcnow() - datetime.timedelta(seconds=5)
@@ -812,20 +780,13 @@ async def tip_prompt(prompt_id: str, amount: int, is_anon: bool = False,
812
  prev_hash_tipper = last_tx_tipper.tx_hash if last_tx_tipper else "GENESIS_HASH"
813
  prev_hash_author = last_tx_author.tx_hash if last_tx_author else "GENESIS_HASH"
814
 
815
- # 获取用户信息和提示词标题
816
  users_db = db.load_data("users.json", default_data={})
817
  author_info = users_db.get(author, {})
818
  tipper_info = users_db.get(current_user, {})
819
  author_name = author_info.get("name", author)
820
  tipper_name = tipper_info.get("name", current_user)
821
 
822
- prompt_title = None
823
- prompts_db = db.load_data("prompts.json", default_data=[])
824
- for prompt in prompts_db:
825
- if prompt["id"] == prompt_id:
826
- prompt_title = prompt.get("title")
827
- break
828
-
829
  # 打赏方交易记录 (TIP_OUT)
830
  tx_tipper = Transaction(
831
  tx_id=tx_id_tipper,
@@ -894,7 +855,26 @@ async def tip_prompt(prompt_id: str, amount: int, is_anon: bool = False,
894
  logger.error(f"PROMPT_TIP_ERROR | from={current_user} | prompt={prompt_id} | amount={amount} | error={str(e)}")
895
  raise HTTPException(status_code=500, detail="打赏处理失败,请稍后重试")
896
 
897
- return result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
898
 
899
 
900
  # ==========================================
 
726
  if amount > MAX_TIP_AMOUNT:
727
  raise HTTPException(status_code=400, detail=f"打赏金额必须在1-{MAX_TIP_AMOUNT}之间")
728
 
729
+ # 🔒 P0数据修复(时序):先只读校验 → SQL转账扣款 → 提交成功后再写打赏榜
730
+ # 避免余额不足/重复请求时榜单已被更新的数据不一致
731
+ prompts_db_ro = db.load_data("prompts.json", default_data=[])
732
+ target_prompt = next((p for p in prompts_db_ro if p["id"] == prompt_id), None)
733
+ if not target_prompt:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
734
  raise HTTPException(status_code=404, detail="提示词不存在")
735
+ if target_prompt.get("author") == current_user:
736
  raise HTTPException(status_code=400, detail="不能打赏自己的提示词")
737
+ author = target_prompt.get("author")
738
+ if not author:
739
+ raise HTTPException(status_code=404, detail="作者账户不存在")
740
+ prompt_title = target_prompt.get("title")
741
 
742
+ # 💳 使用 SQL Wallet 系统处理余额转账(先扣款)
743
  try:
 
 
 
744
 
745
  # 🔒 P1幂等性防护:检查最近5秒内是否存在相同交易
746
  recent_cutoff = datetime.datetime.utcnow() - datetime.timedelta(seconds=5)
 
780
  prev_hash_tipper = last_tx_tipper.tx_hash if last_tx_tipper else "GENESIS_HASH"
781
  prev_hash_author = last_tx_author.tx_hash if last_tx_author else "GENESIS_HASH"
782
 
783
+ # 获取用户信息(prompt_title 已在预校验阶段取得)
784
  users_db = db.load_data("users.json", default_data={})
785
  author_info = users_db.get(author, {})
786
  tipper_info = users_db.get(current_user, {})
787
  author_name = author_info.get("name", author)
788
  tipper_name = tipper_info.get("name", current_user)
789
 
 
 
 
 
 
 
 
790
  # 打赏方交易记录 (TIP_OUT)
791
  tx_tipper = Transaction(
792
  tx_id=tx_id_tipper,
 
855
  logger.error(f"PROMPT_TIP_ERROR | from={current_user} | prompt={prompt_id} | amount={amount} | error={str(e)}")
856
  raise HTTPException(status_code=500, detail="打赏处理失败,请稍后重试")
857
 
858
+ # 🏆 扣款成功后再写打赏榜(写榜失败仅记录日志,不影响资金安全)
859
+ def board_updater(data):
860
+ for prompt in data:
861
+ if prompt["id"] == prompt_id:
862
+ tip_board = prompt.get("tip_board", [])
863
+ existing = next((t for t in tip_board if t["account"] == current_user), None)
864
+ if existing:
865
+ existing["amount"] += amount
866
+ else:
867
+ tip_board.append({"account": current_user, "amount": amount, "is_anon": is_anon})
868
+ tip_board.sort(key=lambda x: x["amount"], reverse=True)
869
+ prompt["tip_board"] = tip_board
870
+ break
871
+
872
+ try:
873
+ db.atomic_update("prompts.json", board_updater, default_data=[])
874
+ except Exception as e:
875
+ logger.error(f"PROMPT_TIP_BOARD_ERROR | prompt={prompt_id} | error={str(e)}")
876
+
877
+ return {"status": "success", "message": f"成功打赏 {amount} 积分"}
878
 
879
 
880
  # ==========================================
router_proxy.py CHANGED
@@ -14,6 +14,7 @@ import urllib.error
14
  import 数据库连接 as json_db
15
  from database_sql import get_db
16
  from models_sql import Ownership
 
17
 
18
  router = APIRouter()
19
 
@@ -22,7 +23,8 @@ class ProxyGithubZipRequest(BaseModel):
22
  """GitHub ZIP 代理下载请求模型"""
23
  url: str = Field(..., min_length=1, description="资源URL")
24
  item_id: str = Field(..., min_length=1, max_length=100, description="资源ID")
25
- account: str = Field(..., min_length=1, max_length=50, description="用户账号")
 
26
 
27
  @field_validator('item_id')
28
  @classmethod
@@ -31,31 +33,21 @@ class ProxyGithubZipRequest(BaseModel):
31
  if not re.match(r'^[a-zA-Z0-9_-]+$', v):
32
  raise ValueError('item_id只能包含字母、数字、下划线和连字符')
33
  return v
34
-
35
- @field_validator('account')
36
- @classmethod
37
- def validate_account(cls, v: str) -> str:
38
- """验证account非空且长度符合要求"""
39
- if len(v) < 1:
40
- raise ValueError('account不能为空')
41
- if len(v) > 50:
42
- raise ValueError('account长度不能超过50个字符')
43
- return v
44
 
45
  @router.post("/api/proxy_github_zip")
46
- async def proxy_github_zip(req_data: ProxyGithubZipRequest, db: Session = Depends(get_db)):
47
  """云端代理:校验所有权后,拉取 GitHub 私有库 ZIP 流透传给本地"""
48
  items_db = json_db.load_data("items.json", default_data=[])
49
  item = next((i for i in items_db if i["id"] == req_data.item_id), None)
50
  if not item: return JSONResponse(content={"error": "资源不存在"}, status_code=404)
51
 
52
- # 1. 核心鉴权:验证用户是否购买过该工具
53
  try:
54
  price = int(float(item.get("price", 0) or 0))
55
  except (ValueError, TypeError):
56
  price = 0
57
- if price > 0 and req_data.account != item.get("author"):
58
- owned = db.query(Ownership).filter(Ownership.account == req_data.account, Ownership.item_id == req_data.item_id).first()
59
  if not owned:
60
  return JSONResponse(content={"error": "🚨 拒绝访问:未找到购买记录!"}, status_code=403)
61
 
@@ -198,7 +190,8 @@ class ProxyDownloadRequest(BaseModel):
198
  """工作流/应用 JSON 代理下载请求模型"""
199
  url: str = Field(..., min_length=1, description="下载URL")
200
  item_id: str = Field(..., min_length=1, max_length=100, description="资源ID")
201
- account: str = Field(..., min_length=1, max_length=50, description="用户账号")
 
202
 
203
  @field_validator('item_id')
204
  @classmethod
@@ -208,16 +201,6 @@ class ProxyDownloadRequest(BaseModel):
208
  raise ValueError('item_id只能包含字母、数字、下划线和连字符')
209
  return v
210
 
211
- @field_validator('account')
212
- @classmethod
213
- def validate_account(cls, v: str) -> str:
214
- """验证account非空且长度符合要求"""
215
- if len(v) < 1:
216
- raise ValueError('account不能为空')
217
- if len(v) > 50:
218
- raise ValueError('account长度不能超过50个字符')
219
- return v
220
-
221
  @field_validator('url')
222
  @classmethod
223
  def validate_url(cls, v: str) -> str:
@@ -242,7 +225,7 @@ class ProxyDownloadRequest(BaseModel):
242
  return v
243
 
244
  @router.post("/api/proxy_download")
245
- async def proxy_download(req_data: ProxyDownloadRequest, db: Session = Depends(get_db)):
246
  """云端代理:校验所有权后,拉取真实的工作流 JSON 文件并透传给本地"""
247
  items_db = json_db.load_data("items.json", default_data=[])
248
  item = next((i for i in items_db if i["id"] == req_data.item_id), None)
@@ -250,10 +233,13 @@ async def proxy_download(req_data: ProxyDownloadRequest, db: Session = Depends(g
250
  if not item:
251
  return JSONResponse(content={"error": "云端记录中找不到该资源"}, status_code=404)
252
 
253
- # 1. 核心鉴权:验证用户是否购买/获取过该工作流
254
- price = int(item.get("price", 0))
255
- if price > 0 and req_data.account != item.get("author"):
256
- owned = db.query(Ownership).filter(Ownership.account == req_data.account, Ownership.item_id == req_data.item_id).first()
 
 
 
257
  if not owned:
258
  return JSONResponse(content={"error": "您尚未获取该工作流,请先在社区页面点击获取"}, status_code=403)
259
 
 
14
  import 数据库连接 as json_db
15
  from database_sql import get_db
16
  from models_sql import Ownership
17
+ from 安全认证 import require_auth
18
 
19
  router = APIRouter()
20
 
 
23
  """GitHub ZIP 代理下载请求模型"""
24
  url: str = Field(..., min_length=1, description="资源URL")
25
  item_id: str = Field(..., min_length=1, max_length=100, description="资源ID")
26
+ # 🔒 P0安全加固:身份以 Authorization Token 为准,account 仅作向后兼容字段,不再作为鉴权依据
27
+ account: Optional[str] = Field(None, max_length=50, description="用户账号(已废弃,以Token为准)")
28
 
29
  @field_validator('item_id')
30
  @classmethod
 
33
  if not re.match(r'^[a-zA-Z0-9_-]+$', v):
34
  raise ValueError('item_id只能包含字母、数字、下划线和连字符')
35
  return v
 
 
 
 
 
 
 
 
 
 
36
 
37
  @router.post("/api/proxy_github_zip")
38
+ async def proxy_github_zip(req_data: ProxyGithubZipRequest, account: str = Depends(require_auth), db: Session = Depends(get_db)):
39
  """云端代理:校验所有权后,拉取 GitHub 私有库 ZIP 流透传给本地"""
40
  items_db = json_db.load_data("items.json", default_data=[])
41
  item = next((i for i in items_db if i["id"] == req_data.item_id), None)
42
  if not item: return JSONResponse(content={"error": "资源不存在"}, status_code=404)
43
 
44
+ # 1. 核心鉴权:验证用户是否购买过该工具(account 来自 Token 验证,不可伪造)
45
  try:
46
  price = int(float(item.get("price", 0) or 0))
47
  except (ValueError, TypeError):
48
  price = 0
49
+ if price > 0 and account != item.get("author"):
50
+ owned = db.query(Ownership).filter(Ownership.account == account, Ownership.item_id == req_data.item_id).first()
51
  if not owned:
52
  return JSONResponse(content={"error": "🚨 拒绝访问:未找到购买记录!"}, status_code=403)
53
 
 
190
  """工作流/应用 JSON 代理下载请求模型"""
191
  url: str = Field(..., min_length=1, description="下载URL")
192
  item_id: str = Field(..., min_length=1, max_length=100, description="资源ID")
193
+ # 🔒 P0安全加固:身份以 Authorization Token 为准,account 仅作向后兼容字段
194
+ account: Optional[str] = Field(None, max_length=50, description="用户账号(已废弃,以Token为准)")
195
 
196
  @field_validator('item_id')
197
  @classmethod
 
201
  raise ValueError('item_id只能包含字母、数字、下划线和连字符')
202
  return v
203
 
 
 
 
 
 
 
 
 
 
 
204
  @field_validator('url')
205
  @classmethod
206
  def validate_url(cls, v: str) -> str:
 
225
  return v
226
 
227
  @router.post("/api/proxy_download")
228
+ async def proxy_download(req_data: ProxyDownloadRequest, account: str = Depends(require_auth), db: Session = Depends(get_db)):
229
  """云端代理:校验所有权后,拉取真实的工作流 JSON 文件并透传给本地"""
230
  items_db = json_db.load_data("items.json", default_data=[])
231
  item = next((i for i in items_db if i["id"] == req_data.item_id), None)
 
233
  if not item:
234
  return JSONResponse(content={"error": "云端记录中找不到该资源"}, status_code=404)
235
 
236
+ # 1. 核心鉴权:验证用户是否购买/获取过该工作流(account 来自 Token 验证,不可伪造)
237
+ try:
238
+ price = int(float(item.get("price", 0) or 0))
239
+ except (ValueError, TypeError):
240
+ price = 0
241
+ if price > 0 and account != item.get("author"):
242
+ owned = db.query(Ownership).filter(Ownership.account == account, Ownership.item_id == req_data.item_id).first()
243
  if not owned:
244
  return JSONResponse(content={"error": "您尚未获取该工作流,请先在社区页面点击获取"}, status_code=403)
245
 
router_tasks.py CHANGED
@@ -13,7 +13,7 @@
13
 
14
  from fastapi import APIRouter, HTTPException, Depends, Query
15
  from sqlalchemy.orm import Session
16
- from models import TaskCreate, TaskUpdate, TaskApply, TaskAssign, TaskSubmit, TaskAccept
17
  import 数据库连接 as db
18
  from 安全认证 import require_auth, is_admin
19
  from notifications import add_notification
@@ -103,6 +103,11 @@ TASK_STATUS = {
103
  # 🔄 过期任务自动处理 + 自动退款
104
  # ==========================================
105
  import datetime
 
 
 
 
 
106
 
107
  def check_and_update_expired_tasks(tasks_db, db_session=None):
108
  """
@@ -159,60 +164,80 @@ def check_and_update_expired_tasks(tasks_db, db_session=None):
159
  # ========== 第二阶段:执行SQL事务(钱包退款+交易记录) ==========
160
  refund_results = [] # 记录退款成功的任务
161
  sql_committed = False
162
-
163
- try:
164
- for item in expired_task_indices:
165
- if item["amount"] > 0:
166
- wallet = db_session.query(Wallet).filter(Wallet.account == item["publisher"]).with_for_update().first()
167
- if wallet:
168
- wallet.frozen_balance = max(0, wallet.frozen_balance - item["amount"])
169
- wallet.balance += item["amount"]
170
-
171
- # 记录退款交易
172
- create_task_transaction(
173
- db_session, item["publisher"], "TASK_REFUND",
174
- item["amount"], task_id=item["task_id"],
175
- task_title=item["title"]
176
- )
177
-
178
- refund_results.append(item)
179
-
180
- # 💳 P0修复:先 commit SQL 事务
181
- db_session.commit()
182
- sql_committed = True
183
- logger.info(f"TASK_EXPIRED_SQL_COMMIT | refunded_tasks={len(refund_results)}")
184
-
185
- except Exception as e:
186
- # 💳 P0修复:SQL事务失败,回滚所有操作,不修改JSON
187
- db_session.rollback()
188
- logger.error(f"TASK_EXPIRED_SQL_ROLLBACK | error={str(e)}")
189
- # 不修改 JSON,下次再试
190
- return False
191
-
192
- # ========== 第三阶段:SQL成功后修改JSON任务状态 ==========
193
- if sql_committed:
194
  try:
195
  for item in expired_task_indices:
196
- task = tasks_db[item["index"]]
197
- task["status"] = "expired"
198
- task["expired_at"] = int(time.time())
199
  if item["amount"] > 0:
200
- task["refunded"] = True
201
- task["refund_amount"] = item["amount"]
202
-
203
- # 💳 P0修复:保存JSON数据
204
- db.save_data("tasks.json", tasks_db)
205
- logger.info(f"TASK_EXPIRED_JSON_SAVED | tasks={len(expired_task_indices)}")
206
-
 
 
 
 
 
 
 
 
 
 
 
 
207
  except Exception as e:
208
- # 💳 P0修复:JSON保存失败,记录告警但���回滚SQL(资金已转移,可后续手动恢复)
209
- import traceback
210
- traceback.print_exc()
211
- task_ids = ",".join(item["task_id"] for item in expired_task_indices)
212
- print(f"[CRITICAL-DATA-INCONSISTENCY] task_id={task_ids}, SQL已退款但JSON状态未更新. Error: {e}")
213
- logger.critical(f"TASK_EXPIRED_JSON_SAVE_FAILED | [CRITICAL-DATA-INCONSISTENCY] task_ids={task_ids} | SQL已退款但JSON状态未更新 | error={str(e)}")
214
- # 不抛出异常 - 资金安全退回是更高优先级
215
- # 定时任务会在下次扫描时修正状态不一致
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
 
217
  # 发送退款通知(在事务外执行,失败不影响主流程)
218
  for item in refund_results:
@@ -748,10 +773,13 @@ async def assign_task(task_id: str, assignee: str, current_user: str = Depends(r
748
  # ==========================================
749
 
750
  @router.post("/api/tasks/{task_id}/submit")
751
- async def submit_task(task_id: str, deliverables: list, note: str = None, current_user: str = Depends(require_auth)):
752
  """
753
  接单者提交成果
754
  """
 
 
 
755
  tasks_db = db.load_data("tasks.json", default_data=[])
756
 
757
  for task in tasks_db:
@@ -926,11 +954,14 @@ async def accept_task(task_id: str, is_accepted: bool, feedback: str = None, cur
926
 
927
 
928
  @router.post("/api/tasks/{task_id}/dispute")
929
- async def create_dispute(task_id: str, reason: str, evidence: list = None, current_user: str = Depends(require_auth)):
930
  """
931
  发起申诉(发布者或接单者均可)
932
  - evidence: 证据图片URL列表(可选)
933
  """
 
 
 
934
  tasks_db = db.load_data("tasks.json", default_data=[])
935
  disputes_db = db.load_data("disputes.json", default_data=[])
936
 
@@ -1030,10 +1061,13 @@ async def get_dispute_detail(dispute_id: str):
1030
  raise HTTPException(status_code=404, detail="申诉不存在")
1031
 
1032
  @router.post("/api/disputes/{dispute_id}/respond")
1033
- async def respond_dispute(dispute_id: str, response: str, evidence: list = None, current_user: str = Depends(require_auth)):
1034
  """
1035
  被申诉方回应申诉
1036
  """
 
 
 
1037
  disputes_db = db.load_data("disputes.json", default_data=[])
1038
 
1039
  for dispute in disputes_db:
@@ -1479,54 +1513,25 @@ async def tip_task(task_id: str, amount: int, is_anon: bool = False, current_use
1479
  """
1480
  if amount <= 0:
1481
  raise HTTPException(status_code=400, detail="打赏金额必须大于0")
1482
-
1483
- result_container = [None]
1484
- publisher_account = [None] # 用于在原子操作外获取发布者账号
1485
-
1486
- def updater(data):
1487
- # 在锁内查找任务
1488
- target_task = None
1489
- for task in data:
1490
- if task["id"] == task_id:
1491
- target_task = task
1492
- break
1493
-
1494
- if not target_task:
1495
- result_container[0] = {"error": "not_found"}
1496
- return
1497
-
1498
- # 不能打赏自己
1499
- if target_task.get("publisher") == current_user:
1500
- result_container[0] = {"error": "self_tip"}
1501
- return
1502
-
1503
- publisher_account[0] = target_task.get("publisher")
1504
-
1505
- # 更新打赏榜单
1506
- tip_board = target_task.get("tip_board", [])
1507
- existing = next((t for t in tip_board if t["account"] == current_user), None)
1508
- if existing:
1509
- existing["amount"] += amount
1510
- else:
1511
- tip_board.append({"account": current_user, "amount": amount, "is_anon": is_anon})
1512
- tip_board.sort(key=lambda x: x["amount"], reverse=True)
1513
- target_task["tip_board"] = tip_board
1514
-
1515
- result_container[0] = {"status": "success", "message": f"成功打赏 {amount} 积分"}
1516
-
1517
- db.atomic_update("tasks.json", updater, default_data=[])
1518
-
1519
- result = result_container[0]
1520
- if result is None or result.get("error") == "not_found":
1521
  raise HTTPException(status_code=404, detail="任务不存在")
1522
- if result.get("error") == "self_tip":
1523
  raise HTTPException(status_code=400, detail="不能打赏自己的任务")
 
 
 
 
1524
 
1525
- # 💳 使用 SQL Wallet 系统处理余额转账
1526
  try:
1527
- publisher = publisher_account[0]
1528
- if not publisher:
1529
- raise HTTPException(status_code=404, detail="作者账户不存在")
1530
 
1531
  # 🔒 P1幂等性防护:检查最近5秒内是否存在相同交易
1532
  recent_cutoff = datetime.datetime.utcnow() - datetime.timedelta(seconds=5)
@@ -1566,20 +1571,13 @@ async def tip_task(task_id: str, amount: int, is_anon: bool = False, current_use
1566
  prev_hash_tipper = last_tx_tipper.tx_hash if last_tx_tipper else "GENESIS_HASH"
1567
  prev_hash_author = last_tx_author.tx_hash if last_tx_author else "GENESIS_HASH"
1568
 
1569
- # 获取用户信息和任务标题
1570
  users_db = db.load_data("users.json", default_data={})
1571
- tasks_db = db.load_data("tasks.json", default_data=[])
1572
  author_info = users_db.get(publisher, {})
1573
  tipper_info = users_db.get(current_user, {})
1574
  author_name = author_info.get("name", publisher)
1575
  tipper_name = tipper_info.get("name", current_user)
1576
 
1577
- task_title = None
1578
- for task in tasks_db:
1579
- if task["id"] == task_id:
1580
- task_title = task.get("title")
1581
- break
1582
-
1583
  # 打赏方交易记录 (TIP_OUT)
1584
  tx_tipper = Transaction(
1585
  tx_id=tx_id_tipper,
@@ -1648,7 +1646,26 @@ async def tip_task(task_id: str, amount: int, is_anon: bool = False, current_use
1648
  logger.error(f"TASK_TIP_ERROR | task={task_id} | amount={amount} | error={str(e)}")
1649
  raise HTTPException(status_code=500, detail="打赏处理失败,请稍后重试")
1650
 
1651
- return result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1652
 
1653
  # ==========================================
1654
  # 💬 评论接口(复用通用评论系统)
 
13
 
14
  from fastapi import APIRouter, HTTPException, Depends, Query
15
  from sqlalchemy.orm import Session
16
+ from models import TaskCreate, TaskUpdate, TaskSubmit, TaskDispute, TaskDisputeResponse # 🔧 P2修复:提交/申诉/回应端点改为 body 模型,匹配前端 JSON 提交
17
  import 数据库连接 as db
18
  from 安全认证 import require_auth, is_admin
19
  from notifications import add_notification
 
103
  # 🔄 过期任务自动处理 + 自动退款
104
  # ==========================================
105
  import datetime
106
+ import threading
107
+
108
+ # 🔒 P1修复(CAS):过期退款串行锁——防止并发请求各持旧 tasks.json 快照,
109
+ # 把同一任务重复判定为过期导致双重退款
110
+ _expired_task_lock = threading.Lock()
111
 
112
  def check_and_update_expired_tasks(tasks_db, db_session=None):
113
  """
 
164
  # ========== 第二阶段:执行SQL事务(钱包退款+交易记录) ==========
165
  refund_results = [] # 记录退款成功的任务
166
  sql_committed = False
167
+
168
+ # 🔒 P1修复(CAS):整个"复核→退款→写状态"流程串行化,并以磁盘最新数据复核任务状态
169
+ # (仅退仍为 open 且未退过款的任务),防止并发旧快照导致同一任务重复退款
170
+ with _expired_task_lock:
171
+ try:
172
+ fresh_tasks = {t["id"]: t for t in db.load_data("tasks.json", default_data=[])}
173
+ except Exception:
174
+ fresh_tasks = {} # 复核失败时宁可不退款,等待下次扫描
175
+
176
+ def _still_refundable(item):
177
+ fresh = fresh_tasks.get(item["task_id"])
178
+ return fresh is not None and fresh.get("status") == "open" and not fresh.get("refunded")
179
+
180
+ expired_task_indices = [item for item in expired_task_indices if _still_refundable(item)]
181
+ if not expired_task_indices:
182
+ return updated
183
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  try:
185
  for item in expired_task_indices:
 
 
 
186
  if item["amount"] > 0:
187
+ wallet = db_session.query(Wallet).filter(Wallet.account == item["publisher"]).with_for_update().first()
188
+ if wallet:
189
+ wallet.frozen_balance = max(0, wallet.frozen_balance - item["amount"])
190
+ wallet.balance += item["amount"]
191
+
192
+ # 记录退款交易
193
+ create_task_transaction(
194
+ db_session, item["publisher"], "TASK_REFUND",
195
+ item["amount"], task_id=item["task_id"],
196
+ task_title=item["title"]
197
+ )
198
+
199
+ refund_results.append(item)
200
+
201
+ # 💳 P0修复:先 commit SQL 事务
202
+ db_session.commit()
203
+ sql_committed = True
204
+ logger.info(f"TASK_EXPIRED_SQL_COMMIT | refunded_tasks={len(refund_results)}")
205
+
206
  except Exception as e:
207
+ # 💳 P0修复:SQL事务失败,回滚所有操作,不修改JSON
208
+ db_session.rollback()
209
+ logger.error(f"TASK_EXPIRED_SQL_ROLLBACK | error={str(e)}")
210
+ # 不修改 JSON,下次再试
211
+ return False
212
+
213
+ # ========== 第三阶段:SQL成功后修改JSON任务状态 ==========
214
+ if sql_committed:
215
+ try:
216
+ # 🔒 P1修复:按 id 定位任务(而非索引),避免并发修改后索引错位
217
+ task_map = {t["id"]: t for t in tasks_db}
218
+ for item in expired_task_indices:
219
+ task = task_map.get(item["task_id"])
220
+ if not task:
221
+ continue
222
+ task["status"] = "expired"
223
+ task["expired_at"] = int(time.time())
224
+ if item["amount"] > 0:
225
+ task["refunded"] = True
226
+ task["refund_amount"] = item["amount"]
227
+
228
+ # 💳 P0修复:保存JSON数据
229
+ db.save_data("tasks.json", tasks_db)
230
+ logger.info(f"TASK_EXPIRED_JSON_SAVED | tasks={len(expired_task_indices)}")
231
+
232
+ except Exception as e:
233
+ # 💳 P0修复:JSON保存失败,记录告警但不回滚SQL(资金已转移,可后续手动恢复)
234
+ import traceback
235
+ traceback.print_exc()
236
+ task_ids = ",".join(item["task_id"] for item in expired_task_indices)
237
+ print(f"[CRITICAL-DATA-INCONSISTENCY] task_id={task_ids}, SQL已退款但JSON状态未更新. Error: {e}")
238
+ logger.critical(f"TASK_EXPIRED_JSON_SAVE_FAILED | [CRITICAL-DATA-INCONSISTENCY] task_ids={task_ids} | SQL已退款但JSON状态未更新 | error={str(e)}")
239
+ # 不抛出异常 - 资金安全退回是更高优先级
240
+ # 定时任务会在下次扫描时修正状态不一致
241
 
242
  # 发送退款通知(在事务外执行,失败不影响主流程)
243
  for item in refund_results:
 
773
  # ==========================================
774
 
775
  @router.post("/api/tasks/{task_id}/submit")
776
+ async def submit_task(task_id: str, payload: TaskSubmit, current_user: str = Depends(require_auth)):
777
  """
778
  接单者提交成果
779
  """
780
+ # 🔧 P2修复:前端以 JSON body 提交,原裸标量参数会被解析为 query 导致 422
781
+ deliverables = payload.deliverables
782
+ note = payload.note
783
  tasks_db = db.load_data("tasks.json", default_data=[])
784
 
785
  for task in tasks_db:
 
954
 
955
 
956
  @router.post("/api/tasks/{task_id}/dispute")
957
+ async def create_dispute(task_id: str, payload: TaskDispute, current_user: str = Depends(require_auth)):
958
  """
959
  发起申诉(发布者或接单者均可)
960
  - evidence: 证据图片URL列表(可选)
961
  """
962
+ # 🔧 P2修复:前端以 JSON body 提交,原裸标量参数会被解析为 query 导致 422
963
+ reason = payload.reason
964
+ evidence = payload.evidence
965
  tasks_db = db.load_data("tasks.json", default_data=[])
966
  disputes_db = db.load_data("disputes.json", default_data=[])
967
 
 
1061
  raise HTTPException(status_code=404, detail="申诉不存在")
1062
 
1063
  @router.post("/api/disputes/{dispute_id}/respond")
1064
+ async def respond_dispute(dispute_id: str, payload: TaskDisputeResponse, current_user: str = Depends(require_auth)):
1065
  """
1066
  被申诉方回应申诉
1067
  """
1068
+ # 🔧 P2修复:前端以 JSON body 提交,原裸标量参数会被解析为 query 导致 422
1069
+ response = payload.response
1070
+ evidence = payload.evidence
1071
  disputes_db = db.load_data("disputes.json", default_data=[])
1072
 
1073
  for dispute in disputes_db:
 
1513
  """
1514
  if amount <= 0:
1515
  raise HTTPException(status_code=400, detail="打赏金额必须大于0")
1516
+ MAX_TIP_AMOUNT = 100000
1517
+ if amount > MAX_TIP_AMOUNT:
1518
+ raise HTTPException(status_code=400, detail=f"打赏金额必须在1-{MAX_TIP_AMOUNT}之间")
1519
+
1520
+ # 🔒 P0数据修复(时序):先只读校验 → SQL转账扣款 → 提交成功后再写打赏榜
1521
+ # 避免余额不足/重复请求时榜单已被更新的数据不一致
1522
+ tasks_db_ro = db.load_data("tasks.json", default_data=[])
1523
+ target_task = next((t for t in tasks_db_ro if t["id"] == task_id), None)
1524
+ if not target_task:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1525
  raise HTTPException(status_code=404, detail="任务不存在")
1526
+ if target_task.get("publisher") == current_user:
1527
  raise HTTPException(status_code=400, detail="不能打赏自己的任务")
1528
+ publisher = target_task.get("publisher")
1529
+ if not publisher:
1530
+ raise HTTPException(status_code=404, detail="作者账户不存在")
1531
+ task_title = target_task.get("title")
1532
 
1533
+ # 💳 使用 SQL Wallet 系统处理余额转账(先扣款)
1534
  try:
 
 
 
1535
 
1536
  # 🔒 P1幂等性防护:检查最近5秒内是否存在相同交易
1537
  recent_cutoff = datetime.datetime.utcnow() - datetime.timedelta(seconds=5)
 
1571
  prev_hash_tipper = last_tx_tipper.tx_hash if last_tx_tipper else "GENESIS_HASH"
1572
  prev_hash_author = last_tx_author.tx_hash if last_tx_author else "GENESIS_HASH"
1573
 
1574
+ # 获取用户信息(task_title 已在预校验阶段取得)
1575
  users_db = db.load_data("users.json", default_data={})
 
1576
  author_info = users_db.get(publisher, {})
1577
  tipper_info = users_db.get(current_user, {})
1578
  author_name = author_info.get("name", publisher)
1579
  tipper_name = tipper_info.get("name", current_user)
1580
 
 
 
 
 
 
 
1581
  # 打赏方交易记录 (TIP_OUT)
1582
  tx_tipper = Transaction(
1583
  tx_id=tx_id_tipper,
 
1646
  logger.error(f"TASK_TIP_ERROR | task={task_id} | amount={amount} | error={str(e)}")
1647
  raise HTTPException(status_code=500, detail="打赏处理失败,请稍后重试")
1648
 
1649
+ # 🏆 扣款成功后再写打赏榜(写榜失败仅记录日志,不影响资金安全)
1650
+ def board_updater(data):
1651
+ for task in data:
1652
+ if task["id"] == task_id:
1653
+ tip_board = task.get("tip_board", [])
1654
+ existing = next((t for t in tip_board if t["account"] == current_user), None)
1655
+ if existing:
1656
+ existing["amount"] += amount
1657
+ else:
1658
+ tip_board.append({"account": current_user, "amount": amount, "is_anon": is_anon})
1659
+ tip_board.sort(key=lambda x: x["amount"], reverse=True)
1660
+ task["tip_board"] = tip_board
1661
+ break
1662
+
1663
+ try:
1664
+ db.atomic_update("tasks.json", board_updater, default_data=[])
1665
+ except Exception as e:
1666
+ logger.error(f"TASK_TIP_BOARD_ERROR | task={task_id} | error={str(e)}")
1667
+
1668
+ return {"status": "success", "message": f"成功打赏 {amount} 积分"}
1669
 
1670
  # ==========================================
1671
  # 💬 评论接口(复用通用评论系统)
router_wallet.py CHANGED
@@ -21,7 +21,7 @@ import os
21
  import datetime
22
  import logging
23
  from database_sql import get_db
24
- from models_sql import Wallet, Transaction, Ownership, Refund
25
  from models import RechargeRequest, WithdrawRequest, PurchaseRequest, TipRequest
26
  import 数据库连接 as json_db
27
  from notifications import add_notification
@@ -49,8 +49,51 @@ from verify_code_engine import VERIFY_CODES
49
  router = APIRouter()
50
 
51
  # 🔒 P0安全修复:充值订单预存映射,回调时通过订单号查找真实账号,不依赖 subject 字段
 
52
  PENDING_RECHARGE_ORDERS = {} # {order_id: {"account": str, "amount": int, "created_at": int}}
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  # ==========================================
55
  # 脱敏工具函数
56
  # ==========================================
@@ -127,7 +170,7 @@ def calculate_tx_hash(tx_id, account, tx_type, amount, prev_hash):
127
  return hashlib.sha256(data.encode()).hexdigest()
128
 
129
  @router.post("/api/wallet/create_recharge_order")
130
- async def create_recharge_order(req: RechargeRequest, current_user: str = Depends(require_auth)):
131
  if not alipay:
132
  # 这里会将真实的错误原因直接弹窗发给前端!
133
  raise HTTPException(status_code=500, detail="支付网关配置错误,请联系管理员")
@@ -137,12 +180,8 @@ async def create_recharge_order(req: RechargeRequest, current_user: str = Depend
137
  order_id = f"PAY_{uuid.uuid4().hex}"
138
  subject = f"ComfyUI Community Points - {authenticated_account}"
139
 
140
- # 🔒 P0安全修复:预存订单号->账号映射,供回调时安全查找
141
- PENDING_RECHARGE_ORDERS[order_id] = {
142
- "account": authenticated_account,
143
- "amount": req.amount,
144
- "created_at": int(time.time())
145
- }
146
 
147
  order_string = alipay.api_alipay_trade_precreate(
148
  out_trade_no=order_id,
@@ -179,8 +218,8 @@ async def alipay_notify(request: Request, db: Session = Depends(get_db)):
179
 
180
  amount = int(float(data.get("total_amount", 0)))
181
 
182
- # 🔒 P0安全修复:通过预存订单映射查找真实账号,不依赖 subject 字段提取
183
- order_info = PENDING_RECHARGE_ORDERS.get(order_id)
184
  if not order_info:
185
  logger.warning(f"RECHARGE_ORDER_NOT_FOUND | order={order_id} | subject={data.get('subject', '')}")
186
  return Response(content="fail", media_type="text/plain")
@@ -192,8 +231,8 @@ async def alipay_notify(request: Request, db: Session = Depends(get_db)):
192
  logger.warning(f"RECHARGE_ACCOUNT_MISMATCH | order={order_id} | expected={account} | subject={subject_account}")
193
  return Response(content="fail", media_type="text/plain")
194
 
195
- # 清理已处理的预存订单
196
- PENDING_RECHARGE_ORDERS.pop(order_id, None)
197
 
198
  # 🔒 P0安全修复:充值金额有效性校验
199
  if amount <= 0:
@@ -243,8 +282,8 @@ async def check_order(order_id: str, current_user: str = Depends(require_auth),
243
  # 🔒 P0安全修复:使用认证用户账号,不再接受客户端传入的 account 参数
244
  account = current_user
245
 
246
- # 🔒 纵深防御:优先从预存订单映射查找账号
247
- order_info = PENDING_RECHARGE_ORDERS.get(order_id)
248
  if order_info:
249
  account = order_info["account"]
250
  # 验证认证用户与订单所属用户一致
@@ -299,7 +338,10 @@ async def check_order(order_id: str, current_user: str = Depends(require_auth),
299
  return {"status": "PENDING"}
300
 
301
  @router.get("/api/wallet/{account}")
302
- async def get_wallet(account: str, db: Session = Depends(get_db)):
 
 
 
303
  wallet = db.query(Wallet).filter(Wallet.account == account).first()
304
 
305
  # 🚀 P1性能优化:使用聚合函数代替 .all() + sum
@@ -813,12 +855,16 @@ async def get_transactions(
813
  page: int = 1,
814
  limit: int = 20,
815
  tx_type: str = None,
 
816
  db: Session = Depends(get_db)
817
  ):
818
  """
819
  获取用户交易明细(分页)
820
  - tx_type: 可选筛选(RECHARGE/PURCHASE/SALE/TIP_OUT/TIP_IN/WITHDRAW/TASK_FREEZE/TASK_DEPOSIT/TASK_PAYMENT/TASK_INCOME/TASK_REFUND)
821
  """
 
 
 
822
  query = db.query(Transaction).filter(Transaction.account == account)
823
 
824
  if tx_type:
@@ -865,11 +911,14 @@ async def get_transactions(
865
  }
866
 
867
  @router.get("/api/wallet/{account}/task-stats")
868
- async def get_task_stats(account: str, db: Session = Depends(get_db)):
869
  """
870
  📊 获取用户任务收益统计
871
  🚀 P1性能优化:使用单次查询+分组聚合替代多次查询
872
  """
 
 
 
873
  from sqlalchemy import func as sql_func, case
874
 
875
  # 🚀 P1性能优化:使用分组聚合一次查询多种类型的统计
@@ -930,8 +979,11 @@ async def get_task_stats(account: str, db: Session = Depends(get_db)):
930
  }
931
 
932
  @router.get("/api/wallet/{account}/tip-stats")
933
- async def get_tip_stats(account: str, db: Session = Depends(get_db)):
934
  """📊 获取用户打赏统计"""
 
 
 
935
  from sqlalchemy import func as sql_func
936
 
937
  stats = db.query(
@@ -963,12 +1015,15 @@ async def get_tip_stats(account: str, db: Session = Depends(get_db)):
963
  }
964
 
965
  @router.get("/api/wallet/{account}/sales-stats")
966
- async def get_sales_stats(account: str, db: Session = Depends(get_db)):
967
  """
968
  📊 获取用户销售统计
969
  - 销售收入:来自 earn_balance(累计销售收益)
970
  - 购买支出:来自 PURCHASE 类型的交易记录
971
  """
 
 
 
972
  from sqlalchemy import func as sql_func
973
 
974
  # 获取钱包信息(用于读取 earn_balance)
@@ -1057,10 +1112,13 @@ async def get_purchases(
1057
  # ==========================================
1058
 
1059
  @router.get("/api/wallet/{account}/purchase/{item_id}")
1060
- async def get_purchase_status(account: str, item_id: str, db: Session = Depends(get_db)):
1061
  """
1062
  获取购买状态(用���判断是否可退款)
1063
  """
 
 
 
1064
  ownership = db.query(Ownership).filter(
1065
  Ownership.account == account,
1066
  Ownership.item_id == item_id,
 
21
  import datetime
22
  import logging
23
  from database_sql import get_db
24
+ from models_sql import Wallet, Transaction, Ownership, Refund, RechargeOrder
25
  from models import RechargeRequest, WithdrawRequest, PurchaseRequest, TipRequest
26
  import 数据库连接 as json_db
27
  from notifications import add_notification
 
49
  router = APIRouter()
50
 
51
  # 🔒 P0安全修复:充值订单预存映射,回调时通过订单号查找真实账号,不依赖 subject 字段
52
+ # 🔒 P0数据修复:内存字典仅作热缓存,订单同时落库 RechargeOrder 表,实例重启不丢失
53
  PENDING_RECHARGE_ORDERS = {} # {order_id: {"account": str, "amount": int, "created_at": int}}
54
 
55
+ def _save_pending_order(order_id, account, amount, db):
56
+ """预存充值订单:内存缓存 + SQL 持久化双写"""
57
+ PENDING_RECHARGE_ORDERS[order_id] = {
58
+ "account": account,
59
+ "amount": amount,
60
+ "created_at": int(time.time())
61
+ }
62
+ try:
63
+ db.add(RechargeOrder(order_id=order_id, account=account, amount=amount, status="pending"))
64
+ db.commit()
65
+ except Exception as e:
66
+ db.rollback()
67
+ logger.warning(f"RECHARGE_ORDER_PERSIST_FAILED | order={order_id} | error={str(e)}")
68
+
69
+ def _get_pending_order(order_id, db):
70
+ """查找充值订单:优先内存缓存,未命中回查 SQL(重启容灾)"""
71
+ info = PENDING_RECHARGE_ORDERS.get(order_id)
72
+ if info:
73
+ return info
74
+ try:
75
+ row = db.query(RechargeOrder).filter(RechargeOrder.order_id == order_id).first()
76
+ if row:
77
+ return {
78
+ "account": row.account,
79
+ "amount": row.amount,
80
+ "created_at": int(row.created_at.timestamp()) if row.created_at else 0
81
+ }
82
+ except Exception as e:
83
+ logger.warning(f"RECHARGE_ORDER_QUERY_FAILED | order={order_id} | error={str(e)}")
84
+ return None
85
+
86
+ def _mark_order_paid(order_id, db):
87
+ """订单入账后:清理内存缓存并在 SQL 中标记已支付"""
88
+ PENDING_RECHARGE_ORDERS.pop(order_id, None)
89
+ try:
90
+ row = db.query(RechargeOrder).filter(RechargeOrder.order_id == order_id).first()
91
+ if row:
92
+ row.status = "paid"
93
+ db.commit()
94
+ except Exception:
95
+ db.rollback()
96
+
97
  # ==========================================
98
  # 脱敏工具函数
99
  # ==========================================
 
170
  return hashlib.sha256(data.encode()).hexdigest()
171
 
172
  @router.post("/api/wallet/create_recharge_order")
173
+ async def create_recharge_order(req: RechargeRequest, current_user: str = Depends(require_auth), db: Session = Depends(get_db)):
174
  if not alipay:
175
  # 这里会将真实的错误原因直接弹窗发给前端!
176
  raise HTTPException(status_code=500, detail="支付网关配置错误,请联系管理员")
 
180
  order_id = f"PAY_{uuid.uuid4().hex}"
181
  subject = f"ComfyUI Community Points - {authenticated_account}"
182
 
183
+ # 🔒 P0安全修复:预存订单号->账号映射,供回调时安全查找(内存+SQL双写)
184
+ _save_pending_order(order_id, authenticated_account, req.amount, db)
 
 
 
 
185
 
186
  order_string = alipay.api_alipay_trade_precreate(
187
  out_trade_no=order_id,
 
218
 
219
  amount = int(float(data.get("total_amount", 0)))
220
 
221
+ # 🔒 P0安全修复:通过预存订单映射查找真实账号,不依赖 subject 字段提取(内存未命中时回查SQL)
222
+ order_info = _get_pending_order(order_id, db)
223
  if not order_info:
224
  logger.warning(f"RECHARGE_ORDER_NOT_FOUND | order={order_id} | subject={data.get('subject', '')}")
225
  return Response(content="fail", media_type="text/plain")
 
231
  logger.warning(f"RECHARGE_ACCOUNT_MISMATCH | order={order_id} | expected={account} | subject={subject_account}")
232
  return Response(content="fail", media_type="text/plain")
233
 
234
+ # 清理已处理的预存订单(内存移除 + SQL 标记 paid)
235
+ _mark_order_paid(order_id, db)
236
 
237
  # 🔒 P0安全修复:充值金额有效性校验
238
  if amount <= 0:
 
282
  # 🔒 P0安全修复:使用认证用户账号,不再接受客户端传入的 account 参数
283
  account = current_user
284
 
285
+ # 🔒 纵深防御:优先从预存订单映射查找账号(内存未命中时回查SQL)
286
+ order_info = _get_pending_order(order_id, db)
287
  if order_info:
288
  account = order_info["account"]
289
  # 验证认证用户与订单所属用户一致
 
338
  return {"status": "PENDING"}
339
 
340
  @router.get("/api/wallet/{account}")
341
+ async def get_wallet(account: str, current_user: str = Depends(require_auth), db: Session = Depends(get_db)):
342
+ # 🔒 P0安全加固:钱包数据属敏感信息,需登录且仅可查询本人钱包
343
+ if current_user != account:
344
+ raise HTTPException(status_code=403, detail="只能查询自己的钱包信息")
345
  wallet = db.query(Wallet).filter(Wallet.account == account).first()
346
 
347
  # 🚀 P1性能优化:使用聚合函数代替 .all() + sum
 
855
  page: int = 1,
856
  limit: int = 20,
857
  tx_type: str = None,
858
+ current_user: str = Depends(require_auth),
859
  db: Session = Depends(get_db)
860
  ):
861
  """
862
  获取用户交易明细(分页)
863
  - tx_type: 可选筛选(RECHARGE/PURCHASE/SALE/TIP_OUT/TIP_IN/WITHDRAW/TASK_FREEZE/TASK_DEPOSIT/TASK_PAYMENT/TASK_INCOME/TASK_REFUND)
864
  """
865
+ # 🔒 P0安全加固:交易明细属敏感信息,需登录且仅可查询本人记录
866
+ if current_user != account:
867
+ raise HTTPException(status_code=403, detail="只能查询自己的交易明细")
868
  query = db.query(Transaction).filter(Transaction.account == account)
869
 
870
  if tx_type:
 
911
  }
912
 
913
  @router.get("/api/wallet/{account}/task-stats")
914
+ async def get_task_stats(account: str, current_user: str = Depends(require_auth), db: Session = Depends(get_db)):
915
  """
916
  📊 获取用户任务收益统计
917
  🚀 P1性能优化:使用单次查询+分组聚合替代多次查询
918
  """
919
+ # 🔒 P0安全加固:收益统计属敏感信息,需登录且仅可查询本人数据
920
+ if current_user != account:
921
+ raise HTTPException(status_code=403, detail="只能查询自己的任务统计")
922
  from sqlalchemy import func as sql_func, case
923
 
924
  # 🚀 P1性能优化:使用分组聚合一次查询多种类型的统计
 
979
  }
980
 
981
  @router.get("/api/wallet/{account}/tip-stats")
982
+ async def get_tip_stats(account: str, current_user: str = Depends(require_auth), db: Session = Depends(get_db)):
983
  """📊 获取用户打赏统计"""
984
+ # 🔒 P0安全加固:打赏统计属敏感信息,需登录且仅可查询本人数据
985
+ if current_user != account:
986
+ raise HTTPException(status_code=403, detail="只能查询自己的打赏统计")
987
  from sqlalchemy import func as sql_func
988
 
989
  stats = db.query(
 
1015
  }
1016
 
1017
  @router.get("/api/wallet/{account}/sales-stats")
1018
+ async def get_sales_stats(account: str, current_user: str = Depends(require_auth), db: Session = Depends(get_db)):
1019
  """
1020
  📊 获取用户销售统计
1021
  - 销售收入:来自 earn_balance(累计销售收益)
1022
  - 购买支出:来自 PURCHASE 类型的交易记录
1023
  """
1024
+ # 🔒 P0安全加固:销售统计属敏感信息,需登录且仅可查询本人数据
1025
+ if current_user != account:
1026
+ raise HTTPException(status_code=403, detail="只能查询自己的销售统计")
1027
  from sqlalchemy import func as sql_func
1028
 
1029
  # 获取钱包信息(用于读取 earn_balance)
 
1112
  # ==========================================
1113
 
1114
  @router.get("/api/wallet/{account}/purchase/{item_id}")
1115
+ async def get_purchase_status(account: str, item_id: str, current_user: str = Depends(require_auth), db: Session = Depends(get_db)):
1116
  """
1117
  获取购买状态(用���判断是否可退款)
1118
  """
1119
+ # 🔒 P0安全加固:购买状态属敏感信息,需登录且仅可查询本人记录
1120
+ if current_user != account:
1121
+ raise HTTPException(status_code=403, detail="只能查询自己的购买状态")
1122
  ownership = db.query(Ownership).filter(
1123
  Ownership.account == account,
1124
  Ownership.item_id == item_id,
安全认证.py CHANGED
@@ -27,14 +27,16 @@ import bcrypt
27
  # ==========================================
28
  # JWT_SECRET: 用于签名 Token,生产环境应设置环境变量
29
  # PASSWORD_SALT: 密码哈希加盐,增强安全性
30
- # 降级策略:环境变量未配置时使用内置默认值(适用于单实例部署)
31
  _DEFAULT_JWT_SECRET = "ComfyUI-Ranking-JWT-Secret-v2-beta-2026"
32
  _DEFAULT_PASSWORD_SALT = "ComfyUI-Ranking-Salt-v1"
33
 
34
  JWT_SECRET = os.environ.get("JWT_SECRET") or _DEFAULT_JWT_SECRET
35
  PASSWORD_SALT = os.environ.get("PASSWORD_SALT") or _DEFAULT_PASSWORD_SALT
36
 
37
- # 单实例部署时使用内置默认值即可,无需额外配置环境变量
 
 
38
 
39
  # Token 有效期配置(单位:秒)
40
  TOKEN_EXPIRE_SECONDS = 7 * 24 * 60 * 60 # 默认7天(向后兼容)
@@ -299,7 +301,7 @@ async def require_auth(authorization: str = Header(None, alias="Authorization"))
299
 
300
  token = parts[1]
301
 
302
- # 验证 Token(使用 fallback 兼容旧版 mock_token)
303
  is_valid, account, error_msg = verify_token_with_fallback(token)
304
 
305
  if not is_valid:
@@ -333,57 +335,33 @@ async def optional_auth(authorization: str = Header(None, alias="Authorization")
333
 
334
 
335
  # ==========================================
336
- # 🔄 兼容性支持:Mock Token 过渡
337
  # ==========================================
338
- # 作用:在过渡期间同时支持旧版 mock_token 和新版 JWT
339
- # 关联:前端更新后可移除此函数
 
340
 
341
  def verify_token_with_fallback(token: str) -> Tuple[bool, Optional[str], str]:
342
  """
343
- 兼容验证:同时支持 JWT 和旧版 Token(过渡期)
344
-
345
- 验证顺序:
346
- 1. 标准 JWT 验证(当前密钥)
347
- 2. 旧版 mock_token 格式兼容
348
- 3. 签名无效的 JWT:降级提取 payload 中的 sub 字段(过渡期兼容)
349
-
350
- ⚠️ 过渡期策略:
351
- - 当前阶段:宽容模式,允许签名无效的旧 JWT 通过(提取 sub 信任)
352
- - 下一版本:移除第3步,强制要求有效签名
353
 
354
  参数:
355
- token: Token 字符串(JWT 或 mock_token_xxx)
356
 
357
  返回:
358
  (is_valid, account, error_message)
359
  """
360
- # 1. 先尝试标准 JWT 验证
 
 
 
361
  is_valid, payload, error_msg = verify_token(token)
362
  if is_valid:
363
- return True, payload.get("sub"), ""
364
-
365
- # 2. 降级:检查是否为旧版 mock_token
366
- if token.startswith("mock_token_"):
367
- account = token.replace("mock_token_", "")
368
- print(f"⚠️ [过渡兼容] 旧版 mock_token 用户: {account}")
369
  return True, account, ""
370
-
371
- # 3. 过渡期兼容:JWT 签名无效时,尝试直接解析 payload 提取账号
372
- # 这允许旧版(密钥不同时签发的)JWT 仍能使用
373
- # ⚠️ 下一版本将移除此降级逻辑,届时用户必须重新登录
374
- if error_msg in ("Token 签名无效", "Token 已过期,请重新登录"):
375
- try:
376
- parts = token.split(".")
377
- if len(parts) == 3:
378
- payload_json = _base64url_decode(parts[1]).decode("utf-8")
379
- payload_data = json.loads(payload_json)
380
- account = payload_data.get("sub")
381
- if account:
382
- print(f"⚠️ [过渡兼容] 旧版 JWT 降级信任,用户: {account}(签名未验证,下版本将失效)")
383
- return True, account, ""
384
- except Exception:
385
- pass
386
-
387
  return False, None, error_msg or "Token 验证失败"
388
 
389
 
 
27
  # ==========================================
28
  # JWT_SECRET: 用于签名 Token,生产环境应设置环境变量
29
  # PASSWORD_SALT: 密码哈希加盐,增强安全性
30
+ # ⚠️ 默认密钥仅供开发环境使用;生产环境必须通过环境变量配置 JWT_SECRET
31
  _DEFAULT_JWT_SECRET = "ComfyUI-Ranking-JWT-Secret-v2-beta-2026"
32
  _DEFAULT_PASSWORD_SALT = "ComfyUI-Ranking-Salt-v1"
33
 
34
  JWT_SECRET = os.environ.get("JWT_SECRET") or _DEFAULT_JWT_SECRET
35
  PASSWORD_SALT = os.environ.get("PASSWORD_SALT") or _DEFAULT_PASSWORD_SALT
36
 
37
+ # 🔒 P0安全加固:未配置 JWT_SECRET 时启动日志告警(默认密钥已公开于源码,必须轮换)
38
+ if not os.environ.get("JWT_SECRET"):
39
+ print("⚠️ [安全告警] 未设置 JWT_SECRET 环境变量,正在使用内置默认密钥,请尽快配置并轮换!")
40
 
41
  # Token 有效期配置(单位:秒)
42
  TOKEN_EXPIRE_SECONDS = 7 * 24 * 60 * 60 # 默认7天(向后兼容)
 
301
 
302
  token = parts[1]
303
 
304
+ # 验证 Token(严格 JWT 验证,不再有降级信任)
305
  is_valid, account, error_msg = verify_token_with_fallback(token)
306
 
307
  if not is_valid:
 
335
 
336
 
337
  # ==========================================
338
+ # 🔒 严格 Token 验证(已移除所有降级信任)
339
  # ==========================================
340
+ # 🔒 P0安全修复历史:旧版实现存在两个高危后门,已全部移除:
341
+ # 1. mock_token_xxx 前缀直接信任后缀为账号(任何人可伪造任意身份)
342
+ # 2. 签名无效/过期的 JWT 仍解析 payload 信任 sub(base64 即可伪造)
343
 
344
  def verify_token_with_fallback(token: str) -> Tuple[bool, Optional[str], str]:
345
  """
346
+ 严格验证:仅信任签名有效且未过期的 JWT
 
 
 
 
 
 
 
 
 
347
 
348
  参数:
349
+ token: Token 字符串
350
 
351
  返回:
352
  (is_valid, account, error_message)
353
  """
354
+ # 🔒 拒绝旧版 mock_token:前缀可被任何人构造,等同于任意账号伪造后门
355
+ if token.startswith("mock_token_"):
356
+ return False, None, "旧版凭证已失效,请重新登录"
357
+
358
  is_valid, payload, error_msg = verify_token(token)
359
  if is_valid:
360
+ account = payload.get("sub")
361
+ if not account:
362
+ return False, None, "Token 中缺少用户信息"
 
 
 
363
  return True, account, ""
364
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
365
  return False, None, error_msg or "Token 验证失败"
366
 
367
 
数据库连接.py CHANGED
@@ -425,9 +425,13 @@ def save_data(file_name: str, data: Union[Dict, List], force_sync: bool = False)
425
  except Exception as e:
426
  # 写入失败,清理临时文件
427
  if os.path.exists(temp_path):
428
- os.remove(temp_path)
 
 
 
429
  print(f"🚨 保存 {file_name} 失败: {e}")
430
- raise
 
431
 
432
  # ========== 第五步:标记文件脏,等待批量同步 ==========
433
  if HF_TOKEN:
@@ -437,6 +441,8 @@ def save_data(file_name: str, data: Union[Dict, List], force_sync: bool = False)
437
  if force_sync:
438
  _immediate_sync_file(file_name)
439
 
 
 
440
 
441
  # ==========================================
442
  # ☁️ HF 批量同步机制
 
425
  except Exception as e:
426
  # 写入失败,清理临时文件
427
  if os.path.exists(temp_path):
428
+ try:
429
+ os.remove(temp_path)
430
+ except OSError:
431
+ pass
432
  print(f"🚨 保存 {file_name} 失败: {e}")
433
+ # 🔧 回归核查修复:按契约返回 False(旧版 raise 与 -> bool 声明不符,且无调用方依赖异常)
434
+ return False
435
 
436
  # ========== 第五步:标记文件脏,等待批量同步 ==========
437
  if HF_TOKEN:
 
441
  if force_sync:
442
  _immediate_sync_file(file_name)
443
 
444
+ return True
445
+
446
 
447
  # ==========================================
448
  # ☁️ HF 批量同步机制
测试脚本.py CHANGED
@@ -34,12 +34,16 @@ class TestAuthSecurity(unittest.TestCase):
34
  self.create_token = create_token
35
  self.verify_token = verify_token
36
 
37
- def test_password_hash_deterministic(self):
38
- """测试:相同密码生成相同哈希"""
 
39
  password = "test123456"
40
  hash1 = self.hash_password(password)
41
  hash2 = self.hash_password(password)
42
- self.assertEqual(hash1, hash2, "相同密码应生成相同哈希")
 
 
 
43
 
44
  def test_password_hash_different(self):
45
  """测试:不同密码生成不同哈希"""
@@ -58,14 +62,19 @@ class TestAuthSecurity(unittest.TestCase):
58
  hashed = self.hash_password("correctPassword")
59
  self.assertFalse(self.verify_password("wrongPassword", hashed), "错误密码应验证失败")
60
 
61
- def test_password_verify_plaintext_fallback(self):
62
- """测试:明文密码兼容(向后兼容)"""
63
- plaintext = "oldPassword123"
64
- # 明文密码(旧版格式)
65
- self.assertTrue(self.verify_password(plaintext, plaintext), "明文密码应向后兼容")
 
 
 
 
66
 
67
  def test_token_create_and_verify(self):
68
  """测试:Token 创建和验证"""
 
69
  account = "test_user"
70
  token = self.create_token(account)
71
 
@@ -74,13 +83,15 @@ class TestAuthSecurity(unittest.TestCase):
74
  self.assertIn(".", token, "Token 应包含点号分隔符")
75
 
76
  # 验证 Token
77
- verified_account = self.verify_token(token)
78
- self.assertEqual(verified_account, account, "验证后的账号应与原账号一致")
 
79
 
80
  def test_token_invalid(self):
81
  """测试:无效 Token 验证失败"""
82
- result = self.verify_token("invalid.token.here")
83
- self.assertIsNone(result, "无效 Token 应返回 None")
 
84
 
85
  def test_token_with_extra_data(self):
86
  """测试:Token 携带额外数据"""
@@ -88,18 +99,20 @@ class TestAuthSecurity(unittest.TestCase):
88
  extra = {"role": "admin", "level": 5}
89
  token = self.create_token(account, extra_data=extra)
90
 
91
- verified_account = self.verify_token(token)
92
- self.assertEqual(verified_account, account, "验证后的账号应正确")
 
 
93
 
94
- def test_mock_token_support(self):
95
- """测试:mock_token 兼容(向后兼容)"""
96
  from 安全认证 import verify_token_with_fallback
97
 
98
- # 旧版 mock_token 格式
99
  mock_token = "mock_token_testuser_1234567890"
100
  is_valid, account, _ = verify_token_with_fallback(mock_token)
101
- self.assertTrue(is_valid, "mock_token 应通过验证")
102
- self.assertEqual(account, "testuser_1234567890", "mock_token 应正确解析账号")
103
 
104
 
105
  # ==========================================
 
34
  self.create_token = create_token
35
  self.verify_token = verify_token
36
 
37
+ def test_password_hash_unique_and_verifiable(self):
38
+ """测试:bcrypt 每次加盐不同,哈希唯一且可验证"""
39
+ # 🧹 P2修正:bcrypt 随机加盐,相同密码每次哈希不同(旧断言基于已废弃的固定盐 SHA256)
40
  password = "test123456"
41
  hash1 = self.hash_password(password)
42
  hash2 = self.hash_password(password)
43
+ self.assertNotEqual(hash1, hash2, "bcrypt 应每次生成不同哈希(随机盐)")
44
+ self.assertTrue(hash1.startswith("$2b$"), "哈希应为 bcrypt 格式")
45
+ self.assertTrue(self.verify_password(password, hash1), "哈希1应验证通过")
46
+ self.assertTrue(self.verify_password(password, hash2), "哈希2应验证通过")
47
 
48
  def test_password_hash_different(self):
49
  """测试:不同密码生成不同哈希"""
 
62
  hashed = self.hash_password("correctPassword")
63
  self.assertFalse(self.verify_password("wrongPassword", hashed), "错误密码应验证失败")
64
 
65
+ def test_password_verify_legacy_sha256(self):
66
+ """测试:旧版 SHA256 哈希向后兼容"""
67
+ # 🧹 P2修正:明文兼容已不存在,实际兼容路径为旧版 SHA256(64位十六进制)
68
+ import hashlib
69
+ from 安全认证 import PASSWORD_SALT
70
+ password = "oldPassword123"
71
+ legacy_hash = hashlib.sha256(f"{PASSWORD_SALT}{password}".encode("utf-8")).hexdigest()
72
+ self.assertTrue(self.verify_password(password, legacy_hash), "旧版 SHA256 哈希应向后兼容")
73
+ self.assertFalse(self.verify_password("wrongPassword", legacy_hash), "错误密码不应通过旧版兼容路径")
74
 
75
  def test_token_create_and_verify(self):
76
  """测试:Token 创建和验证"""
77
+ # 🧹 P2修正:verify_token 返回 (is_valid, payload, error) 三元组
78
  account = "test_user"
79
  token = self.create_token(account)
80
 
 
83
  self.assertIn(".", token, "Token 应包含点号分隔符")
84
 
85
  # 验证 Token
86
+ is_valid, payload, error_msg = self.verify_token(token)
87
+ self.assertTrue(is_valid, f"有效 Token 应验证通过: {error_msg}")
88
+ self.assertEqual(payload.get("sub"), account, "验证后的账号应与原账号一致")
89
 
90
  def test_token_invalid(self):
91
  """测试:无效 Token 验证失败"""
92
+ is_valid, payload, error_msg = self.verify_token("invalid.token.here")
93
+ self.assertFalse(is_valid, "无效 Token 应验证失败")
94
+ self.assertIsNone(payload, "无效 Token 的 payload 应为 None")
95
 
96
  def test_token_with_extra_data(self):
97
  """测试:Token 携带额外数据"""
 
99
  extra = {"role": "admin", "level": 5}
100
  token = self.create_token(account, extra_data=extra)
101
 
102
+ is_valid, payload, _ = self.verify_token(token)
103
+ self.assertTrue(is_valid, "携带额外数据的 Token 应验证通过")
104
+ self.assertEqual(payload.get("sub"), account, "验证后的账号应正确")
105
+ self.assertEqual(payload.get("role"), "admin", "额外数据应保留在 payload 中")
106
 
107
+ def test_mock_token_rejected(self):
108
+ """测试:旧版 mock_token 必须被拒绝(P0安全后门已移除)"""
109
  from 安全认证 import verify_token_with_fallback
110
 
111
+ # 🧹 P2修正:mock_token 前缀可伪造任意身份,P0已移除降级信任,必须拒绝
112
  mock_token = "mock_token_testuser_1234567890"
113
  is_valid, account, _ = verify_token_with_fallback(mock_token)
114
+ self.assertFalse(is_valid, "mock_token 应被拒绝(伪造后门已封堵)")
115
+ self.assertIsNone(account, "mock_token 不应解析出账号")
116
 
117
 
118
  # ==========================================