Spaces:
Running
Running
大量优化
Browse files- image_moderation.py +2 -2
- rate_limiter.py +11 -2
- router_comments.py +39 -17
- router_items.py +3 -1
- router_messages.py +6 -0
- router_tasks.py +45 -13
- router_users_auth.py +10 -6
- router_wallet.py +88 -36
- 数据库连接.py +1 -1
image_moderation.py
CHANGED
|
@@ -198,7 +198,7 @@ async def moderate_image_tencent(image_content: bytes) -> ModerationResult:
|
|
| 198 |
response = result.get("Response", {})
|
| 199 |
|
| 200 |
if "Error" in response:
|
| 201 |
-
logger.error(f"腾讯云审核API错误: {response
|
| 202 |
return ModerationResult(passed=True, suggestion="pass",
|
| 203 |
details={"error": response["Error"]})
|
| 204 |
|
|
@@ -220,7 +220,7 @@ async def moderate_image_tencent(image_content: bytes) -> ModerationResult:
|
|
| 220 |
)
|
| 221 |
|
| 222 |
except Exception as e:
|
| 223 |
-
logger.error(f"腾讯云审核异常: {
|
| 224 |
# 审核服务异常时拒绝,需要人工审核
|
| 225 |
return ModerationResult(passed=False, suggestion="review",
|
| 226 |
details={"error": "审核服务暂时不可用,需人工审核"})
|
|
|
|
| 198 |
response = result.get("Response", {})
|
| 199 |
|
| 200 |
if "Error" in response:
|
| 201 |
+
logger.error(f"腾讯云审核API错误: {response.get('Error', 'unknown')!r}")
|
| 202 |
return ModerationResult(passed=True, suggestion="pass",
|
| 203 |
details={"error": response["Error"]})
|
| 204 |
|
|
|
|
| 220 |
)
|
| 221 |
|
| 222 |
except Exception as e:
|
| 223 |
+
logger.error(f"腾讯云审核异常: {e!r}")
|
| 224 |
# 审核服务异常时拒绝,需要人工审核
|
| 225 |
return ModerationResult(passed=False, suggestion="review",
|
| 226 |
details={"error": "审核服务暂时不可用,需人工审核"})
|
rate_limiter.py
CHANGED
|
@@ -167,9 +167,18 @@ def record_request(ip: str, endpoint: str):
|
|
| 167 |
now = time.time()
|
| 168 |
key = f"{ip}:{endpoint}"
|
| 169 |
|
| 170 |
-
#
|
| 171 |
if len(ip_stats) >= MAX_DICT_SIZE:
|
| 172 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
|
| 174 |
if key not in ip_stats:
|
| 175 |
ip_stats[key] = {"count": 0, "first_request": now}
|
|
|
|
| 167 |
now = time.time()
|
| 168 |
key = f"{ip}:{endpoint}"
|
| 169 |
|
| 170 |
+
# 🔒 P0安全加固:主动清理机制,防止短期内 OOM
|
| 171 |
if len(ip_stats) >= MAX_DICT_SIZE:
|
| 172 |
+
# 第一步:清理过期条目(超过1小时未活跃的)
|
| 173 |
+
expired_keys = [k for k, v in list(ip_stats.items()) if now - v.get("last_request", 0) > 3600]
|
| 174 |
+
for k in expired_keys:
|
| 175 |
+
del ip_stats[k]
|
| 176 |
+
# 第二步:如果清理后仍超限,删除最老的 20%
|
| 177 |
+
if len(ip_stats) >= MAX_DICT_SIZE:
|
| 178 |
+
sorted_keys = sorted(ip_stats.keys(), key=lambda k: ip_stats[k].get("last_request", 0))
|
| 179 |
+
for k in sorted_keys[:len(ip_stats) // 5]:
|
| 180 |
+
del ip_stats[k]
|
| 181 |
+
logger.warning(f"IP_STATS_EVICTION | evicted={len(sorted_keys[:len(ip_stats) // 5])}, remaining={len(ip_stats)}")
|
| 182 |
|
| 183 |
if key not in ip_stats:
|
| 184 |
ip_stats[key] = {"count": 0, "first_request": now}
|
router_comments.py
CHANGED
|
@@ -13,26 +13,48 @@ router = APIRouter()
|
|
| 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 |
-
|
| 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="目标存在问题")
|
| 19 |
-
|
| 20 |
list_key = f"{interaction.action_type}d_by"
|
| 21 |
count_key = "likes" if interaction.action_type == "like" else "favorites"
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
if interaction.is_active:
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
add_notification(target_item.get("author", ""), {"type": interaction.action_type, "from_user": interaction.user_id, "target_item_id": target_item["id"], "target_item_title": target_item["title"]})
|
| 30 |
-
else:
|
| 31 |
-
if interaction.user_id in user_list:
|
| 32 |
-
user_list.remove(interaction.user_id)
|
| 33 |
-
target_item[count_key] = max(0, target_item[count_key] - 1)
|
| 34 |
-
db.save_data("items.json", items_db)
|
| 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)):
|
|
|
|
| 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 |
+
|
|
|
|
|
|
|
|
|
|
| 17 |
list_key = f"{interaction.action_type}d_by"
|
| 18 |
count_key = "likes" if interaction.action_type == "like" else "favorites"
|
| 19 |
+
|
| 20 |
+
# 使用闭包容器传递原子操作结果
|
| 21 |
+
result_container = {}
|
| 22 |
+
|
| 23 |
+
def updater(items_db):
|
| 24 |
+
target_item = next((item for item in items_db if item["id"] == interaction.item_id), None)
|
| 25 |
+
if not target_item:
|
| 26 |
+
result_container["error"] = "目标存在问题"
|
| 27 |
+
return
|
| 28 |
+
|
| 29 |
+
if list_key not in target_item:
|
| 30 |
+
target_item[list_key] = []
|
| 31 |
+
|
| 32 |
+
user_list = target_item[list_key]
|
| 33 |
+
if interaction.is_active:
|
| 34 |
+
# 双重检查:防止并发重复添加
|
| 35 |
+
if interaction.user_id not in user_list:
|
| 36 |
+
user_list.append(interaction.user_id)
|
| 37 |
+
else:
|
| 38 |
+
if interaction.user_id in user_list:
|
| 39 |
+
user_list.remove(interaction.user_id)
|
| 40 |
+
|
| 41 |
+
# 基于实际列表长度计算 count,即使并发重复也不会导致数量错误
|
| 42 |
+
target_item[count_key] = len(target_item[list_key])
|
| 43 |
+
result_container["count"] = target_item[count_key]
|
| 44 |
+
result_container["author"] = target_item.get("author", "")
|
| 45 |
+
result_container["item_id"] = target_item["id"]
|
| 46 |
+
result_container["title"] = target_item.get("title", "")
|
| 47 |
+
|
| 48 |
+
db.atomic_update("items.json", updater, default_data=[])
|
| 49 |
+
|
| 50 |
+
if result_container.get("error"):
|
| 51 |
+
raise HTTPException(status_code=404, detail=result_container["error"])
|
| 52 |
+
|
| 53 |
+
# 通知在原子操作外发送(通知失败不影响主流程)
|
| 54 |
if interaction.is_active:
|
| 55 |
+
add_notification(result_container.get("author", ""), {"type": interaction.action_type, "from_user": interaction.user_id, "target_item_id": result_container["item_id"], "target_item_title": result_container["title"]})
|
| 56 |
+
|
| 57 |
+
return {"status": "success", "new_count": result_container["count"]}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
@router.post("/api/comments")
|
| 60 |
async def post_comment(comment: CommentCreate, current_user: str = Depends(require_auth)):
|
router_items.py
CHANGED
|
@@ -312,7 +312,9 @@ async def get_creator_details(account: str):
|
|
| 312 |
}
|
| 313 |
|
| 314 |
@router.post("/api/items")
|
| 315 |
-
async def create_item(item: ItemCreate):
|
|
|
|
|
|
|
| 316 |
if not item.type:
|
| 317 |
raise HTTPException(status_code=400, detail="资源类型不能为空")
|
| 318 |
item.price = int(item.price or 0)
|
|
|
|
| 312 |
}
|
| 313 |
|
| 314 |
@router.post("/api/items")
|
| 315 |
+
async def create_item(item: ItemCreate, current_user: str = Depends(require_auth)):
|
| 316 |
+
# 🔒 P0安全修复:使用 JWT 认证的用户账号作为作者,防止伪造
|
| 317 |
+
item.author = current_user
|
| 318 |
if not item.type:
|
| 319 |
raise HTTPException(status_code=400, detail="资源类型不能为空")
|
| 320 |
item.price = int(item.price or 0)
|
router_messages.py
CHANGED
|
@@ -152,6 +152,12 @@ async def run_admin_script(req: AdminScriptRequest, current_user: str = Depends(
|
|
| 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] = []
|
|
|
|
| 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 |
+
|
| 156 |
+
# 验证接收者存在
|
| 157 |
+
users_db = db.load_data("users.json", default_data={})
|
| 158 |
+
if msg.receiver not in users_db:
|
| 159 |
+
raise HTTPException(status_code=404, detail="接收者不存在")
|
| 160 |
+
|
| 161 |
chats_db = db.load_data("chats.json", default_data={})
|
| 162 |
conv_id = f"{min(msg.sender, msg.receiver)}_{max(msg.sender, msg.receiver)}"
|
| 163 |
if conv_id not in chats_db: chats_db[conv_id] = []
|
router_tasks.py
CHANGED
|
@@ -205,9 +205,14 @@ def check_and_update_expired_tasks(tasks_db, db_session=None):
|
|
| 205 |
logger.info(f"TASK_EXPIRED_JSON_SAVED | tasks={len(expired_task_indices)}")
|
| 206 |
|
| 207 |
except Exception as e:
|
| 208 |
-
# 💳 P0修复:JSON保存失败,记录
|
| 209 |
-
|
| 210 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
|
| 212 |
# 发送退款通知(在事务外执行,失败不影响主流程)
|
| 213 |
for item in refund_results:
|
|
@@ -319,15 +324,24 @@ async def get_task_detail(task_id: str, current_user: str = None):
|
|
| 319 |
publisher_info = user_map.get(task.get("publisher"), {})
|
| 320 |
assignee_info = user_map.get(task.get("assignee"), {}) if task.get("assignee") else None
|
| 321 |
|
| 322 |
-
#
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
applicants_with_info
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
|
| 332 |
# 👁️ 过滤敏感字段(详情接口保留 liked_by 和 favorited_by,过滤 viewed_by)
|
| 333 |
task_data = {k: v for k, v in task.items() if k != "viewed_by"}
|
|
@@ -340,7 +354,8 @@ async def get_task_detail(task_id: str, current_user: str = None):
|
|
| 340 |
"publisher_avatar": publisher_info.get("avatarDataUrl", ""),
|
| 341 |
"assignee_name": assignee_info.get("name") if assignee_info else None,
|
| 342 |
"assignee_avatar": assignee_info.get("avatarDataUrl") if assignee_info else None,
|
| 343 |
-
"applicants":
|
|
|
|
| 344 |
"status_text": TASK_STATUS.get(task.get("status"), "未知")
|
| 345 |
}
|
| 346 |
}
|
|
@@ -515,6 +530,10 @@ async def cancel_task(task_id: str, current_user: str = Depends(require_auth), d
|
|
| 515 |
取消任务(仅发布者可操作,且仅在 open 状态时可取消)
|
| 516 |
💳 P0修复:取消时退还冻结资金
|
| 517 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 518 |
tasks_db = db.load_data("tasks.json", default_data=[])
|
| 519 |
|
| 520 |
for task in tasks_db:
|
|
@@ -527,6 +546,11 @@ async def cancel_task(task_id: str, current_user: str = Depends(require_auth), d
|
|
| 527 |
|
| 528 |
# 💳 P0修复:退还冻结资金
|
| 529 |
frozen_amount = task.get("frozen_amount", task.get("total_price", 0))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 530 |
refund_success = False
|
| 531 |
|
| 532 |
if frozen_amount > 0:
|
|
@@ -668,6 +692,10 @@ async def assign_task(task_id: str, assignee: str, current_user: str = Depends(r
|
|
| 668 |
|
| 669 |
deposit = task.get("deposit_amount", 0)
|
| 670 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 671 |
# 💳 从冻结金额中扣除订金(订金暂时不给接单者,待任务完成后一起给)
|
| 672 |
wallet = db_session.query(Wallet).filter(Wallet.account == current_user).with_for_update().first()
|
| 673 |
if not wallet or wallet.frozen_balance < deposit:
|
|
@@ -788,6 +816,10 @@ async def accept_task(task_id: str, is_accepted: bool, feedback: str = None, cur
|
|
| 788 |
deposit = task.get("deposit_paid", task.get("deposit_amount", 0)) # 优先使用已记录的支付订金
|
| 789 |
remaining = total_price - deposit # 尾款
|
| 790 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 791 |
if is_accepted:
|
| 792 |
# 💳 验收通过:支付尾款给接单者
|
| 793 |
# 💳 P0修复:使用事务保护,失败则回滚
|
|
|
|
| 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:
|
|
|
|
| 324 |
publisher_info = user_map.get(task.get("publisher"), {})
|
| 325 |
assignee_info = user_map.get(task.get("assignee"), {}) if task.get("assignee") else None
|
| 326 |
|
| 327 |
+
# 🔒 P0安全修复:仅任务发布者可查看完整申请者信息,其他人只返回申请人数
|
| 328 |
+
is_publisher = current_user and current_user == task.get("publisher")
|
| 329 |
+
if is_publisher:
|
| 330 |
+
# 发布者可查看完整申请者列表(带用户信息)
|
| 331 |
+
applicants_with_info = []
|
| 332 |
+
for app in task.get("applicants", []):
|
| 333 |
+
app_user = user_map.get(app.get("account"), {})
|
| 334 |
+
applicants_with_info.append({
|
| 335 |
+
**app,
|
| 336 |
+
"name": app_user.get("name", app.get("account")),
|
| 337 |
+
"avatar": app_user.get("avatarDataUrl", "")
|
| 338 |
+
})
|
| 339 |
+
applicants_result = applicants_with_info
|
| 340 |
+
applicant_count = len(task.get("applicants", []))
|
| 341 |
+
else:
|
| 342 |
+
# 非发布者仅返回申请人数,不暴露申请者个人信息
|
| 343 |
+
applicants_result = []
|
| 344 |
+
applicant_count = len(task.get("applicants", []))
|
| 345 |
|
| 346 |
# 👁️ 过滤敏感字段(详情接口保留 liked_by 和 favorited_by,过滤 viewed_by)
|
| 347 |
task_data = {k: v for k, v in task.items() if k != "viewed_by"}
|
|
|
|
| 354 |
"publisher_avatar": publisher_info.get("avatarDataUrl", ""),
|
| 355 |
"assignee_name": assignee_info.get("name") if assignee_info else None,
|
| 356 |
"assignee_avatar": assignee_info.get("avatarDataUrl") if assignee_info else None,
|
| 357 |
+
"applicants": applicants_result,
|
| 358 |
+
"applicant_count": applicant_count,
|
| 359 |
"status_text": TASK_STATUS.get(task.get("status"), "未知")
|
| 360 |
}
|
| 361 |
}
|
|
|
|
| 530 |
取消任务(仅发布者可操作,且仅在 open 状态时可取消)
|
| 531 |
💳 P0修复:取消时退还冻结资金
|
| 532 |
"""
|
| 533 |
+
# 🔒 P0安全修复:防御性断言,确保 current_user 不为 None
|
| 534 |
+
if not current_user:
|
| 535 |
+
raise HTTPException(status_code=401, detail="未认证")
|
| 536 |
+
|
| 537 |
tasks_db = db.load_data("tasks.json", default_data=[])
|
| 538 |
|
| 539 |
for task in tasks_db:
|
|
|
|
| 546 |
|
| 547 |
# 💳 P0修复:退还冻结资金
|
| 548 |
frozen_amount = task.get("frozen_amount", task.get("total_price", 0))
|
| 549 |
+
|
| 550 |
+
# 🔒 P0安全修复:冻结金额负数防护
|
| 551 |
+
if frozen_amount < 0:
|
| 552 |
+
raise HTTPException(status_code=400, detail="冻结金额无效,无法退款")
|
| 553 |
+
|
| 554 |
refund_success = False
|
| 555 |
|
| 556 |
if frozen_amount > 0:
|
|
|
|
| 692 |
|
| 693 |
deposit = task.get("deposit_amount", 0)
|
| 694 |
|
| 695 |
+
# 🔒 P0安全修复:订金金额负数防护
|
| 696 |
+
if deposit < 0:
|
| 697 |
+
raise HTTPException(status_code=400, detail="订金金额无效")
|
| 698 |
+
|
| 699 |
# 💳 从冻结金额中扣除订金(订金暂时不给接单者,待任务完成后一起给)
|
| 700 |
wallet = db_session.query(Wallet).filter(Wallet.account == current_user).with_for_update().first()
|
| 701 |
if not wallet or wallet.frozen_balance < deposit:
|
|
|
|
| 816 |
deposit = task.get("deposit_paid", task.get("deposit_amount", 0)) # 优先使用已记录的支付订金
|
| 817 |
remaining = total_price - deposit # 尾款
|
| 818 |
|
| 819 |
+
# 🔒 P0安全修复:金额有效性校验
|
| 820 |
+
if total_price < 0 or deposit < 0 or remaining < 0:
|
| 821 |
+
raise HTTPException(status_code=400, detail="任务金额数据异常,无法完成验收")
|
| 822 |
+
|
| 823 |
if is_accepted:
|
| 824 |
# 💳 验收通过:支付尾款给接单者
|
| 825 |
# 💳 P0修复:使用事务保护,失败则回滚
|
router_users_auth.py
CHANGED
|
@@ -78,8 +78,8 @@ async def send_verify_code(request: Request, req: SendCodeRequest, bg_tasks: Bac
|
|
| 78 |
# 🚀 P1性能优化:每次写入前触发清理过期验证码
|
| 79 |
cleanup_expired_codes()
|
| 80 |
|
| 81 |
-
# 构建缓存键(联系方式
|
| 82 |
-
cache_key = f"{req.contact}
|
| 83 |
|
| 84 |
# 将验证码存入内存缓存,有效期5分钟
|
| 85 |
VERIFY_CODES[cache_key] = {
|
|
@@ -111,7 +111,8 @@ async def send_code_api(req: SendCodeRequest):
|
|
| 111 |
|
| 112 |
# 生成6位随机验证码
|
| 113 |
code = str(random.randint(100000, 999999))
|
| 114 |
-
|
|
|
|
| 115 |
|
| 116 |
# 存入缓存,有效期5分钟
|
| 117 |
VERIFY_CODES[key] = {
|
|
@@ -125,7 +126,10 @@ async def send_code_api(req: SendCodeRequest):
|
|
| 125 |
send_email_code(req.contact, code, req.action_type)
|
| 126 |
return {"status": "success", "message": "验证码已成功发送至邮箱"}
|
| 127 |
except Exception as e:
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
| 129 |
|
| 130 |
elif req.contact_type == "phone":
|
| 131 |
return {"status": "success", "message": "验证码已成功发送至手机"}
|
|
@@ -178,7 +182,7 @@ async def register_user(request: Request, user: UserRegister):
|
|
| 178 |
|
| 179 |
# ========== 第二步:验证码校验(并发安全) ==========
|
| 180 |
# 根据注册方式构建缓存键
|
| 181 |
-
cache_key = f"{user.email}
|
| 182 |
# 🔒 P0安全修复:验证码一次性使用,并发安全键防止并发重用
|
| 183 |
async with _verify_code_lock:
|
| 184 |
cached = VERIFY_CODES.pop(cache_key, None)
|
|
@@ -346,7 +350,7 @@ async def reset_password(request: Request):
|
|
| 346 |
raise HTTPException(status_code=400, detail="填写的手机号与该账号绑定的手机号不匹配")
|
| 347 |
|
| 348 |
# 校验验证码(原子性获取+删除)
|
| 349 |
-
cache_key = f"{verify_contact}
|
| 350 |
# 🔒 P0安全修复:验证码一次性使用,原子性pop防止并发重用
|
| 351 |
cached = VERIFY_CODES.pop(cache_key, None)
|
| 352 |
expire_time = cached.get("expires_at", cached.get("expires", 0)) if cached else 0
|
|
|
|
| 78 |
# 🚀 P1性能优化:每次写入前触发清理过期验证码
|
| 79 |
cleanup_expired_codes()
|
| 80 |
|
| 81 |
+
# 🔒 P0安全加固:构建缓存键(联系方式:动作类型),使用冒号分隔符避免与邮箱中的下划线混淆
|
| 82 |
+
cache_key = f"{req.contact}:{req.action_type}"
|
| 83 |
|
| 84 |
# 将验证码存入内存缓存,有效期5分钟
|
| 85 |
VERIFY_CODES[cache_key] = {
|
|
|
|
| 111 |
|
| 112 |
# 生成6位随机验证码
|
| 113 |
code = str(random.randint(100000, 999999))
|
| 114 |
+
# 🔒 P0安全加固:使用冒号分隔符防止跨操作重用验证码
|
| 115 |
+
key = f"{req.contact}:{req.action_type}"
|
| 116 |
|
| 117 |
# 存入缓存,有效期5分钟
|
| 118 |
VERIFY_CODES[key] = {
|
|
|
|
| 126 |
send_email_code(req.contact, code, req.action_type)
|
| 127 |
return {"status": "success", "message": "验证码已成功发送至邮箱"}
|
| 128 |
except Exception as e:
|
| 129 |
+
# 🔒 P0安全修复:仅服务端记录详细错误,不暴露给前端
|
| 130 |
+
import traceback
|
| 131 |
+
traceback.print_exc()
|
| 132 |
+
raise HTTPException(status_code=500, detail="邮件发送失败,请稍后重试")
|
| 133 |
|
| 134 |
elif req.contact_type == "phone":
|
| 135 |
return {"status": "success", "message": "验证码已成功发送至手机"}
|
|
|
|
| 182 |
|
| 183 |
# ========== 第二步:验证码校验(并发安全) ==========
|
| 184 |
# 根据注册方式构建缓存键
|
| 185 |
+
cache_key = f"{user.email}:register" if user.email else f"{user.phone}:register"
|
| 186 |
# 🔒 P0安全修复:验证码一次性使用,并发安全键防止并发重用
|
| 187 |
async with _verify_code_lock:
|
| 188 |
cached = VERIFY_CODES.pop(cache_key, None)
|
|
|
|
| 350 |
raise HTTPException(status_code=400, detail="填写的手机号与该账号绑定的手机号不匹配")
|
| 351 |
|
| 352 |
# 校验验证码(原子性获取+删除)
|
| 353 |
+
cache_key = f"{verify_contact}:reset"
|
| 354 |
# 🔒 P0安全修复:验证码一次性使用,原子性pop防止并发重用
|
| 355 |
cached = VERIFY_CODES.pop(cache_key, None)
|
| 356 |
expire_time = cached.get("expires_at", cached.get("expires", 0)) if cached else 0
|
router_wallet.py
CHANGED
|
@@ -13,7 +13,7 @@
|
|
| 13 |
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 14 |
from fastapi.responses import Response
|
| 15 |
from sqlalchemy.orm import Session
|
| 16 |
-
from pydantic import BaseModel
|
| 17 |
import time
|
| 18 |
import uuid
|
| 19 |
import hashlib
|
|
@@ -48,6 +48,9 @@ from verify_code_engine import VERIFY_CODES
|
|
| 48 |
|
| 49 |
router = APIRouter()
|
| 50 |
|
|
|
|
|
|
|
|
|
|
| 51 |
# ==========================================
|
| 52 |
# 脱敏工具函数
|
| 53 |
# ==========================================
|
|
@@ -134,6 +137,13 @@ async def create_recharge_order(req: RechargeRequest, current_user: str = Depend
|
|
| 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(
|
| 138 |
out_trade_no=order_id,
|
| 139 |
total_amount=str(req.amount),
|
|
@@ -162,50 +172,92 @@ async def alipay_notify(request: Request, db: Session = Depends(get_db)):
|
|
| 162 |
if data.get("trade_status") in ("TRADE_SUCCESS", "TRADE_FINISHED"):
|
| 163 |
order_id = data.get("out_trade_no")
|
| 164 |
|
|
|
|
| 165 |
existing_tx = db.query(Transaction).filter(Transaction.tx_id == order_id).first()
|
| 166 |
-
if
|
| 167 |
-
|
| 168 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
|
| 170 |
-
# 🔒
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
wallet
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
|
|
|
|
|
|
|
|
|
| 197 |
|
| 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,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
# 防线 1:先查本地数据库(如果 Webhook 成功了,这里就能查到)
|
| 203 |
tx = db.query(Transaction).filter(Transaction.tx_id == order_id).first()
|
| 204 |
if tx:
|
| 205 |
return {"status": "SUCCESS"}
|
| 206 |
|
| 207 |
# 防线 2:主动向支付宝发起查单(解决 Hugging Face 收不到回调的绝杀技)
|
| 208 |
-
if alipay
|
| 209 |
try:
|
| 210 |
# 拿着订单号去问支付宝:这笔钱到底付了没?
|
| 211 |
result = alipay.api_alipay_trade_query(out_trade_no=order_id)
|
|
@@ -628,14 +680,14 @@ async def withdraw(request: Request, req: WithdrawRequest, current_user: str = D
|
|
| 628 |
if req.amount < 1:
|
| 629 |
raise HTTPException(status_code=400, detail="最小提现金额为1积分")
|
| 630 |
|
| 631 |
-
# 🔒 验证码缓存键格式:{contact}
|
| 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}
|
| 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
|
|
@@ -1191,7 +1243,7 @@ async def get_admin_withdrawals(
|
|
| 1191 |
}
|
| 1192 |
|
| 1193 |
class CompleteWithdrawRequest(BaseModel):
|
| 1194 |
-
payment_order_id: str
|
| 1195 |
|
| 1196 |
@router.post("/api/admin/withdrawals/{tx_id}/complete")
|
| 1197 |
async def complete_withdrawal(
|
|
|
|
| 13 |
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 14 |
from fastapi.responses import Response
|
| 15 |
from sqlalchemy.orm import Session
|
| 16 |
+
from pydantic import BaseModel, Field
|
| 17 |
import time
|
| 18 |
import uuid
|
| 19 |
import hashlib
|
|
|
|
| 48 |
|
| 49 |
router = APIRouter()
|
| 50 |
|
| 51 |
+
# 🔒 P0安全修复:充值订单预存映射,回调时通过订单号查找真实账号,不依赖 subject 字段
|
| 52 |
+
PENDING_RECHARGE_ORDERS = {} # {order_id: {"account": str, "amount": int, "created_at": int}}
|
| 53 |
+
|
| 54 |
# ==========================================
|
| 55 |
# 脱敏工具函数
|
| 56 |
# ==========================================
|
|
|
|
| 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,
|
| 149 |
total_amount=str(req.amount),
|
|
|
|
| 172 |
if data.get("trade_status") in ("TRADE_SUCCESS", "TRADE_FINISHED"):
|
| 173 |
order_id = data.get("out_trade_no")
|
| 174 |
|
| 175 |
+
# 🔒 第一层防御:快速预检查(优化,避免不必要的锁获取)
|
| 176 |
existing_tx = db.query(Transaction).filter(Transaction.tx_id == order_id).first()
|
| 177 |
+
if existing_tx:
|
| 178 |
+
return Response(content="success", media_type="text/plain")
|
| 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")
|
| 187 |
+
account = order_info["account"]
|
| 188 |
+
|
| 189 |
+
# 🔒 纵深防御:验证 subject 中的账号与预存映射一致
|
| 190 |
+
subject_account = data.get("subject", "").split(" - ")[-1] if " - " in data.get("subject", "") else ""
|
| 191 |
+
if subject_account and subject_account != account:
|
| 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:
|
| 200 |
+
logger.warning(f"RECHARGE_AMOUNT_INVALID | order={order_id} | amount={amount}")
|
| 201 |
+
return Response(content="fail", media_type="text/plain")
|
| 202 |
+
|
| 203 |
+
# 🔒 P1并发安全:使用悲观锁+异常处理+事务回滚防止并发充值问题
|
| 204 |
+
# 🔒 P1 TOCTOU修复:先获取行锁,再在锁内检查重复,确保检查和操作的原子性
|
| 205 |
+
try:
|
| 206 |
+
wallet = db.query(Wallet).filter(Wallet.account == account).with_for_update().first()
|
| 207 |
|
| 208 |
+
# 🔒 第二层防御:在持有行锁后再检查是否已处理过此订单(原子性防重复充值)
|
| 209 |
+
existing_tx = db.query(Transaction).filter(Transaction.tx_id == order_id).first()
|
| 210 |
+
if existing_tx:
|
| 211 |
+
db.commit() # 释放锁
|
| 212 |
+
return Response(content="success", media_type="text/plain") # 幂等返回成功
|
| 213 |
+
|
| 214 |
+
if not wallet:
|
| 215 |
+
wallet = Wallet(account=account, balance=0, earn_balance=0, tip_balance=0, frozen_balance=0)
|
| 216 |
+
db.add(wallet)
|
| 217 |
+
|
| 218 |
+
wallet.balance = int(wallet.balance or 0) + int(amount)
|
| 219 |
+
|
| 220 |
+
last_tx = db.query(Transaction).filter(Transaction.account == account).order_by(Transaction.created_at.desc()).first()
|
| 221 |
+
prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
|
| 222 |
+
tx_hash = calculate_tx_hash(order_id, account, "RECHARGE", amount, prev_hash)
|
| 223 |
+
|
| 224 |
+
new_tx = Transaction(
|
| 225 |
+
tx_id=order_id, account=account, tx_type="RECHARGE", amount=amount,
|
| 226 |
+
prev_hash=prev_hash, tx_hash=tx_hash,
|
| 227 |
+
description="支付宝充值"
|
| 228 |
+
)
|
| 229 |
+
db.add(new_tx)
|
| 230 |
+
db.commit()
|
| 231 |
+
|
| 232 |
+
# 📝 P2优化:充值审计日志
|
| 233 |
+
logger.info(f"RECHARGE | account={account} | amount={amount} | order={order_id}")
|
| 234 |
+
except Exception as e:
|
| 235 |
+
db.rollback()
|
| 236 |
+
logger.error(f"RECHARGE_ERROR | account={account} | amount={amount} | order={order_id} | error={str(e)}")
|
| 237 |
+
return Response(content="fail", media_type="text/plain")
|
| 238 |
|
| 239 |
return Response(content="success", media_type="text/plain")
|
| 240 |
|
| 241 |
@router.get("/api/wallet/check_order/{order_id}")
|
| 242 |
+
async def check_order(order_id: str, current_user: str = Depends(require_auth), db: Session = Depends(get_db)):
|
| 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 |
+
# 验证认证用户与订单所属用户一致
|
| 251 |
+
if current_user != account:
|
| 252 |
+
logger.warning(f"CHECK_ORDER_ACCOUNT_MISMATCH | order={order_id} | auth={current_user} | order_account={account}")
|
| 253 |
+
return {"status": "PENDING"}
|
| 254 |
# 防线 1:先查本地数据库(如果 Webhook 成功了,这里就能查到)
|
| 255 |
tx = db.query(Transaction).filter(Transaction.tx_id == order_id).first()
|
| 256 |
if tx:
|
| 257 |
return {"status": "SUCCESS"}
|
| 258 |
|
| 259 |
# 防线 2:主动向支付宝发起查单(解决 Hugging Face 收不到回调的绝杀技)
|
| 260 |
+
if alipay:
|
| 261 |
try:
|
| 262 |
# 拿着订单号去问支付宝:这笔钱到底付了没?
|
| 263 |
result = alipay.api_alipay_trade_query(out_trade_no=order_id)
|
|
|
|
| 680 |
if req.amount < 1:
|
| 681 |
raise HTTPException(status_code=400, detail="最小提现金额为1积分")
|
| 682 |
|
| 683 |
+
# 🔒 验证码缓存键格式:{contact}:{action_type},与 send_code 接口一致
|
| 684 |
# 先通过账号查询用户邮箱或手机号,再用其构建缓存键(不 fallback 到 account)
|
| 685 |
users_db = json_db.load_data("users.json", default_data={})
|
| 686 |
user_info = users_db.get(req.account, {})
|
| 687 |
user_email = user_info.get("email") or user_info.get("phone", "")
|
| 688 |
if not user_email:
|
| 689 |
raise HTTPException(status_code=400, detail="请先绑定邮箱或手机号")
|
| 690 |
+
key = f"{user_email}:withdraw"
|
| 691 |
code_data = VERIFY_CODES.get(key)
|
| 692 |
# 🔒 P0安全修复:统一使用 expires_at 字段,兼容旧版 expires
|
| 693 |
expire_time = code_data.get("expires_at", code_data.get("expires", 0)) if code_data else 0
|
|
|
|
| 1243 |
}
|
| 1244 |
|
| 1245 |
class CompleteWithdrawRequest(BaseModel):
|
| 1246 |
+
payment_order_id: str = Field(..., min_length=1, max_length=64, description="支付宝/银行打款流水号")
|
| 1247 |
|
| 1248 |
@router.post("/api/admin/withdrawals/{tx_id}/complete")
|
| 1249 |
async def complete_withdrawal(
|
数据库连接.py
CHANGED
|
@@ -71,7 +71,7 @@ os.makedirs(BACKUP_DIR, exist_ok=True)
|
|
| 71 |
CACHE_TTL = {
|
| 72 |
"items.json": 120, # 列表数据 2 分钟
|
| 73 |
"tasks.json": 60, # 任务数据 1 分钟
|
| 74 |
-
"users.json":
|
| 75 |
"posts.json": 120, # 帖子数据 2 分钟
|
| 76 |
"comments.json": 60, # 评论数据 1 分钟
|
| 77 |
"disputes.json": 60, # 申诉数据 1 分钟
|
|
|
|
| 71 |
CACHE_TTL = {
|
| 72 |
"items.json": 120, # 列表数据 2 分钟
|
| 73 |
"tasks.json": 60, # 任务数据 1 分钟
|
| 74 |
+
"users.json": 30, # 🔒 用户数据 30秒(含权限字段 is_admin,缩短 TTL 确保权限变更及时生效)
|
| 75 |
"posts.json": 120, # 帖子数据 2 分钟
|
| 76 |
"comments.json": 60, # 评论数据 1 分钟
|
| 77 |
"disputes.json": 60, # 申诉数据 1 分钟
|