ZHIWEI666 commited on
Commit
c60e0ef
·
verified ·
1 Parent(s): ba72d14

版本更新

Browse files
Files changed (12) hide show
  1. app.py +154 -2
  2. database_sql.py +43 -0
  3. image_moderation.py +457 -0
  4. models.py +24 -1
  5. models_sql.py +37 -4
  6. rate_limiter.py +164 -0
  7. router_items.py +42 -4
  8. router_posts.py +277 -187
  9. router_tasks.py +858 -1241
  10. router_users_auth.py +5 -3
  11. router_wallet.py +326 -19
  12. 数据库连接.py +18 -10
app.py CHANGED
@@ -48,6 +48,8 @@ from router_comments import router as comments_router # 💬 评论系统
48
  from router_messages import router as messages_router # ✉️ 私信系统
49
  from router_wallet import router as wallet_router # 💰 钱包/提现
50
  from router_proxy import router as proxy_router # 🔗 代理下载
 
 
51
 
52
  from database_sql import init_sql_db, get_db
53
  from models_sql import Ownership
@@ -203,6 +205,8 @@ app.include_router(comments_router) # 💬 评论系统
203
  app.include_router(messages_router) # ✉️ 私信系统
204
  app.include_router(wallet_router) # 💰 钱包/提现
205
  app.include_router(proxy_router) # 🔗 代理下载
 
 
206
 
207
  # ==========================================
208
  # 🟢 私有图床代理中心 (Image Proxy)
@@ -217,7 +221,7 @@ def proxy_hf_image(url: str = None, path: str = None):
217
  try:
218
  path = url.split("resolve/main/")[-1]
219
  path = urllib.parse.unquote(path)
220
- except:
221
  raise HTTPException(status_code=400, detail="无效的 HF 原链接格式")
222
 
223
  if not path:
@@ -247,7 +251,10 @@ def proxy_hf_image(url: str = None, path: str = None):
247
 
248
  # ==========================================
249
  # 上传接口 (将返回的 URL 替换为 Proxy 代理链接)
 
250
  # ==========================================
 
 
251
  @app.post("/api/upload")
252
  def upload_file(file: UploadFile = File(...), file_type: str = Form(...)):
253
  content = file.file.read()
@@ -265,6 +272,15 @@ def upload_file(file: UploadFile = File(...), file_type: str = Form(...)):
265
  ext = file.filename.split(".")[-1].lower()
266
  if ext not in ["jpg", "jpeg", "png", "gif", "webp", "json", "mp4"]:
267
  raise HTTPException(status_code=400, detail="不支持的文件格式")
 
 
 
 
 
 
 
 
 
268
 
269
  file_hash = hashlib.md5(content).hexdigest()[:10]
270
  safe_filename = f"{file_type}_{file_hash}.{ext}"
@@ -365,4 +381,140 @@ def validate_resource(req_data: ValidateResourceRequest, sql_db: Session = Depen
365
  except Exception as e:
366
  return JSONResponse(content={"error": f"资源探测异常: {str(e)}"}, status_code=500)
367
 
368
- return {"status": "success"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  from router_messages import router as messages_router # ✉️ 私信系统
49
  from router_wallet import router as wallet_router # 💰 钱包/提现
50
  from router_proxy import router as proxy_router # 🔗 代理下载
51
+ from router_posts import router as posts_router # 💬 讨论区
52
+ from router_tasks import router as tasks_router # 📝 任务榜
53
 
54
  from database_sql import init_sql_db, get_db
55
  from models_sql import Ownership
 
205
  app.include_router(messages_router) # ✉️ 私信系统
206
  app.include_router(wallet_router) # 💰 钱包/提现
207
  app.include_router(proxy_router) # 🔗 代理下载
208
+ app.include_router(posts_router) # 💬 讨论区
209
+ app.include_router(tasks_router) # 📝 任务榜
210
 
211
  # ==========================================
212
  # 🟢 私有图床代理中心 (Image Proxy)
 
221
  try:
222
  path = url.split("resolve/main/")[-1]
223
  path = urllib.parse.unquote(path)
224
+ except Exception:
225
  raise HTTPException(status_code=400, detail="无效的 HF 原链接格式")
226
 
227
  if not path:
 
251
 
252
  # ==========================================
253
  # 上传接口 (将返回的 URL 替换为 Proxy 代理链接)
254
+ # 🔒 P0安全优化:集成图片内容审核
255
  # ==========================================
256
+ from image_moderation import moderate_image_sync, ModerationResult
257
+
258
  @app.post("/api/upload")
259
  def upload_file(file: UploadFile = File(...), file_type: str = Form(...)):
260
  content = file.file.read()
 
272
  ext = file.filename.split(".")[-1].lower()
273
  if ext not in ["jpg", "jpeg", "png", "gif", "webp", "json", "mp4"]:
274
  raise HTTPException(status_code=400, detail="不支持的文件格式")
275
+
276
+ # 🔒 P0安全优化:图片内容审核
277
+ if ext in ["jpg", "jpeg", "png", "gif", "webp"]:
278
+ moderation_result = moderate_image_sync(content, ext)
279
+ if not moderation_result.passed:
280
+ raise HTTPException(
281
+ status_code=400,
282
+ detail=f"图片内容审核未通过:{moderation_result.label or '内容不合规'}"
283
+ )
284
 
285
  file_hash = hashlib.md5(content).hexdigest()[:10]
286
  safe_filename = f"{file_type}_{file_hash}.{ext}"
 
381
  except Exception as e:
382
  return JSONResponse(content={"error": f"资源探测异常: {str(e)}"}, status_code=500)
383
 
384
+ return {"status": "success"}
385
+
386
+
387
+ # ==========================================
388
+ # 🔒 管理员:系统配置 API
389
+ # ==========================================
390
+ from 安全认证 import require_auth
391
+
392
+ # 🔒 管理员账号列表(从环境变量读取)
393
+ ADMIN_ACCOUNTS = set(
394
+ acc.strip()
395
+ for acc in os.environ.get("ADMIN_ACCOUNTS", "").split(",")
396
+ if acc.strip()
397
+ )
398
+
399
+ def _is_admin(account: str) -> bool:
400
+ """检查账号是否为管理员"""
401
+ return account in ADMIN_ACCOUNTS
402
+
403
+ # 配置存储文件
404
+ SYSTEM_CONFIG_FILE = "/tmp/system_config.json"
405
+
406
+ def _load_system_config() -> dict:
407
+ """ 加载系统配置 """
408
+ try:
409
+ if os.path.exists(SYSTEM_CONFIG_FILE):
410
+ with open(SYSTEM_CONFIG_FILE, "r") as f:
411
+ return json.load(f)
412
+ except Exception as e:
413
+ logger.debug(f"加载系统配置失败: {e}")
414
+ return {}
415
+
416
+ def _save_system_config(config: dict):
417
+ """ 保存系统配置 """
418
+ try:
419
+ with open(SYSTEM_CONFIG_FILE, "w") as f:
420
+ json.dump(config, f)
421
+ except Exception as e:
422
+ logger.warning(f"保存系统配置失败: {e}")
423
+
424
+
425
+ @app.get("/api/admin/config/{config_key}")
426
+ async def get_system_config(config_key: str, current_user: str = Depends(require_auth)):
427
+ """ 🔒 获取系统配置(仅管理员) """
428
+ # 🔒 权限验证
429
+ if not _is_admin(current_user):
430
+ raise HTTPException(status_code=403, detail="无权访问系统配置,仅管理员可操作")
431
+
432
+ config = _load_system_config()
433
+
434
+ if config_key == "image_moderation":
435
+ # 图像审核配置:包含开关状态和额度信息
436
+ try:
437
+ from image_moderation import _load_quota, ALIYUN_FREE_QUOTA, TENCENT_FREE_QUOTA
438
+ quota = _load_quota()
439
+ return {
440
+ "status": "success",
441
+ "data": {
442
+ "enabled": config.get("image_moderation_enabled", False),
443
+ "quota": {
444
+ "aliyun": quota.get("aliyun", 0),
445
+ "tencent": quota.get("tencent", 0),
446
+ "aliyun_limit": ALIYUN_FREE_QUOTA,
447
+ "tencent_limit": TENCENT_FREE_QUOTA
448
+ }
449
+ }
450
+ }
451
+ except Exception as e:
452
+ logger.error(f"获取审核配置失败: {e}")
453
+ return {
454
+ "status": "success",
455
+ "data": {
456
+ "enabled": config.get("image_moderation_enabled", False),
457
+ "quota": {}
458
+ }
459
+ }
460
+
461
+ if config_key == "project_version":
462
+ # 🏷️ 项目版本配置
463
+ version_config = config.get("project_version", DEFAULT_VERSION_CONFIG)
464
+ return {
465
+ "status": "success",
466
+ "data": version_config
467
+ }
468
+
469
+ return {"status": "success", "data": config.get(config_key)}
470
+
471
+
472
+ # 🏷️ 版本配置默认值
473
+ DEFAULT_VERSION_CONFIG = {
474
+ "stage": "alpha",
475
+ "major": 1,
476
+ "minor": 0,
477
+ "patch": 0
478
+ }
479
+
480
+
481
+ @app.put("/api/admin/config/{config_key}")
482
+ async def set_system_config(config_key: str, value: dict, current_user: str = Depends(require_auth)):
483
+ """ 🔒 设置系统配置(仅管理员) """
484
+ # 🔒 权限验证
485
+ if not _is_admin(current_user):
486
+ raise HTTPException(status_code=403, detail="无权修改系统配置,仅管理员可操作")
487
+
488
+ config = _load_system_config()
489
+
490
+ if config_key == "image_moderation":
491
+ # 更新图像审核开关
492
+ enabled = value.get("enabled", False)
493
+ config["image_moderation_enabled"] = enabled
494
+ _save_system_config(config)
495
+
496
+ logger.info(f"🔒 管理员设置图像审核: {'enabled' if enabled else 'disabled'}")
497
+ return {"status": "success", "message": f"图像审核已{'enabled' if enabled else 'disabled'}"}
498
+
499
+ if config_key == "project_version":
500
+ # 🏷️ 更新项目版本配置
501
+ version_config = {
502
+ "stage": value.get("stage", "alpha"),
503
+ "major": int(value.get("major", 1)),
504
+ "minor": int(value.get("minor", 0)),
505
+ "patch": int(value.get("patch", 0))
506
+ }
507
+ config["project_version"] = version_config
508
+ _save_system_config(config)
509
+
510
+ version_str = f"V{version_config['major']}.{version_config['minor']}.{version_config['patch']}"
511
+ stage_labels = {"alpha": "内测", "beta": "公测", "rc": "候选版", "stable": "正式版"}
512
+ stage_label = stage_labels.get(version_config["stage"], version_config["stage"])
513
+
514
+ logger.info(f"🏷️ 管理员更新项目版本: {version_str} {stage_label}")
515
+ return {"status": "success", "message": f"版本已更新为 {version_str} {stage_label}"}
516
+
517
+ # 其他配置项
518
+ config[config_key] = value
519
+ _save_system_config(config)
520
+ return {"status": "success", "message": "配置已更新"}
database_sql.py CHANGED
@@ -78,6 +78,10 @@ def init_sql_db():
78
  for attempt in range(3):
79
  try:
80
  Base.metadata.create_all(bind=engine)
 
 
 
 
81
  logger.info("数据库初始化成功")
82
  return
83
  except Exception as e:
@@ -89,6 +93,45 @@ def init_sql_db():
89
  raise
90
 
91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  def get_db():
93
  """获取数据库会话,带连接有效性检测"""
94
  db = SessionLocal()
 
78
  for attempt in range(3):
79
  try:
80
  Base.metadata.create_all(bind=engine)
81
+
82
+ # 🔄 P7后悔模式:自动迁移新增字段
83
+ _auto_migrate_p7_fields()
84
+
85
  logger.info("数据库初始化成功")
86
  return
87
  except Exception as e:
 
93
  raise
94
 
95
 
96
+ def _auto_migrate_p7_fields():
97
+ """
98
+ 🔄 P7后悔模式:自动迁移新增字段
99
+ 检查并添加 ownerships 表的新字段
100
+ """
101
+ from sqlalchemy import inspect
102
+
103
+ try:
104
+ inspector = inspect(engine)
105
+
106
+ # 检查 ownerships 表是否存在
107
+ if 'ownerships' in inspector.get_table_names():
108
+ columns = [col['name'] for col in inspector.get_columns('ownerships')]
109
+
110
+ # 添加 P7 新增字段
111
+ with engine.connect() as conn:
112
+ if 'price_paid' not in columns:
113
+ if 'sqlite' in SQLALCHEMY_DATABASE_URL:
114
+ conn.execute(text("ALTER TABLE ownerships ADD COLUMN price_paid INTEGER DEFAULT 0"))
115
+ else:
116
+ conn.execute(text("ALTER TABLE ownerships ADD COLUMN price_paid INTEGER DEFAULT 0"))
117
+ logger.info("迁移完成: 添加 ownerships.price_paid 字段")
118
+
119
+ if 'is_refunded' not in columns:
120
+ if 'sqlite' in SQLALCHEMY_DATABASE_URL:
121
+ conn.execute(text("ALTER TABLE ownerships ADD COLUMN is_refunded BOOLEAN DEFAULT 0"))
122
+ else:
123
+ conn.execute(text("ALTER TABLE ownerships ADD COLUMN is_refunded BOOLEAN DEFAULT FALSE"))
124
+ logger.info("迁移完成: 添加 ownerships.is_refunded 字段")
125
+
126
+ if 'refunded_at' not in columns:
127
+ conn.execute(text("ALTER TABLE ownerships ADD COLUMN refunded_at TIMESTAMP"))
128
+ logger.info("迁移完成: 添加 ownerships.refunded_at 字段")
129
+
130
+ conn.commit()
131
+ except Exception as e:
132
+ logger.warning(f"P7字段迁移跳过 (可能已存在): {e}")
133
+
134
+
135
  def get_db():
136
  """获取数据库会话,带连接有效性检测"""
137
  db = SessionLocal()
image_moderation.py ADDED
@@ -0,0 +1,457 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # image_moderation.py
2
+ # ==========================================
3
+ # 🔒 P0安全优化:图片内容审核模块
4
+ # ==========================================
5
+ # 支持腾讯云/阿里云图片审核API
6
+ # 环境变量配置:
7
+ # - IMAGE_MODERATION_PROVIDER: "tencent" 或 "aliyun" (默认: tencent)
8
+ # - TENCENT_SECRET_ID / TENCENT_SECRET_KEY
9
+ # - ALIYUN_ACCESS_KEY_ID / ALIYUN_ACCESS_KEY_SECRET
10
+ # ==========================================
11
+
12
+ import os
13
+ import base64
14
+ import json
15
+ import logging
16
+ import hmac
17
+ import hashlib
18
+ import time
19
+ import uuid
20
+ from datetime import datetime
21
+ from typing import Optional, Tuple
22
+ import httpx
23
+
24
+ logger = logging.getLogger("ComfyUI-Ranking.ImageModeration")
25
+
26
+ # ==========================================
27
+ # 🔧 配置
28
+ # ==========================================
29
+
30
+ MODERATION_ENABLED = os.environ.get("IMAGE_MODERATION_ENABLED", "false").lower() == "true"
31
+
32
+ # 腾讯云配置
33
+ TENCENT_SECRET_ID = os.environ.get("TENCENT_SECRET_ID", "")
34
+ TENCENT_SECRET_KEY = os.environ.get("TENCENT_SECRET_KEY", "")
35
+ TENCENT_REGION = os.environ.get("TENCENT_REGION", "ap-guangzhou")
36
+
37
+ # 阿里云配置
38
+ ALIYUN_ACCESS_KEY_ID = os.environ.get("ALIYUN_ACCESS_KEY_ID", "")
39
+ ALIYUN_ACCESS_KEY_SECRET = os.environ.get("ALIYUN_ACCESS_KEY_SECRET", "")
40
+ ALIYUN_REGION = os.environ.get("ALIYUN_REGION", "cn-shanghai")
41
+
42
+ # 💰 免费额度管理(每月重置)
43
+ ALIYUN_FREE_QUOTA = 3000 # 阿里云每月免费 3000 次
44
+ TENCENT_FREE_QUOTA = 10000 # 腾讯云每月免费 10000 次
45
+
46
+ # 额度记录文件
47
+ QUOTA_FILE = "/tmp/image_moderation_quota.json"
48
+
49
+ def _load_quota() -> dict:
50
+ """加载当月额度使用记录"""
51
+ current_month = datetime.now().strftime("%Y-%m")
52
+ try:
53
+ if os.path.exists(QUOTA_FILE):
54
+ with open(QUOTA_FILE, "r") as f:
55
+ data = json.load(f)
56
+ # 检查是否是当月记录,否则重置
57
+ if data.get("month") != current_month:
58
+ return {"month": current_month, "aliyun": 0, "tencent": 0}
59
+ return data
60
+ except Exception as e:
61
+ logger.debug(f"加载额度记录失败: {e}")
62
+ return {"month": current_month, "aliyun": 0, "tencent": 0}
63
+
64
+ def _save_quota(quota: dict):
65
+ """保存额度使用记录"""
66
+ try:
67
+ with open(QUOTA_FILE, "w") as f:
68
+ json.dump(quota, f)
69
+ except Exception as e:
70
+ logger.warning(f"保存额度记录失败: {e}")
71
+
72
+ def _increment_quota(provider: str):
73
+ """增加使用次数"""
74
+ quota = _load_quota()
75
+ quota[provider] = quota.get(provider, 0) + 1
76
+ _save_quota(quota)
77
+ logger.info(f"审核额度: {provider}={quota[provider]}, 阿里云剩余{ALIYUN_FREE_QUOTA - quota.get('aliyun', 0)}, 腾讯云剩余{TENCENT_FREE_QUOTA - quota.get('tencent', 0)}")
78
+
79
+ def _get_available_provider() -> str:
80
+ """
81
+ 获取可用的审核服务商
82
+ 策略:阿里云优先 → 腾讯云兆底 → 都用完则跳过
83
+ """
84
+ quota = _load_quota()
85
+
86
+ # 1️⃣ 优先阿里云
87
+ if quota.get("aliyun", 0) < ALIYUN_FREE_QUOTA and ALIYUN_ACCESS_KEY_ID:
88
+ return "aliyun"
89
+
90
+ # 2️⃣ 阿里云用完,切换腾讯云
91
+ if quota.get("tencent", 0) < TENCENT_FREE_QUOTA and TENCENT_SECRET_ID:
92
+ return "tencent"
93
+
94
+ # 3️⃣ 两家都用完,返回 None
95
+ return None
96
+
97
+ # ==========================================
98
+ # 📊 审核结果类型
99
+ # ==========================================
100
+
101
+ class ModerationResult:
102
+ """图片审核结果"""
103
+ def __init__(self, passed: bool, label: str = "", confidence: float = 0.0,
104
+ suggestion: str = "pass", details: dict = None):
105
+ self.passed = passed # 是否通过
106
+ self.label = label # 违规标签(如 Porn, Terror, Ad)
107
+ self.confidence = confidence # 置信度 0-100
108
+ self.suggestion = suggestion # pass/review/block
109
+ self.details = details or {} # 详细信息
110
+
111
+ def to_dict(self):
112
+ return {
113
+ "passed": self.passed,
114
+ "label": self.label,
115
+ "confidence": self.confidence,
116
+ "suggestion": self.suggestion,
117
+ "details": self.details
118
+ }
119
+
120
+ # ==========================================
121
+ # 🔵 腾讯云图片审核
122
+ # ==========================================
123
+
124
+ def _tencent_sign_v3(secret_id: str, secret_key: str, host: str,
125
+ payload: str, timestamp: int) -> dict:
126
+ """腾讯云 TC3-HMAC-SHA256 签名"""
127
+ service = "ims"
128
+ date = datetime.utcfromtimestamp(timestamp).strftime("%Y-%m-%d")
129
+
130
+ # 规范请求
131
+ http_request_method = "POST"
132
+ canonical_uri = "/"
133
+ canonical_querystring = ""
134
+ ct = "application/json; charset=utf-8"
135
+ canonical_headers = f"content-type:{ct}\nhost:{host}\nx-tc-action:imagemoderationsync\n"
136
+ signed_headers = "content-type;host;x-tc-action"
137
+ hashed_request_payload = hashlib.sha256(payload.encode("utf-8")).hexdigest()
138
+ canonical_request = (f"{http_request_method}\n{canonical_uri}\n{canonical_querystring}\n"
139
+ f"{canonical_headers}\n{signed_headers}\n{hashed_request_payload}")
140
+
141
+ # 待签名字符串
142
+ algorithm = "TC3-HMAC-SHA256"
143
+ credential_scope = f"{date}/{service}/tc3_request"
144
+ hashed_canonical_request = hashlib.sha256(canonical_request.encode("utf-8")).hexdigest()
145
+ string_to_sign = f"{algorithm}\n{timestamp}\n{credential_scope}\n{hashed_canonical_request}"
146
+
147
+ # 计算签名
148
+ def sign(key, msg):
149
+ return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
150
+
151
+ secret_date = sign(("TC3" + secret_key).encode("utf-8"), date)
152
+ secret_service = sign(secret_date, service)
153
+ secret_signing = sign(secret_service, "tc3_request")
154
+ signature = hmac.new(secret_signing, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
155
+
156
+ # 构建 Authorization
157
+ authorization = (f"{algorithm} Credential={secret_id}/{credential_scope}, "
158
+ f"SignedHeaders={signed_headers}, Signature={signature}")
159
+
160
+ return {
161
+ "Authorization": authorization,
162
+ "Content-Type": ct,
163
+ "Host": host,
164
+ "X-TC-Action": "ImageModerationSync",
165
+ "X-TC-Timestamp": str(timestamp),
166
+ "X-TC-Version": "2020-07-13",
167
+ "X-TC-Region": TENCENT_REGION
168
+ }
169
+
170
+ async def moderate_image_tencent(image_content: bytes) -> ModerationResult:
171
+ """
172
+ 腾讯云图片审核
173
+ API文档: https://cloud.tencent.com/document/product/1125/53273
174
+ """
175
+ if not TENCENT_SECRET_ID or not TENCENT_SECRET_KEY:
176
+ logger.warning("腾讯云审核未配置,跳过审核")
177
+ return ModerationResult(passed=True, suggestion="pass")
178
+
179
+ host = "ims.tencentcloudapi.com"
180
+ timestamp = int(time.time())
181
+
182
+ # Base64 编码图片
183
+ file_content_base64 = base64.b64encode(image_content).decode("utf-8")
184
+
185
+ payload = json.dumps({
186
+ "BizType": "default", # 使用默认策略
187
+ "FileContent": file_content_base64,
188
+ "DataId": str(uuid.uuid4())
189
+ })
190
+
191
+ headers = _tencent_sign_v3(TENCENT_SECRET_ID, TENCENT_SECRET_KEY, host, payload, timestamp)
192
+
193
+ try:
194
+ async with httpx.AsyncClient(timeout=30.0) as client:
195
+ resp = await client.post(f"https://{host}", content=payload, headers=headers)
196
+ result = resp.json()
197
+
198
+ response = result.get("Response", {})
199
+
200
+ if "Error" in response:
201
+ logger.error(f"腾讯云审核API错误: {response['Error']}")
202
+ return ModerationResult(passed=True, suggestion="pass",
203
+ details={"error": response["Error"]})
204
+
205
+ # 解析审核结果
206
+ suggestion = response.get("Suggestion", "Pass").lower()
207
+ label = response.get("Label", "")
208
+ score = response.get("Score", 0)
209
+
210
+ passed = suggestion == "pass"
211
+
212
+ logger.info(f"腾讯云审核结果: suggestion={suggestion}, label={label}, score={score}")
213
+
214
+ return ModerationResult(
215
+ passed=passed,
216
+ label=label,
217
+ confidence=score,
218
+ suggestion=suggestion,
219
+ details=response
220
+ )
221
+
222
+ except Exception as e:
223
+ logger.error(f"腾讯云审核异常: {str(e)}")
224
+ # 审核服务异常时默认放行,避免影响正常业务
225
+ return ModerationResult(passed=True, suggestion="pass",
226
+ details={"error": str(e)})
227
+
228
+ # ==========================================
229
+ # 🟠 阿里云图片审核
230
+ # ==========================================
231
+
232
+ def _aliyun_sign(access_key_secret: str, string_to_sign: str) -> str:
233
+ """阿里云 ROA 签名"""
234
+ signature = hmac.new(
235
+ (access_key_secret + "&").encode("utf-8"),
236
+ string_to_sign.encode("utf-8"),
237
+ hashlib.sha1
238
+ ).digest()
239
+ return base64.b64encode(signature).decode("utf-8")
240
+
241
+ def _build_aliyun_signature(method: str, headers: dict, uri: str, access_key_secret: str) -> str:
242
+ """
243
+ 构建阿里云 ROA 风格签名
244
+ 参考: https://help.aliyun.com/document_detail/315526.html
245
+ """
246
+ # 1. 构建 CanonicalizedHeaders
247
+ acs_headers = {}
248
+ for key, value in headers.items():
249
+ lower_key = key.lower()
250
+ if lower_key.startswith("x-acs-"):
251
+ acs_headers[lower_key] = value
252
+
253
+ sorted_headers = sorted(acs_headers.items())
254
+ canonical_headers = "\n".join([f"{k}:{v}" for k, v in sorted_headers])
255
+
256
+ # 2. 构建 StringToSign
257
+ content_type = headers.get("Content-Type", "")
258
+ accept = headers.get("Accept", "")
259
+
260
+ string_to_sign = f"{method}\n{accept}\n\n{content_type}\n\n{canonical_headers}\n{uri}"
261
+
262
+ # 3. 计算签名
263
+ return _aliyun_sign(access_key_secret, string_to_sign)
264
+
265
+ async def moderate_image_aliyun(image_content: bytes) -> ModerationResult:
266
+ """
267
+ 阿里云图片审核
268
+ API文档: https://help.aliyun.com/document_detail/70292.html
269
+ """
270
+ if not ALIYUN_ACCESS_KEY_ID or not ALIYUN_ACCESS_KEY_SECRET:
271
+ logger.warning("阿里云审核未配置,跳过审核")
272
+ return ModerationResult(passed=True, suggestion="pass")
273
+
274
+ # Base64 编码图片
275
+ file_content_base64 = base64.b64encode(image_content).decode("utf-8")
276
+
277
+ # 使用阿里云内容安全 API (绿网)
278
+ endpoint = f"https://green.{ALIYUN_REGION}.aliyuncs.com"
279
+ uri = "/green/image/scan"
280
+
281
+ # 构建请求体
282
+ payload = {
283
+ "scenes": ["porn", "terrorism", "ad"], # 鉴黄、暴恐、广告
284
+ "tasks": [{
285
+ "dataId": str(uuid.uuid4()),
286
+ "content": file_content_base64,
287
+ "type": "BASE64"
288
+ }]
289
+ }
290
+ payload_str = json.dumps(payload)
291
+
292
+ # 签名参数
293
+ timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
294
+ nonce = str(uuid.uuid4())
295
+
296
+ # 🔒 正确构建头部(包含签名所需字段)
297
+ headers = {
298
+ "Accept": "application/json",
299
+ "Content-Type": "application/json",
300
+ "x-acs-version": "2018-05-09",
301
+ "x-acs-signature-method": "HMAC-SHA1",
302
+ "x-acs-signature-version": "1.0",
303
+ "x-acs-signature-nonce": nonce,
304
+ "Date": timestamp,
305
+ }
306
+
307
+ # 🔒 计算签名
308
+ signature = _build_aliyun_signature("POST", headers, uri, ALIYUN_ACCESS_KEY_SECRET)
309
+
310
+ # 添加 Authorization 头部
311
+ headers["Authorization"] = f"acs {ALIYUN_ACCESS_KEY_ID}:{signature}"
312
+
313
+ try:
314
+ async with httpx.AsyncClient(timeout=30.0) as client:
315
+ resp = await client.post(
316
+ f"{endpoint}{uri}",
317
+ content=payload_str,
318
+ headers=headers
319
+ )
320
+ result = resp.json()
321
+
322
+ # 解析审核结果
323
+ if result.get("code") != 200:
324
+ logger.error(f"阿里云审核API错误: {result}")
325
+ return ModerationResult(passed=True, suggestion="pass",
326
+ details={"error": result})
327
+
328
+ data = result.get("data", [])
329
+ if not data:
330
+ return ModerationResult(passed=True, suggestion="pass")
331
+
332
+ task_result = data[0].get("results", [])
333
+
334
+ # 检查所有场景的审核结果
335
+ max_rate = 0
336
+ block_label = ""
337
+ overall_suggestion = "pass"
338
+
339
+ for scene_result in task_result:
340
+ scene = scene_result.get("scene", "")
341
+ suggestion = scene_result.get("suggestion", "pass")
342
+ rate = scene_result.get("rate", 0)
343
+ label = scene_result.get("label", "")
344
+
345
+ if suggestion == "block":
346
+ overall_suggestion = "block"
347
+ if rate > max_rate:
348
+ max_rate = rate
349
+ block_label = f"{scene}:{label}"
350
+ elif suggestion == "review" and overall_suggestion != "block":
351
+ overall_suggestion = "review"
352
+ if rate > max_rate:
353
+ max_rate = rate
354
+ block_label = f"{scene}:{label}"
355
+
356
+ passed = overall_suggestion == "pass"
357
+
358
+ logger.info(f"阿里云审核结果: suggestion={overall_suggestion}, label={block_label}, rate={max_rate}")
359
+
360
+ return ModerationResult(
361
+ passed=passed,
362
+ label=block_label,
363
+ confidence=max_rate * 100,
364
+ suggestion=overall_suggestion,
365
+ details=result
366
+ )
367
+
368
+ except Exception as e:
369
+ logger.error(f"阿里云审核异常: {str(e)}")
370
+ # 审核服务异常时默认放行
371
+ return ModerationResult(passed=True, suggestion="pass",
372
+ details={"error": str(e)})
373
+
374
+ # ==========================================
375
+ # 🎯 统一审核入口(智能额度管理)
376
+ # ==========================================
377
+
378
+ # 🔒 动态配置检查
379
+ SYSTEM_CONFIG_FILE = "/tmp/system_config.json"
380
+
381
+ def _is_moderation_enabled() -> bool:
382
+ """
383
+ 检查图片审核是否启用
384
+ 优先检查系统配置文件,其次检查环境变量
385
+ """
386
+ # 1️⃣ 优先检查系统配置文件(管理员动态设置)
387
+ try:
388
+ if os.path.exists(SYSTEM_CONFIG_FILE):
389
+ with open(SYSTEM_CONFIG_FILE, "r") as f:
390
+ config = json.load(f)
391
+ if "image_moderation_enabled" in config:
392
+ return config["image_moderation_enabled"] == True
393
+ except Exception as e:
394
+ logger.debug(f"读取系统配置失败: {e}")
395
+
396
+ # 2️⃣ 回退到环境变量
397
+ return MODERATION_ENABLED
398
+
399
+
400
+ async def moderate_image(image_content: bytes, file_ext: str = "") -> ModerationResult:
401
+ """
402
+ 统一图片审核入口
403
+
404
+ 策略:阿里云优先(3000次/月) → 腾讯云兆底(10000次/月) → 都用完则跳过
405
+
406
+ Args:
407
+ image_content: 图片二进制内容
408
+ file_ext: 文件扩展名(用于判断是否需要审核)
409
+
410
+ Returns:
411
+ ModerationResult: 审核结果
412
+ """
413
+ # 跳过非图片文件
414
+ if file_ext.lower() in ["json", "mp4"]:
415
+ return ModerationResult(passed=True, suggestion="pass",
416
+ details={"skipped": True, "reason": "非图片文件"})
417
+
418
+ # 检查是否启用审核(支持动态配置)
419
+ if not _is_moderation_enabled():
420
+ logger.debug("图片审核未启用")
421
+ return ModerationResult(passed=True, suggestion="pass",
422
+ details={"skipped": True, "reason": "审核未启用"})
423
+
424
+ # 💰 智能选择服务商(基于免费额度)
425
+ provider = _get_available_provider()
426
+
427
+ if provider is None:
428
+ logger.warning("本月免费审核额度已用完,跳过审核")
429
+ return ModerationResult(passed=True, suggestion="pass",
430
+ details={"skipped": True, "reason": "免费额度已用完"})
431
+
432
+ # 执行审核
433
+ if provider == "aliyun":
434
+ result = await moderate_image_aliyun(image_content)
435
+ else:
436
+ result = await moderate_image_tencent(image_content)
437
+
438
+ # 记录使用次数(只有审核成功才计数)
439
+ if "error" not in result.details:
440
+ _increment_quota(provider)
441
+
442
+ result.details["provider"] = provider
443
+ return result
444
+
445
+ def moderate_image_sync(image_content: bytes, file_ext: str = "") -> ModerationResult:
446
+ """
447
+ 同步版本的图片审核(用于非异步上下文)
448
+ """
449
+ import asyncio
450
+
451
+ try:
452
+ loop = asyncio.get_event_loop()
453
+ except RuntimeError:
454
+ loop = asyncio.new_event_loop()
455
+ asyncio.set_event_loop(loop)
456
+
457
+ return loop.run_until_complete(moderate_image(image_content, file_ext))
models.py CHANGED
@@ -63,6 +63,8 @@ class ItemCreate(BaseModel):
63
  author: str
64
  price: int = 0
65
  github_token: Optional[str] = None
 
 
66
 
67
  class ItemUpdate(BaseModel):
68
  title: Optional[str] = None
@@ -73,6 +75,8 @@ class ItemUpdate(BaseModel):
73
  imageUrls: Optional[List[str]] = []
74
  price: Optional[int] = None
75
  github_token: Optional[str] = None
 
 
76
 
77
  class FollowToggle(BaseModel):
78
  user_id: str
@@ -220,4 +224,23 @@ class TaskDisputeResolve(BaseModel):
220
  admin_account: str # 管理员账号
221
  result: str # 仲裁结果: support_initiator / support_respondent / split
222
  split_ratio: Optional[int] = 50 # 分成比例(申诉方获得的百分比,仅当result=split时有效)
223
- admin_note: Optional[str] = None # 管理员备注
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  author: str
64
  price: int = 0
65
  github_token: Optional[str] = None
66
+ netdisk_password: Optional[str] = None # ☁️ 网盘提取码(加密存储,购买后解密)
67
+ is_netdisk: Optional[bool] = False # ☁️ 是否为网盘资源
68
 
69
  class ItemUpdate(BaseModel):
70
  title: Optional[str] = None
 
75
  imageUrls: Optional[List[str]] = []
76
  price: Optional[int] = None
77
  github_token: Optional[str] = None
78
+ netdisk_password: Optional[str] = None # ☁️ 网盘提取码
79
+ is_netdisk: Optional[bool] = None # ☁️ 是否为网盘资源
80
 
81
  class FollowToggle(BaseModel):
82
  user_id: str
 
224
  admin_account: str # 管理员账号
225
  result: str # 仲裁结果: support_initiator / support_respondent / split
226
  split_ratio: Optional[int] = 50 # 分成比例(申诉方获得的百分比,仅当result=split时有效)
227
+ admin_note: Optional[str] = None # 管理员备注
228
+
229
+ # ==========================================
230
+ # 💬 讨论区数据模型(小红书风格图文社区)
231
+ # ==========================================
232
+
233
+ class PostCreate(BaseModel):
234
+ """ 创建帖子 """
235
+ title: str # 标题
236
+ content: str # 正文/文案
237
+ cover_image: str # 封面图(第一张)
238
+ images: Optional[List[str]] = [] # 图片列表(最多9张)
239
+ author: str # 作者账号
240
+
241
+ class PostUpdate(BaseModel):
242
+ """ 更新帖子 """
243
+ title: Optional[str] = None
244
+ content: Optional[str] = None
245
+ cover_image: Optional[str] = None
246
+ images: Optional[List[str]] = None
models_sql.py CHANGED
@@ -1,5 +1,11 @@
1
  # models_sql.py
2
- from sqlalchemy import Column, Integer, String, DateTime
 
 
 
 
 
 
3
  from sqlalchemy.orm import declarative_base
4
  import datetime
5
 
@@ -26,6 +32,27 @@ class Ownership(Base):
26
  account = Column(String, index=True)
27
  item_id = Column(String, index=True)
28
  purchased_at = Column(DateTime, default=datetime.datetime.utcnow)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
  class Transaction(Base):
31
  """交易流水表"""
@@ -33,10 +60,16 @@ class Transaction(Base):
33
 
34
  tx_id = Column(String, primary_key=True)
35
  account = Column(String, index=True)
36
- tx_type = Column(String) # 枚举: RECHARGE, PURCHASE, EARN, TIP_SEND, TIP_RECEIVE, WITHDRAW_APPLY
37
  amount = Column(Integer)
38
  related_account = Column(String, nullable=True)
39
  item_id = Column(String, nullable=True)
40
- created_at = Column(DateTime, default=datetime.datetime.utcnow)
41
  prev_hash = Column(String) # 上一笔订单的哈希
42
- tx_hash = Column(String) # 本笔订单的哈希 (防篡改)
 
 
 
 
 
 
 
1
  # models_sql.py
2
+ # ==========================================
3
+ # 🗄️ SQL 数据库模型定义
4
+ # ==========================================
5
+ # 🚀 P1性能优化:添加复合索引加速查询
6
+ # ==========================================
7
+
8
+ from sqlalchemy import Column, Integer, String, DateTime, Boolean, Index, func
9
  from sqlalchemy.orm import declarative_base
10
  import datetime
11
 
 
32
  account = Column(String, index=True)
33
  item_id = Column(String, index=True)
34
  purchased_at = Column(DateTime, default=datetime.datetime.utcnow)
35
+ # 🔄 P7后悔模式增强
36
+ price_paid = Column(Integer, default=0) # 购买时支付的价格
37
+ is_refunded = Column(Boolean, default=False) # 是否已退款
38
+ refunded_at = Column(DateTime, nullable=True) # 退款时间
39
+
40
+ # 🚀 P1性能优化:复合索引
41
+ __table_args__ = (
42
+ Index('ix_ownerships_account_item', 'account', 'item_id'),
43
+ Index('ix_ownerships_account_refunded', 'account', 'is_refunded'),
44
+ )
45
+
46
+ class Refund(Base):
47
+ """🔄 P7后悔模式:退款记录表"""
48
+ __tablename__ = "refunds"
49
+
50
+ id = Column(Integer, primary_key=True, autoincrement=True)
51
+ account = Column(String, index=True) # 退款用户
52
+ item_id = Column(String, index=True) # 退款商品
53
+ amount = Column(Integer) # 退款金额
54
+ refunded_at = Column(DateTime, default=datetime.datetime.utcnow)
55
+ ban_until = Column(DateTime) # 禁止购买截止时间(30天后)
56
 
57
  class Transaction(Base):
58
  """交易流水表"""
 
60
 
61
  tx_id = Column(String, primary_key=True)
62
  account = Column(String, index=True)
63
+ tx_type = Column(String) # 枚举: RECHARGE, PURCHASE, EARN, TIP_SEND, TIP_RECEIVE, WITHDRAW_APPLY, REFUND
64
  amount = Column(Integer)
65
  related_account = Column(String, nullable=True)
66
  item_id = Column(String, nullable=True)
67
+ created_at = Column(DateTime, default=datetime.datetime.utcnow, index=True) # 🚀 P1: 添加时间索引
68
  prev_hash = Column(String) # 上一笔订单的哈希
69
+ tx_hash = Column(String) # 本笔订单的哈希 (防篡改)
70
+
71
+ # 🚀 P1性能优化:复合索引
72
+ __table_args__ = (
73
+ Index('ix_transactions_account_type', 'account', 'tx_type'),
74
+ Index('ix_transactions_account_created', 'account', 'created_at'),
75
+ )
rate_limiter.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # rate_limiter.py
2
+ # ==========================================
3
+ # 🔒 P0安全优化:API 限流配置
4
+ # ==========================================
5
+ # 作用:防止恶意刷接口、暴力破解、DDoS 攻击
6
+ # 关联文件:
7
+ # - app.py (全局限流器初始化)
8
+ # - 各 router_*.py (应用限流装饰器)
9
+ # ==========================================
10
+
11
+ from slowapi import Limiter
12
+ from slowapi.util import get_remote_address
13
+ from functools import wraps
14
+ import time
15
+ import logging
16
+
17
+ logger = logging.getLogger("ComfyUI-Ranking.RateLimiter")
18
+
19
+ # ==========================================
20
+ # 📊 限流配置
21
+ # ==========================================
22
+
23
+ # 全局限流器实例(在 app.py 中初始化)
24
+ limiter = Limiter(key_func=get_remote_address)
25
+
26
+ # 限流规则定义
27
+ RATE_LIMITS = {
28
+ # 🔐 认证相关(严格限制)
29
+ "login": "5/minute", # 登录:每分钟5次
30
+ "register": "3/minute", # 注册:每分钟3次
31
+ "reset_password": "3/minute", # 重置密码:每分钟3次
32
+ "send_code": "1/minute", # 发送验证码:每分钟1次
33
+
34
+ # 💰 金融操作(严格限制)
35
+ "purchase": "10/minute", # 购买:每分钟10次
36
+ "refund": "3/minute", # 退款:每分钟3次
37
+ "withdraw": "3/minute", # 提现:每分钟3次
38
+ "tip": "20/minute", # 打赏:每分钟20次
39
+
40
+ # 📝 内容操作(中等限制)
41
+ "create_item": "10/minute", # 发布内容:每分钟10次
42
+ "update_item": "30/minute", # 更新内容:每分钟30次
43
+ "create_task": "5/minute", # 创建任务:每分钟5次
44
+ "create_post": "10/minute", # 发帖:每分钟10次
45
+ "comment": "30/minute", # 评论:每分钟30次
46
+
47
+ # 🔄 互动操作(宽松限制)
48
+ "like": "60/minute", # 点赞:每分钟60次
49
+ "follow": "30/minute", # 关注:每分钟30次
50
+ "message": "30/minute", # 私信:每分钟30次
51
+
52
+ # 📖 查询操作(最宽松)
53
+ "read": "100/minute", # 读取:每分钟100次
54
+ "search": "30/minute", # 搜索:每分钟30次
55
+
56
+ # 📤 上传操作(严格限制)
57
+ "upload": "20/minute", # 上传:每分钟20次
58
+
59
+ # 🌐 默认限制
60
+ "default": "60/minute", # 默认:每分钟60次
61
+ }
62
+
63
+
64
+ # ==========================================
65
+ # 🔧 限流工具函数
66
+ # ==========================================
67
+
68
+ def get_limit(action: str) -> str:
69
+ """获取指定操作的限流规则"""
70
+ return RATE_LIMITS.get(action, RATE_LIMITS["default"])
71
+
72
+
73
+ # ==========================================
74
+ # 🛡️ 用户级限流(基于账号)
75
+ # ==========================================
76
+
77
+ # 用户请求计数器
78
+ user_request_counts = {}
79
+ USER_LIMIT_WINDOW = 60 # 时间窗口(秒)
80
+ USER_LIMIT_MAX = 100 # 每用户每分钟最大请求数
81
+
82
+
83
+ def check_user_rate_limit(account: str) -> bool:
84
+ """
85
+ 检查用户级限流
86
+ 返回 True 表示允许请求,False 表示超限
87
+ """
88
+ if not account:
89
+ return True
90
+
91
+ now = time.time()
92
+ key = f"user:{account}"
93
+
94
+ # 清理过期记录
95
+ if key in user_request_counts:
96
+ user_request_counts[key] = [
97
+ t for t in user_request_counts[key]
98
+ if now - t < USER_LIMIT_WINDOW
99
+ ]
100
+ else:
101
+ user_request_counts[key] = []
102
+
103
+ # 检查是否超限
104
+ if len(user_request_counts[key]) >= USER_LIMIT_MAX:
105
+ logger.warning(f"RATE_LIMIT | user={account} | requests={len(user_request_counts[key])}")
106
+ return False
107
+
108
+ # 记录请求
109
+ user_request_counts[key].append(now)
110
+ return True
111
+
112
+
113
+ def user_rate_limit_decorator(func):
114
+ """用户级限流装饰器"""
115
+ @wraps(func)
116
+ async def wrapper(*args, **kwargs):
117
+ # 尝试从参数中获取用户账号
118
+ account = kwargs.get('current_user') or kwargs.get('account')
119
+
120
+ if account and not check_user_rate_limit(account):
121
+ from fastapi import HTTPException
122
+ raise HTTPException(
123
+ status_code=429,
124
+ detail="请求过于频繁,请稍后再试"
125
+ )
126
+
127
+ return await func(*args, **kwargs)
128
+ return wrapper
129
+
130
+
131
+ # ==========================================
132
+ # 📊 限流统计
133
+ # ==========================================
134
+
135
+ # IP 请求统计(用于监控)
136
+ ip_stats = {}
137
+
138
+
139
+ def record_request(ip: str, endpoint: str):
140
+ """记录请求用于统计"""
141
+ now = time.time()
142
+ key = f"{ip}:{endpoint}"
143
+
144
+ if key not in ip_stats:
145
+ ip_stats[key] = {"count": 0, "first_request": now}
146
+
147
+ ip_stats[key]["count"] += 1
148
+ ip_stats[key]["last_request"] = now
149
+
150
+
151
+ def get_ip_stats():
152
+ """获取 IP 请求统计"""
153
+ return ip_stats
154
+
155
+
156
+ def clear_old_stats(max_age: int = 3600):
157
+ """清理超过指定时间的统计数据"""
158
+ now = time.time()
159
+ keys_to_remove = [
160
+ k for k, v in ip_stats.items()
161
+ if now - v.get("last_request", 0) > max_age
162
+ ]
163
+ for k in keys_to_remove:
164
+ del ip_stats[k]
router_items.py CHANGED
@@ -42,8 +42,9 @@ async def get_items(type: str = "tool", sort: str = "time", limit: int = 50): #
42
  item["comments"] = len(item["commentsData"])
43
  item["latest_version"] = versions_db.get(item["id"], "")
44
 
45
- # 🔴 【绝对核心防线】:在下发给前端前,强行在内存中抹除创作者的 Token
46
  item.pop("github_token", None)
 
47
 
48
  if sort == "likes": filtered_items.sort(key=lambda x: x.get("likes", 0), reverse=True)
49
  elif sort == "favorites": filtered_items.sort(key=lambda x: x.get("favorites", 0), reverse=True)
@@ -133,8 +134,12 @@ async def create_item(item: ItemCreate):
133
  items_db = db.load_data("items.json", default_data=[])
134
  new_item = {
135
  "id": f"{item.type}_{int(time.time())}_{uuid.uuid4().hex[:6]}", "type": item.type, "title": item.title, "author": item.author,
136
- "shortDesc": item.shortDesc, "fullDesc": item.fullDesc, "link": item.link, "coverUrl": item.coverUrl, "price": item.price,
 
 
137
  "github_token": item.github_token,
 
 
138
  "likes": 0, "favorites": 0, "comments": 0, "uses": 0, "use_history": {}, "created_at": int(time.time()), "liked_by": [], "favorited_by": []
139
  }
140
  items_db.insert(0, new_item)
@@ -146,6 +151,7 @@ async def update_item(item_id: str, update_data: ItemUpdate, current_user: str =
146
  """
147
  更新内容接口
148
  🔒 P0安全修复:使用 JWT Token 验证用户身份,而非前端传入的 author 参数
 
149
  """
150
  if update_data.price is not None:
151
  update_data.price = int(update_data.price)
@@ -159,16 +165,48 @@ async def update_item(item_id: str, update_data: ItemUpdate, current_user: str =
159
  if item.get("author") != current_user:
160
  raise HTTPException(status_code=403, detail="无权修改他人发布的内容")
161
 
 
 
162
  if update_data.title is not None: item["title"] = update_data.title
163
  if update_data.shortDesc is not None: item["shortDesc"] = update_data.shortDesc
164
  if update_data.fullDesc is not None: item["fullDesc"] = update_data.fullDesc
165
  if update_data.link is not None: item["link"] = update_data.link
166
  if update_data.coverUrl is not None: item["coverUrl"] = update_data.coverUrl
167
- if update_data.price is not None: item["price"] = update_data.price
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  if update_data.github_token is not None: item["github_token"] = update_data.github_token
 
 
169
 
170
  db.save_data("items.json", items_db)
171
- return {"status": "success"}
 
 
 
 
 
172
 
173
  raise HTTPException(status_code=404, detail="找不到该内容记录")
174
 
 
42
  item["comments"] = len(item["commentsData"])
43
  item["latest_version"] = versions_db.get(item["id"], "")
44
 
45
+ # 🔴 【绝对核心防线】:在下发给前端前,强行在内存中抹除敏感信息
46
  item.pop("github_token", None)
47
+ item.pop("netdisk_password", None) # ☁️ 网盘密码不在列表中显示
48
 
49
  if sort == "likes": filtered_items.sort(key=lambda x: x.get("likes", 0), reverse=True)
50
  elif sort == "favorites": filtered_items.sort(key=lambda x: x.get("favorites", 0), reverse=True)
 
134
  items_db = db.load_data("items.json", default_data=[])
135
  new_item = {
136
  "id": f"{item.type}_{int(time.time())}_{uuid.uuid4().hex[:6]}", "type": item.type, "title": item.title, "author": item.author,
137
+ "shortDesc": item.shortDesc, "fullDesc": item.fullDesc, "link": item.link, "coverUrl": item.coverUrl,
138
+ "imageUrls": item.imageUrls or [], # 🖼️ 效果展示图列表
139
+ "price": item.price,
140
  "github_token": item.github_token,
141
+ "netdisk_password": item.netdisk_password, # ☁️ 网盘密码
142
+ "is_netdisk": item.is_netdisk, # ☁️ 是否网盘资源
143
  "likes": 0, "favorites": 0, "comments": 0, "uses": 0, "use_history": {}, "created_at": int(time.time()), "liked_by": [], "favorited_by": []
144
  }
145
  items_db.insert(0, new_item)
 
151
  """
152
  更新内容接口
153
  🔒 P0安全修复:使用 JWT Token 验证用户身份,而非前端传入的 author 参数
154
+ 🔄 P7后悔模式:价格修改延迟24小时生效
155
  """
156
  if update_data.price is not None:
157
  update_data.price = int(update_data.price)
 
165
  if item.get("author") != current_user:
166
  raise HTTPException(status_code=403, detail="无权修改他人发布的内容")
167
 
168
+ price_change_info = None
169
+
170
  if update_data.title is not None: item["title"] = update_data.title
171
  if update_data.shortDesc is not None: item["shortDesc"] = update_data.shortDesc
172
  if update_data.fullDesc is not None: item["fullDesc"] = update_data.fullDesc
173
  if update_data.link is not None: item["link"] = update_data.link
174
  if update_data.coverUrl is not None: item["coverUrl"] = update_data.coverUrl
175
+ if update_data.imageUrls is not None: item["imageUrls"] = update_data.imageUrls # 🖼️ 效果展示图列表
176
+
177
+ # 🔄 P7后悔模式:价格修改延迟24小时生效
178
+ if update_data.price is not None:
179
+ current_price = item.get("price", 0)
180
+ new_price = update_data.price
181
+
182
+ if current_price != new_price:
183
+ # 设置待生效价格,24小时后生效
184
+ import datetime
185
+ effective_time = datetime.datetime.now() + datetime.timedelta(hours=24)
186
+ item["pending_price"] = new_price
187
+ item["pending_price_effective_at"] = effective_time.isoformat()
188
+ price_change_info = {
189
+ "current_price": current_price,
190
+ "new_price": new_price,
191
+ "effective_at": effective_time.isoformat()
192
+ }
193
+ # 不立即修改 price,等待24小时后生效
194
+ else:
195
+ # 价格未变,清除待生效价格
196
+ item["pending_price"] = None
197
+ item["pending_price_effective_at"] = None
198
+
199
  if update_data.github_token is not None: item["github_token"] = update_data.github_token
200
+ if update_data.netdisk_password is not None: item["netdisk_password"] = update_data.netdisk_password # ☁️
201
+ if update_data.is_netdisk is not None: item["is_netdisk"] = update_data.is_netdisk # ☁️
202
 
203
  db.save_data("items.json", items_db)
204
+
205
+ result = {"status": "success"}
206
+ if price_change_info:
207
+ result["price_change"] = price_change_info
208
+ result["message"] = f"价格将于24小时后从{current_price}调整为{new_price}积分"
209
+ return result
210
 
211
  raise HTTPException(status_code=404, detail="找不到该内容记录")
212
 
router_posts.py CHANGED
@@ -1,278 +1,368 @@
1
- # router_posts.py
2
  # ==========================================
3
- # 💬 讨论区路由模块
4
  # ==========================================
5
- # 作用处理讨论区内容的增删改查和互动功能
6
- # 关联文件:
7
- # - 数据库连接.py (JSON数据库读写 posts.json)
8
- # - models.py (PostCreate, PostUpdate, PostInteraction)
9
- # 前端调用:
10
- # - 讨论区列表组件.js (获取帖子列表)
11
- # - 讨论区发布组件.js (发布新帖子)
12
- # - 讨论区详情组件.js (帖子详情、互动操作)
13
  # ==========================================
14
 
15
- from fastapi import APIRouter, HTTPException
16
- import 数据库连接 as db
17
- from models import PostCreate, PostUpdate, PostInteraction
 
18
  import time
19
  import uuid
20
 
21
- # 创建子路由实例
22
  router = APIRouter()
23
 
24
-
25
  # ==========================================
26
- # 📖 获取讨论区列表
27
  # ==========================================
 
28
  @router.get("/api/posts")
29
- async def get_posts(sort: str = "time", page: int = 1, page_size: int = 20):
30
  """
31
- 获取讨论区帖子列表
32
-
33
- 查询参数:
34
- - sort: 排序方式 (time=最新, hot=最热)
35
- - page: 页码
36
- - page_size: 每页数量
37
  """
38
  posts_db = db.load_data("posts.json", default_data=[])
 
39
 
40
- # 排序
41
- if sort == "hot":
42
- # 最热:按点赞+收藏加权排序
43
- posts_db.sort(key=lambda x: x.get("likes", 0) * 2 + x.get("favorites", 0), reverse=True)
44
- else:
45
- # 最新:按创建时间倒序
46
- posts_db.sort(key=lambda x: x.get("createdAt", 0), reverse=True)
47
 
48
  # 分页
49
- start = (page - 1) * page_size
50
- end = start + page_size
51
- paginated = posts_db[start:end]
 
 
 
 
 
 
 
 
 
 
 
52
 
53
  return {
54
  "status": "success",
55
- "data": paginated,
56
  "total": len(posts_db),
57
  "page": page,
58
- "page_size": page_size
59
  }
60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
 
62
- # ==========================================
63
- # 📖 获取单个帖子详情
64
- # ==========================================
65
  @router.get("/api/posts/{post_id}")
66
  async def get_post_detail(post_id: str):
67
  """
68
  获取帖子详情
69
  """
70
  posts_db = db.load_data("posts.json", default_data=[])
 
71
 
72
- post = next((p for p in posts_db if p.get("id") == post_id), None)
73
- if not post:
74
- raise HTTPException(status_code=404, detail="帖子不存在")
75
 
76
- # 增加浏览量
77
- post["views"] = post.get("views", 0) + 1
78
- db.save_data("posts.json", posts_db)
 
 
 
 
 
 
 
 
79
 
80
- return {"status": "success", "data": post}
81
-
82
 
83
- # ==========================================
84
- # ✏️ 发布新帖子
85
- # ==========================================
86
  @router.post("/api/posts")
87
- async def create_post(post_data: PostCreate):
88
  """
89
- 发布帖子
90
  """
91
  posts_db = db.load_data("posts.json", default_data=[])
92
- users_db = db.load_data("users.json", default_data={})
93
 
94
- # 获取作者信息
95
- author_info = users_db.get(post_data.author, {})
96
 
97
- # 构建新帖子对象
98
  new_post = {
99
- "id": f"post_{uuid.uuid4().hex[:12]}",
100
- "type": "discussion",
101
- "title": post_data.title,
102
- "content": post_data.content,
103
- "coverImage": post_data.coverImage,
104
- "images": post_data.images or [],
105
-
106
- # 作者信息快照
107
- "author": post_data.author,
108
- "authorName": author_info.get("name", post_data.author),
109
- "authorAvatar": author_info.get("avatarDataUrl", ""),
110
-
111
- # 互动数据初始化
112
  "likes": 0,
113
  "favorites": 0,
114
  "comments": 0,
115
- "views": 0,
116
-
117
- # 点赞/收藏用户列表(用于判断当前用户是否已操作)
118
- "likedBy": [],
119
- "favoritedBy": [],
120
-
121
- # 打赏数据
122
- "tip_board": [],
123
- "totalTips": 0,
124
-
125
- # 时间戳
126
- "createdAt": int(time.time()),
127
- "updatedAt": int(time.time())
128
  }
129
 
130
- posts_db.insert(0, new_post) # 插入到列表头部
131
  db.save_data("posts.json", posts_db)
132
 
133
  return {"status": "success", "data": new_post}
134
 
135
-
136
- # ==========================================
137
- # ✏️ 更新帖子
138
- # ==========================================
139
  @router.put("/api/posts/{post_id}")
140
- async def update_post(post_id: str, update_data: PostUpdate, author: str = None):
141
  """
142
- 更新帖子内容
143
  """
144
  posts_db = db.load_data("posts.json", default_data=[])
145
 
146
- post_idx = next((i for i, p in enumerate(posts_db) if p.get("id") == post_id), None)
147
- if post_idx is None:
148
- raise HTTPException(status_code=404, detail="帖子不存在")
 
 
 
 
 
 
 
 
 
 
 
 
 
149
 
150
- post = posts_db[post_idx]
 
 
 
 
 
 
 
151
 
152
- # 权限校验:只有作者可以修改
153
- if author and post.get("author") != author:
154
- raise HTTPException(status_code=403, detail="无权修改此帖子")
 
 
 
 
 
155
 
156
- # 更新字段
157
- for k, v in update_data.dict(exclude_unset=True).items():
158
- if v is not None:
159
- post[k] = v
 
 
 
 
 
 
 
 
160
 
161
- post["updatedAt"] = int(time.time())
162
- db.save_data("posts.json", posts_db)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
 
164
- return {"status": "success", "data": post}
165
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
 
167
  # ==========================================
168
- # 🗑️ 删除帖子
169
  # ==========================================
170
- @router.delete("/api/posts/{post_id}")
171
- async def delete_post(post_id: str, author: str = None):
 
172
  """
173
- 删除帖子
174
  """
 
 
 
175
  posts_db = db.load_data("posts.json", default_data=[])
 
176
 
177
- post_idx = next((i for i, p in enumerate(posts_db) if p.get("id") == post_id), None)
178
- if post_idx is None:
 
 
 
 
 
 
179
  raise HTTPException(status_code=404, detail="帖子不存在")
180
 
181
- post = posts_db[post_idx]
 
 
 
 
 
 
 
 
 
 
 
 
182
 
183
- # 权限校验
184
- if author and post.get("author") != author:
185
- raise HTTPException(status_code=403, detail="无权删除此帖子")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
 
187
- posts_db.pop(post_idx)
188
  db.save_data("posts.json", posts_db)
 
189
 
190
- return {"status": "success", "message": "帖子已删除"}
191
-
192
 
193
  # ==========================================
194
- # ❤️ 点赞/收藏操作
195
  # ==========================================
196
- @router.post("/api/posts/interaction")
197
- async def toggle_interaction(req: PostInteraction):
 
198
  """
199
- 点赞或收藏帖子
200
-
201
- 请求参数:
202
- - post_id: 帖子ID
203
- - user_id: 用户账号
204
- - action_type: like / favorite
205
- - is_active: True=添加, False=取消
206
  """
207
- posts_db = db.load_data("posts.json", default_data=[])
208
- users_db = db.load_data("users.json", default_data={})
209
 
210
- post_idx = next((i for i, p in enumerate(posts_db) if p.get("id") == req.post_id), None)
211
- if post_idx is None:
212
- raise HTTPException(status_code=404, detail="帖子不存在")
213
 
214
- post = posts_db[post_idx]
215
-
216
- if req.action_type == "like":
217
- liked_by = post.get("likedBy", [])
218
- if req.is_active and req.user_id not in liked_by:
219
- liked_by.append(req.user_id)
220
- post["likes"] = post.get("likes", 0) + 1
221
- elif not req.is_active and req.user_id in liked_by:
222
- liked_by.remove(req.user_id)
223
- post["likes"] = max(0, post.get("likes", 0) - 1)
224
- post["likedBy"] = liked_by
225
-
226
- elif req.action_type == "favorite":
227
- favorited_by = post.get("favoritedBy", [])
228
- if req.is_active and req.user_id not in favorited_by:
229
- favorited_by.append(req.user_id)
230
- post["favorites"] = post.get("favorites", 0) + 1
231
- # 同步到用户收藏列表
232
- user = users_db.get(req.user_id, {})
233
- user_favs = user.get("favorited_posts", [])
234
- if req.post_id not in user_favs:
235
- user_favs.append(req.post_id)
236
- user["favorited_posts"] = user_favs
237
- users_db[req.user_id] = user
238
- db.save_data("users.json", users_db)
239
- elif not req.is_active and req.user_id in favorited_by:
240
- favorited_by.remove(req.user_id)
241
- post["favorites"] = max(0, post.get("favorites", 0) - 1)
242
- # 从用户收藏列表移除
243
- user = users_db.get(req.user_id, {})
244
- user_favs = user.get("favorited_posts", [])
245
- if req.post_id in user_favs:
246
- user_favs.remove(req.post_id)
247
- user["favorited_posts"] = user_favs
248
- users_db[req.user_id] = user
249
- db.save_data("users.json", users_db)
250
- post["favoritedBy"] = favorited_by
251
 
252
- db.save_data("posts.json", posts_db)
 
 
 
 
 
 
 
 
253
 
254
- return {
255
- "status": "success",
256
- "data": {
257
- "likes": post.get("likes", 0),
258
- "favorites": post.get("favorites", 0),
259
- "isLiked": req.user_id in post.get("likedBy", []),
260
- "isFavorited": req.user_id in post.get("favoritedBy", [])
261
- }
262
- }
263
-
264
 
265
- # ==========================================
266
- # 📊 获取用户的帖子列表
267
- # ==========================================
268
- @router.get("/api/posts/user/{account}")
269
- async def get_user_posts(account: str):
270
  """
271
- 获取指定用户发布的帖子
272
  """
 
 
 
273
  posts_db = db.load_data("posts.json", default_data=[])
 
 
 
 
 
 
274
 
275
- user_posts = [p for p in posts_db if p.get("author") == account]
276
- user_posts.sort(key=lambda x: x.get("createdAt", 0), reverse=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
 
278
- return {"status": "success", "data": user_posts}
 
1
+ # 云端Space代码/router_posts.py
2
  # ==========================================
3
+ # 💬 讨论区API路由(小红书风格图文社区)
4
  # ==========================================
5
+ # 功能帖子发布、列表、详情、互动(点赞/收藏/评论/打赏)
 
 
 
 
 
 
 
6
  # ==========================================
7
 
8
+ from fastapi import APIRouter, HTTPException, Depends
9
+ from models import PostCreate, PostUpdate
10
+ from 数据库连接 import db
11
+ from 安全认证 import require_auth
12
  import time
13
  import uuid
14
 
 
15
  router = APIRouter()
16
 
 
17
  # ==========================================
18
+ # 📝 帖子CRUD接口
19
  # ==========================================
20
+
21
  @router.get("/api/posts")
22
+ async def get_posts(page: int = 1, limit: int = 20):
23
  """
24
+ 获取帖子列表(分页,按时间倒序)
 
 
 
 
 
25
  """
26
  posts_db = db.load_data("posts.json", default_data=[])
27
+ users_db = db.load_data("users.json", default_data=[])
28
 
29
+ # 构建用户信息映射
30
+ user_map = {u["account"]: u for u in users_db}
31
+
32
+ # 按创建时间倒序
33
+ sorted_posts = sorted(posts_db, key=lambda x: x.get("created_at", 0), reverse=True)
 
 
34
 
35
  # 分页
36
+ start = (page - 1) * limit
37
+ end = start + limit
38
+ paged_posts = sorted_posts[start:end]
39
+
40
+ # 附加作者信息
41
+ result = []
42
+ for post in paged_posts:
43
+ author_info = user_map.get(post.get("author"), {})
44
+ post_data = {
45
+ **post,
46
+ "author_name": author_info.get("name", post.get("author")),
47
+ "author_avatar": author_info.get("avatar", "")
48
+ }
49
+ result.append(post_data)
50
 
51
  return {
52
  "status": "success",
53
+ "data": result,
54
  "total": len(posts_db),
55
  "page": page,
56
+ "limit": limit
57
  }
58
 
59
+ @router.get("/api/my-posts")
60
+ async def get_my_posts(current_user: str = Depends(require_auth)):
61
+ """
62
+ 获取我的帖子列表
63
+ """
64
+ posts_db = db.load_data("posts.json", default_data=[])
65
+
66
+ # 筛选当前用户的帖子
67
+ my_posts = [p for p in posts_db if p.get("author") == current_user]
68
+
69
+ # 按创建时间倒序
70
+ my_posts = sorted(my_posts, key=lambda x: x.get("created_at", 0), reverse=True)
71
+
72
+ return {
73
+ "status": "success",
74
+ "data": my_posts
75
+ }
76
 
 
 
 
77
  @router.get("/api/posts/{post_id}")
78
  async def get_post_detail(post_id: str):
79
  """
80
  获取帖子详情
81
  """
82
  posts_db = db.load_data("posts.json", default_data=[])
83
+ users_db = db.load_data("users.json", default_data=[])
84
 
85
+ user_map = {u["account"]: u for u in users_db}
 
 
86
 
87
+ for post in posts_db:
88
+ if post["id"] == post_id:
89
+ author_info = user_map.get(post.get("author"), {})
90
+ return {
91
+ "status": "success",
92
+ "data": {
93
+ **post,
94
+ "author_name": author_info.get("name", post.get("author")),
95
+ "author_avatar": author_info.get("avatar", "")
96
+ }
97
+ }
98
 
99
+ raise HTTPException(status_code=404, detail="帖子不存在")
 
100
 
 
 
 
101
  @router.post("/api/posts")
102
+ async def create_post(post: PostCreate, current_user: str = Depends(require_auth)):
103
  """
104
+ 发布帖子
105
  """
106
  posts_db = db.load_data("posts.json", default_data=[])
 
107
 
108
+ # 限制图片数量
109
+ images = (post.images or [])[:9]
110
 
 
111
  new_post = {
112
+ "id": f"post_{int(time.time())}_{uuid.uuid4().hex[:6]}",
113
+ "title": post.title,
114
+ "content": post.content,
115
+ "cover_image": post.cover_image,
116
+ "images": images,
117
+ "author": current_user,
118
+ "created_at": int(time.time()),
119
+ # 互动数据
 
 
 
 
 
120
  "likes": 0,
121
  "favorites": 0,
122
  "comments": 0,
123
+ "liked_by": [],
124
+ "favorited_by": [],
125
+ "tip_board": [] # 打赏榜单
 
 
 
 
 
 
 
 
 
 
126
  }
127
 
128
+ posts_db.insert(0, new_post)
129
  db.save_data("posts.json", posts_db)
130
 
131
  return {"status": "success", "data": new_post}
132
 
 
 
 
 
133
  @router.put("/api/posts/{post_id}")
134
+ async def update_post(post_id: str, update_data: PostUpdate, current_user: str = Depends(require_auth)):
135
  """
136
+ 更新帖子(仅作者可操作)
137
  """
138
  posts_db = db.load_data("posts.json", default_data=[])
139
 
140
+ for post in posts_db:
141
+ if post["id"] == post_id:
142
+ if post.get("author") != current_user:
143
+ raise HTTPException(status_code=403, detail="无权修改他人帖子")
144
+
145
+ if update_data.title is not None:
146
+ post["title"] = update_data.title
147
+ if update_data.content is not None:
148
+ post["content"] = update_data.content
149
+ if update_data.cover_image is not None:
150
+ post["cover_image"] = update_data.cover_image
151
+ if update_data.images is not None:
152
+ post["images"] = update_data.images[:9]
153
+
154
+ db.save_data("posts.json", posts_db)
155
+ return {"status": "success"}
156
 
157
+ raise HTTPException(status_code=404, detail="帖子不存在")
158
+
159
+ @router.delete("/api/posts/{post_id}")
160
+ async def delete_post(post_id: str, current_user: str = Depends(require_auth)):
161
+ """
162
+ 删除帖子(仅作者可操作)
163
+ """
164
+ posts_db = db.load_data("posts.json", default_data=[])
165
 
166
+ for i, post in enumerate(posts_db):
167
+ if post["id"] == post_id:
168
+ if post.get("author") != current_user:
169
+ raise HTTPException(status_code=403, detail="无权删除他人帖子")
170
+
171
+ posts_db.pop(i)
172
+ db.save_data("posts.json", posts_db)
173
+ return {"status": "success"}
174
 
175
+ raise HTTPException(status_code=404, detail="帖子不存在")
176
+
177
+ # ==========================================
178
+ # ❤️ 互动接口(点赞/收藏)
179
+ # ==========================================
180
+
181
+ @router.post("/api/posts/{post_id}/like")
182
+ async def toggle_like(post_id: str, current_user: str = Depends(require_auth)):
183
+ """
184
+ 点赞/取消点赞
185
+ """
186
+ posts_db = db.load_data("posts.json", default_data=[])
187
 
188
+ for post in posts_db:
189
+ if post["id"] == post_id:
190
+ liked_by = post.get("liked_by", [])
191
+
192
+ if current_user in liked_by:
193
+ liked_by.remove(current_user)
194
+ post["likes"] = max(0, post.get("likes", 0) - 1)
195
+ action = "unliked"
196
+ else:
197
+ liked_by.append(current_user)
198
+ post["likes"] = post.get("likes", 0) + 1
199
+ action = "liked"
200
+
201
+ post["liked_by"] = liked_by
202
+ db.save_data("posts.json", posts_db)
203
+
204
+ return {"status": "success", "action": action, "likes": post["likes"]}
205
 
206
+ raise HTTPException(status_code=404, detail="帖子不存在")
207
 
208
+ @router.post("/api/posts/{post_id}/favorite")
209
+ async def toggle_favorite(post_id: str, current_user: str = Depends(require_auth)):
210
+ """
211
+ 收藏/取消收藏
212
+ """
213
+ posts_db = db.load_data("posts.json", default_data=[])
214
+
215
+ for post in posts_db:
216
+ if post["id"] == post_id:
217
+ favorited_by = post.get("favorited_by", [])
218
+
219
+ if current_user in favorited_by:
220
+ favorited_by.remove(current_user)
221
+ post["favorites"] = max(0, post.get("favorites", 0) - 1)
222
+ action = "unfavorited"
223
+ else:
224
+ favorited_by.append(current_user)
225
+ post["favorites"] = post.get("favorites", 0) + 1
226
+ action = "favorited"
227
+
228
+ post["favorited_by"] = favorited_by
229
+ db.save_data("posts.json", posts_db)
230
+
231
+ return {"status": "success", "action": action, "favorites": post["favorites"]}
232
+
233
+ raise HTTPException(status_code=404, detail="帖子不存在")
234
 
235
  # ==========================================
236
+ # 🎁 打赏接口
237
  # ==========================================
238
+
239
+ @router.post("/api/posts/{post_id}/tip")
240
+ async def tip_post(post_id: str, amount: int, is_anon: bool = False, current_user: str = Depends(require_auth)):
241
  """
242
+ 打赏帖子
243
  """
244
+ if amount <= 0:
245
+ raise HTTPException(status_code=400, detail="打赏金额必须大于0")
246
+
247
  posts_db = db.load_data("posts.json", default_data=[])
248
+ users_db = db.load_data("users.json", default_data=[])
249
 
250
+ # 查找帖子
251
+ target_post = None
252
+ for post in posts_db:
253
+ if post["id"] == post_id:
254
+ target_post = post
255
+ break
256
+
257
+ if not target_post:
258
  raise HTTPException(status_code=404, detail="帖子不存在")
259
 
260
+ # 不能打赏自己
261
+ if target_post.get("author") == current_user:
262
+ raise HTTPException(status_code=400, detail="不能打赏自己的帖子")
263
+
264
+ # 检查余额
265
+ tipper = None
266
+ for u in users_db:
267
+ if u["account"] == current_user:
268
+ tipper = u
269
+ break
270
+
271
+ if not tipper or tipper.get("balance", 0) < amount:
272
+ raise HTTPException(status_code=400, detail="余额不足")
273
 
274
+ # 扣款
275
+ tipper["balance"] = tipper.get("balance", 0) - amount
276
+
277
+ # 给作者加钱
278
+ for u in users_db:
279
+ if u["account"] == target_post.get("author"):
280
+ u["balance"] = u.get("balance", 0) + amount
281
+ break
282
+
283
+ # 更新打赏榜单
284
+ tip_board = target_post.get("tip_board", [])
285
+ existing = next((t for t in tip_board if t["account"] == current_user), None)
286
+ if existing:
287
+ existing["amount"] += amount
288
+ else:
289
+ tip_board.append({
290
+ "account": current_user,
291
+ "amount": amount,
292
+ "is_anon": is_anon
293
+ })
294
+
295
+ # 按金额排序
296
+ tip_board.sort(key=lambda x: x["amount"], reverse=True)
297
+ target_post["tip_board"] = tip_board
298
 
 
299
  db.save_data("posts.json", posts_db)
300
+ db.save_data("users.json", users_db)
301
 
302
+ return {"status": "success", "message": f"成功打赏 {amount} 积分"}
 
303
 
304
  # ==========================================
305
+ # 💬 评论接口(复用通用评论系统)
306
  # ==========================================
307
+
308
+ @router.get("/api/posts/{post_id}/comments")
309
+ async def get_post_comments(post_id: str):
310
  """
311
+ 获取帖子评论
 
 
 
 
 
 
312
  """
313
+ comments_db = db.load_data("comments.json", default_data=[])
314
+ users_db = db.load_data("users.json", default_data=[])
315
 
316
+ user_map = {u["account"]: u for u in users_db}
 
 
317
 
318
+ # 过滤该帖子的评论
319
+ post_comments = [c for c in comments_db if c.get("target_id") == post_id and c.get("target_type") == "post"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
 
321
+ # 附加用户信息
322
+ result = []
323
+ for c in post_comments:
324
+ author_info = user_map.get(c.get("author"), {})
325
+ result.append({
326
+ **c,
327
+ "author_name": author_info.get("name", c.get("author")),
328
+ "author_avatar": author_info.get("avatar", "")
329
+ })
330
 
331
+ return {"status": "success", "data": result}
 
 
 
 
 
 
 
 
 
332
 
333
+ @router.post("/api/posts/{post_id}/comments")
334
+ async def add_post_comment(post_id: str, content: str, current_user: str = Depends(require_auth)):
 
 
 
335
  """
336
+ 添加帖子评论
337
  """
338
+ if not content or not content.strip():
339
+ raise HTTPException(status_code=400, detail="评论内容不能为空")
340
+
341
  posts_db = db.load_data("posts.json", default_data=[])
342
+ comments_db = db.load_data("comments.json", default_data=[])
343
+
344
+ # 检查帖子是否存在
345
+ post_exists = any(p["id"] == post_id for p in posts_db)
346
+ if not post_exists:
347
+ raise HTTPException(status_code=404, detail="帖子不存在")
348
 
349
+ new_comment = {
350
+ "id": f"comment_{int(time.time())}_{uuid.uuid4().hex[:6]}",
351
+ "target_id": post_id,
352
+ "target_type": "post",
353
+ "author": current_user,
354
+ "content": content.strip(),
355
+ "created_at": int(time.time())
356
+ }
357
+
358
+ comments_db.insert(0, new_comment)
359
+ db.save_data("comments.json", comments_db)
360
+
361
+ # 更新帖子评论数
362
+ for post in posts_db:
363
+ if post["id"] == post_id:
364
+ post["comments"] = post.get("comments", 0) + 1
365
+ break
366
+ db.save_data("posts.json", posts_db)
367
 
368
+ return {"status": "success", "data": new_comment}
router_tasks.py CHANGED
@@ -1,1411 +1,1028 @@
1
- # router_tasks.py
2
  # ==========================================
3
- # 📝 任务榜路由模块
4
  # ==========================================
5
- # 作用处理任务榜的发布、申请指派、提交、验收等功能
6
- # 关联文件
7
- # - 数据库连接.py (JSON数据库读写 tasks.json)
8
- # - models.py (TaskCreate, TaskApply, TaskAssign 等)
9
- # - router_wallet.py (订金/尾款支付)
10
- # 前端调用:
11
- # - 任务榜列表组件.js (获取任务列表)
12
- # - 任务榜发布组件.js (发布新任务)
13
- # - 任务榜详情组件.js (任务详情、申请/指派/提交/验收)
14
- # ==========================================
15
- # P2 增强功能:
16
- # - 余额预检与冻结
17
- # - 交易明细记录
18
- # - 过期自动退款
19
  # - 支付通知推送
20
- # P3 增强功能:
21
- # - 验收申诉机制
22
- # - 管理员仲裁
23
- # - 争议资金分配
24
  # ==========================================
25
 
26
- from fastapi import APIRouter, HTTPException
27
- import 数据库连接 as db
28
- from models import TaskCreate, TaskUpdate, TaskApply, TaskAssign, TaskSubmit, TaskAccept, TaskDispute, TaskDisputeResponse, TaskDisputeResolve
 
 
 
 
 
29
  import time
30
  import uuid
31
- from datetime import datetime
 
32
 
33
- # 创建子路由实例
34
  router = APIRouter()
35
 
 
 
36
 
37
  # ==========================================
38
- # 💰 P2增强:交易明细记录辅助函数
39
  # ==========================================
40
- def _record_transaction(account: str, tx_type: str, amount: int, related_task_id: str = None,
41
- target_account: str = None, note: str = ""):
 
 
 
42
  """
43
- 记录交易明细
44
-
45
- tx_type 类型:
46
- - task_freeze: 发布任务冻结
47
- - task_unfreeze: 取消任务解冻
48
- - deposit_pay: 支付订金
49
- - deposit_receive: 收到订金
50
- - final_pay: 支付尾款
51
- - final_receive: 收到尾款
52
- - task_refund: 任务退款
53
- - expired_refund: 过期退款
54
  """
55
- transactions_db = db.load_data("transactions.json", default_data=[])
56
-
57
- transaction = {
58
- "id": f"tx_{uuid.uuid4().hex[:12]}",
59
- "account": account,
60
- "type": tx_type,
61
- "amount": amount,
62
- "related_task_id": related_task_id,
63
- "target_account": target_account,
64
- "note": note,
65
- "createdAt": int(time.time())
66
- }
67
-
68
- transactions_db.insert(0, transaction)
69
- db.save_data("transactions.json", transactions_db)
 
 
70
 
71
- return transaction
 
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
 
74
- def _send_task_notification(account: str, title: str, content: str, task_id: str = None):
 
 
 
 
 
75
  """
76
- 发送任务相关的系统通知
 
 
 
77
  """
78
- messages_db = db.load_data("messages.json", default_data={})
79
-
80
- if account not in messages_db:
81
- messages_db[account] = []
82
 
83
- notification = {
84
- "id": f"msg_{uuid.uuid4().hex[:8]}",
85
- "type": "task_notification",
86
- "title": title,
87
- "content": content,
88
- "related_task_id": task_id,
89
- "timestamp": int(time.time()),
90
- "read": False
91
- }
92
-
93
- messages_db[account].insert(0, notification)
94
- db.save_data("messages.json", messages_db)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
 
96
- return notification
97
 
98
-
99
- # ==========================================
100
- # 📖 获取任务榜列表(仅显示未过期+招募中的任务)
101
- # ==========================================
102
  @router.get("/api/tasks")
103
- async def get_tasks(sort: str = "time", page: int = 1, page_size: int = 20):
 
 
 
 
 
 
104
  """
105
- 获取任务列表
106
- P2增强:过期自动退款
107
-
108
- 查询参数:
109
- - sort: 排序方式 (time=最新, price=价格最高)
110
- - page: 页码
111
- - page_size: 每页数量
112
  """
113
  tasks_db = db.load_data("tasks.json", default_data=[])
114
- users_db = db.load_data("users.json", default_data={})
115
- current_time = int(time.time())
116
 
117
- # 过滤:只显示 open 状态且未过任务
118
- visible_tasks = []
119
- users_updated = False
120
 
121
- for task in tasks_db:
122
- # 检查是否过期
123
- deadline_ts = _parse_deadline(task.get("deadline", ""))
124
- if deadline_ts and deadline_ts < current_time:
125
- # P2增强:过期自动退款
126
- if task.get("status") in ["open", "assigned"]:
127
- _handle_task_expiry(task, users_db)
128
- users_updated = True
129
- continue
130
-
131
- # 只显示招募中的任务
132
- if task.get("status") == "open":
133
- visible_tasks.append(task)
134
 
135
- # 保存状态更新
136
- db.save_data("tasks.json", tasks_db)
137
- if users_updated:
138
- db.save_data("users.json", users_db)
 
 
 
139
 
140
  # 排序
141
  if sort == "price":
142
- visible_tasks.sort(key=lambda x: x.get("totalPrice", 0), reverse=True)
143
- else:
144
- visible_tasks.sort(key=lambda x: x.get("createdAt", 0), reverse=True)
 
 
145
 
146
  # 分页
147
- start = (page - 1) * page_size
148
- end = start + page_size
149
- paginated = visible_tasks[start:end]
 
 
 
 
 
 
 
 
 
 
 
150
 
151
  return {
152
  "status": "success",
153
- "data": paginated,
154
- "total": len(visible_tasks),
155
  "page": page,
156
- "page_size": page_size
157
  }
158
 
159
-
160
- def _handle_task_expiry(task: dict, users_db: dict):
161
- """
162
- P2增强:处理任务过期退款
163
- """
164
- publisher = task.get("publisher")
165
- assignee = task.get("assignee")
166
- status = task.get("status")
167
-
168
- publisher_info = users_db.get(publisher, {})
169
-
170
- if status == "open":
171
- # 招募中过期:解冻全部金额
172
- frozen_amount = task.get("frozenAmount", task.get("totalPrice", 0))
173
- publisher_info["frozen_balance"] = max(0, publisher_info.get("frozen_balance", 0) - frozen_amount)
174
- users_db[publisher] = publisher_info
175
-
176
- _record_transaction(
177
- account=publisher,
178
- tx_type="expired_refund",
179
- amount=frozen_amount,
180
- related_task_id=task.get("id"),
181
- note=f"任务过期解冻: {task['title'][:20]}"
182
- )
183
-
184
- _send_task_notification(
185
- account=publisher,
186
- title="⏰ 任务已过期",
187
- content=f"任务『{task['title']}』已过期,冻结的 {frozen_amount} 积分已解冻。",
188
- task_id=task.get("id")
189
- )
190
-
191
- elif status == "assigned":
192
- # 已指派但未完成过期
193
- if task.get("depositPaid"):
194
- # 已支付订金:从接单者扣回订金,解冻尾款
195
- deposit_amount = task.get("depositAmount", 0)
196
- final_payment = task.get("finalPayment", 0)
197
-
198
- if assignee in users_db:
199
- users_db[assignee]["balance"] = max(0, users_db[assignee].get("balance", 0) - deposit_amount)
200
-
201
- publisher_info["balance"] = publisher_info.get("balance", 0) + deposit_amount
202
- publisher_info["frozen_balance"] = max(0, publisher_info.get("frozen_balance", 0) - final_payment)
203
- users_db[publisher] = publisher_info
204
-
205
- _record_transaction(
206
- account=publisher,
207
- tx_type="expired_refund",
208
- amount=deposit_amount,
209
- related_task_id=task.get("id"),
210
- target_account=assignee,
211
- note=f"任务过期收回订金: {task['title'][:20]}"
212
- )
213
-
214
- _send_task_notification(
215
- account=publisher,
216
- title="⏰ 任务已过期",
217
- content=f"任务『{task['title']}』已过期,订金 {deposit_amount} 积分已退回。",
218
- task_id=task.get("id")
219
- )
220
- _send_task_notification(
221
- account=assignee,
222
- title="⏰ 任务已过期",
223
- content=f"任务『{task['title']}』已过期,订金 {deposit_amount} 积分已退回发布者。",
224
- task_id=task.get("id")
225
- )
226
- else:
227
- # 未支付订金:解冻全部金额
228
- frozen_amount = task.get("frozenAmount", task.get("totalPrice", 0))
229
- publisher_info["frozen_balance"] = max(0, publisher_info.get("frozen_balance", 0) - frozen_amount)
230
- users_db[publisher] = publisher_info
231
-
232
- _record_transaction(
233
- account=publisher,
234
- tx_type="expired_refund",
235
- amount=frozen_amount,
236
- related_task_id=task.get("id"),
237
- note=f"任务过期解冻: {task['title'][:20]}"
238
- )
239
-
240
- _send_task_notification(
241
- account=publisher,
242
- title="⏰ 任务已过期",
243
- content=f"任务『{task['title']}』已过期,冻结的 {frozen_amount} 积分已解冻。",
244
- task_id=task.get("id")
245
- )
246
-
247
- task["status"] = "expired"
248
- task["expiredAt"] = int(time.time())
249
-
250
-
251
- # ==========================================
252
- # 📖 获取单个任务详情
253
- # ==========================================
254
  @router.get("/api/tasks/{task_id}")
255
- async def get_task_detail(task_id: str):
256
  """
257
  获取任务详情
258
  """
259
  tasks_db = db.load_data("tasks.json", default_data=[])
 
260
 
261
- task = next((t for t in tasks_db if t.get("id") == task_id), None)
262
- if not task:
263
- raise HTTPException(status_code=404, detail="任务不存在")
264
 
265
- return {"status": "success", "data": task}
266
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
 
268
- # ==========================================
269
- # ✏️ 发布新任务(P2增强:余额预检+冻结)
270
- # ==========================================
271
  @router.post("/api/tasks")
272
- async def create_task(task_data: TaskCreate):
273
  """
274
  发布新任务
275
- P2增强:发布时预检余额并冻结总金
276
  """
277
  tasks_db = db.load_data("tasks.json", default_data=[])
278
- users_db = db.load_data("users.json", default_data={})
279
 
280
- # 获取发布者信息
281
- publisher_info = users_db.get(task_data.publisher, {})
 
282
 
283
- # 💰 P2增强:余额预检
284
- current_balance = publisher_info.get("balance", 0)
285
- frozen_balance = publisher_info.get("frozen_balance", 0)
286
- available_balance = current_balance - frozen_balance
287
 
288
- if available_balance < task_data.totalPrice:
289
- raise HTTPException(
290
- status_code=400,
291
- detail=f"可用余额不足!当前可用 {available_balance} 积分,任务需要 {task_data.totalPrice} 积分"
292
- )
 
293
 
294
- # 计算订金和尾款
295
- deposit_amount = int(task_data.totalPrice * task_data.depositRatio / 100)
296
- final_payment = task_data.totalPrice - deposit_amount
297
 
298
- # 💰 P2增强:冻结总金
299
- publisher_info["frozen_balance"] = frozen_balance + task_data.totalPrice
300
- users_db[task_data.publisher] = publisher_info
301
- db.save_data("users.json", users_db)
302
 
303
- # 记录冻结交易
304
- _record_transaction(
305
- account=task_data.publisher,
306
- tx_type="task_freeze",
307
- amount=task_data.totalPrice,
308
- note=f"发布任务冻结: {task_data.title[:20]}"
309
- )
310
 
311
- # 构建新任务对象
312
- task_id = f"task_{uuid.uuid4().hex[:12]}"
313
  new_task = {
314
  "id": task_id,
315
- "type": "task",
316
- "title": task_data.title,
317
- "description": task_data.description,
318
- "referenceImages": task_data.referenceImages or [],
319
- "referenceLink": task_data.referenceLink or "",
320
-
321
- # 💰 价格与订金
322
- "totalPrice": task_data.totalPrice,
323
- "depositRatio": task_data.depositRatio,
324
- "depositAmount": deposit_amount,
325
- "finalPayment": final_payment,
326
- "frozenAmount": task_data.totalPrice, # P2: 记录冻结金额
327
-
328
- # ⏰ 时间控制
329
- "deadline": task_data.deadline,
330
- "createdAt": int(time.time()),
331
-
332
- # 👤 参与者
333
- "publisher": task_data.publisher,
334
- "publisherName": publisher_info.get("name", task_data.publisher),
335
- "publisherAvatar": publisher_info.get("avatarDataUrl", ""),
336
- "applicants": [], # 申请者列表 [{account, name, avatar, message, appliedAt}]
337
- "assignee": None, # 被选中的接单者
338
- "assigneeName": None,
339
- "assigneeAvatar": None,
340
-
341
- # 📊 状态机
342
- "status": "open", # open/assigned/in_progress/submitted/completed/expired/cancelled
343
- "depositPaid": False, # 订金已支付
344
- "finalPaid": False, # 尾款已支付
345
-
346
- # 📁 交付物
347
- "deliverables": [], # 接单者提交的成果
348
- "deliverNote": "", # 交付备注
349
- "publisherFeedback": "" # 发布者反馈
350
  }
351
 
352
  tasks_db.insert(0, new_task)
353
  db.save_data("tasks.json", tasks_db)
354
 
355
- return {"status": "success", "data": new_task, "message": f"任务发布成功,已冻结 {task_data.totalPrice} 积分"}
356
-
357
-
358
- # ==========================================
359
- # 🙋 申请接单
360
- # ==========================================
361
- @router.post("/api/tasks/apply")
362
- async def apply_task(req: TaskApply):
363
- """
364
- 接单者申请接单
365
- """
366
- tasks_db = db.load_data("tasks.json", default_data=[])
367
- users_db = db.load_data("users.json", default_data={})
368
-
369
- task_idx = next((i for i, t in enumerate(tasks_db) if t.get("id") == req.task_id), None)
370
- if task_idx is None:
371
- raise HTTPException(status_code=404, detail="任务不存在")
372
-
373
- task = tasks_db[task_idx]
374
-
375
- # 状态检查
376
- if task.get("status") != "open":
377
- raise HTTPException(status_code=400, detail="任务不在招募中")
378
-
379
- # 不能申请自己发布的任务
380
- if task.get("publisher") == req.applicant:
381
- raise HTTPException(status_code=400, detail="不能申请自己发布的任务")
382
-
383
- # 检查是否已申请
384
- applicants = task.get("applicants", [])
385
- if any(a.get("account") == req.applicant for a in applicants):
386
- raise HTTPException(status_code=400, detail="您已申请过此任务")
387
-
388
- # 获取申请者信息
389
- applicant_info = users_db.get(req.applicant, {})
390
-
391
- # 添加申请
392
- applicants.append({
393
- "account": req.applicant,
394
- "name": applicant_info.get("name", req.applicant),
395
- "avatar": applicant_info.get("avatarDataUrl", ""),
396
- "message": req.message or "",
397
- "appliedAt": int(time.time())
398
- })
399
- task["applicants"] = applicants
400
-
401
- db.save_data("tasks.json", tasks_db)
402
 
403
- # TODO: 发送通知给发布者
404
 
405
- return {"status": "success", "message": "申请成功,等待发布者选择"}
406
 
407
-
408
- # ==========================================
409
- # 👆 发布者指派接单者
410
- # ==========================================
411
- @router.post("/api/tasks/assign")
412
- async def assign_task(req: TaskAssign):
413
  """
414
- 发布者指派接单者
415
  """
416
  tasks_db = db.load_data("tasks.json", default_data=[])
417
- users_db = db.load_data("users.json", default_data={})
418
-
419
- task_idx = next((i for i, t in enumerate(tasks_db) if t.get("id") == req.task_id), None)
420
- if task_idx is None:
421
- raise HTTPException(status_code=404, detail="任务不存在")
422
-
423
- task = tasks_db[task_idx]
424
-
425
- # 权限校验
426
- if task.get("publisher") != req.publisher:
427
- raise HTTPException(status_code=403, detail="无权操作此任务")
428
-
429
- # 状态检查
430
- if task.get("status") != "open":
431
- raise HTTPException(status_code=400, detail="任务已不在招募中")
432
-
433
- # 检查指派的人是否在申请列表中
434
- applicants = task.get("applicants", [])
435
- assignee_info = next((a for a in applicants if a.get("account") == req.assignee), None)
436
- if not assignee_info:
437
- raise HTTPException(status_code=400, detail="该用户未申请此任务")
438
-
439
- # 更新任务状态
440
- task["assignee"] = req.assignee
441
- task["assigneeName"] = assignee_info.get("name", req.assignee)
442
- task["assigneeAvatar"] = assignee_info.get("avatar", "")
443
- task["status"] = "assigned" # 等待支付订金
444
 
445
- db.save_data("tasks.json", tasks_db)
446
-
447
- # P2增强:发送通知给接单者
448
- _send_task_notification(
449
- account=req.assignee,
450
- title="🎉 您已被选中接单",
451
- content=f"您已被选中接单任务『{task['title']}』,请等待发布者支付订金后开始工作。",
452
- task_id=task["id"]
453
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
454
 
455
- return {"status": "success", "message": "已指派接单者,请支付订金"}
456
 
457
-
458
- # ==========================================
459
- # 💰 支付订金(P2增强:从冻结余额扣款+交易记录+通知)
460
- # ==========================================
461
- @router.post("/api/tasks/{task_id}/pay_deposit")
462
- async def pay_deposit(task_id: str, publisher: str):
463
  """
464
- 发布者支付订金
465
- P2增强:从冻结余额中扣除订金
466
  """
467
  tasks_db = db.load_data("tasks.json", default_data=[])
468
- users_db = db.load_data("users.json", default_data={})
469
-
470
- task_idx = next((i for i, t in enumerate(tasks_db) if t.get("id") == task_id), None)
471
- if task_idx is None:
472
- raise HTTPException(status_code=404, detail="任务不存在")
473
-
474
- task = tasks_db[task_idx]
475
-
476
- # 权限校验
477
- if task.get("publisher") != publisher:
478
- raise HTTPException(status_code=403, detail="无权操作此任务")
479
-
480
- # 状态检查
481
- if task.get("status") != "assigned":
482
- raise HTTPException(status_code=400, detail="当前状态不允许支付订金")
483
-
484
- if task.get("depositPaid"):
485
- raise HTTPException(status_code=400, detail="订金已支付")
486
-
487
- deposit_amount = task.get("depositAmount", 0)
488
- assignee = task.get("assignee")
489
- publisher_info = users_db.get(publisher, {})
490
-
491
- # P2增强:从冻结余额中扣除订金
492
- frozen_balance = publisher_info.get("frozen_balance", 0)
493
- current_balance = publisher_info.get("balance", 0)
494
-
495
- # 订金从冻结金额中支付(已在发布时冻结)
496
- # 扣减实际余额,同时减少冻结金额
497
- publisher_info["balance"] = current_balance - deposit_amount
498
- publisher_info["frozen_balance"] = frozen_balance - deposit_amount
499
- users_db[publisher] = publisher_info
500
-
501
- # 订金转入接单者账户
502
- if assignee not in users_db:
503
- users_db[assignee] = {"balance": 0, "frozen_balance": 0}
504
- users_db[assignee]["balance"] = users_db[assignee].get("balance", 0) + deposit_amount
505
-
506
- # 更新任务状态
507
- task["depositPaid"] = True
508
- task["status"] = "in_progress"
509
- task["depositPaidAt"] = int(time.time())
510
-
511
- db.save_data("tasks.json", tasks_db)
512
- db.save_data("users.json", users_db)
513
-
514
- # P2增强:记录交易明细
515
- _record_transaction(
516
- account=publisher,
517
- tx_type="deposit_pay",
518
- amount=deposit_amount,
519
- related_task_id=task_id,
520
- target_account=assignee,
521
- note=f"支付订金: {task['title'][:20]}"
522
- )
523
- _record_transaction(
524
- account=assignee,
525
- tx_type="deposit_receive",
526
- amount=deposit_amount,
527
- related_task_id=task_id,
528
- target_account=publisher,
529
- note=f"收到订金: {task['title'][:20]}"
530
- )
531
 
532
- # P2增强:发送通知
533
- _send_task_notification(
534
- account=assignee,
535
- title="💰 订金已到账",
536
- content=f"任务『{task['title']}』的订金 {deposit_amount} 积分已到账,请尽快开始工作!",
537
- task_id=task_id
538
- )
 
 
 
 
539
 
540
- return {"status": "success", "message": f"订金 {deposit_amount} 积分已支付,任务开始进行"}
541
-
542
 
543
  # ==========================================
544
- # 📤 接单者提交成果
545
  # ==========================================
546
- @router.post("/api/tasks/submit")
547
- async def submit_deliverables(req: TaskSubmit):
 
548
  """
549
- 接单者提交成果
550
  """
551
  tasks_db = db.load_data("tasks.json", default_data=[])
552
 
553
- task_idx = next((i for i, t in enumerate(tasks_db) if t.get("id") == req.task_id), None)
554
- if task_idx is None:
555
- raise HTTPException(status_code=404, detail="任务不存在")
556
-
557
- task = tasks_db[task_idx]
558
-
559
- # 权限校验
560
- if task.get("assignee") != req.assignee:
561
- raise HTTPException(status_code=403, detail="您不此任务的接单者")
562
-
563
- # 状态检查
564
- if task.get("status") != "in_progress":
565
- raise HTTPException(status_code=400, detail="当前状态不允许提交成果")
566
-
567
- # 更新交付物
568
- task["deliverables"] = req.deliverables
569
- task["deliverNote"] = req.note or ""
570
- task["status"] = "submitted"
571
- task["submittedAt"] = int(time.time())
572
-
573
- db.save_data("tasks.json", tasks_db)
574
-
575
- # P2增强:发送通知给发布者
576
- _send_task_notification(
577
- account=task.get("publisher"),
578
- title="📥 任务成果已提交",
579
- content=f"接单者已提交任务『{task['title']}』的成果,请查看并验收。",
580
- task_id=req.task_id
581
- )
 
 
 
 
582
 
583
- return {"status": "success", "message": "成果已提交,等待发布者验收"}
584
 
585
-
586
- # ==========================================
587
- # ✅ 发布者验收(P2增强:从冻结余额支付尾款+交易记录+通知)
588
- # ==========================================
589
- @router.post("/api/tasks/accept")
590
- async def accept_task(req: TaskAccept):
591
  """
592
- 发布者验收成果
593
- P2增强:尾款从冻结余额支付
594
  """
595
  tasks_db = db.load_data("tasks.json", default_data=[])
596
- users_db = db.load_data("users.json", default_data={})
597
-
598
- task_idx = next((i for i, t in enumerate(tasks_db) if t.get("id") == req.task_id), None)
599
- if task_idx is None:
600
- raise HTTPException(status_code=404, detail="任务不存在")
601
-
602
- task = tasks_db[task_idx]
603
-
604
- # 权限校验
605
- if task.get("publisher") != req.publisher:
606
- raise HTTPException(status_code=403, detail="无权操作此任务")
607
 
608
- # 状态检查
609
- if task.get("status") != "submitted":
610
- raise HTTPException(status_code=400, detail="当前状态不允许验收")
611
-
612
- task["publisherFeedback"] = req.feedback or ""
613
- publisher = task.get("publisher")
614
- assignee = task.get("assignee")
615
-
616
- if req.is_accepted:
617
- # 验收通过:支付尾款
618
- final_payment = task.get("finalPayment", 0)
619
- publisher_info = users_db.get(publisher, {})
620
-
621
- # P2增强:尾款从冻结余额中支付
622
- frozen_balance = publisher_info.get("frozen_balance", 0)
623
- current_balance = publisher_info.get("balance", 0)
624
-
625
- # 扣减实际余额,同时减少冻结金额
626
- publisher_info["balance"] = current_balance - final_payment
627
- publisher_info["frozen_balance"] = frozen_balance - final_payment
628
- users_db[publisher] = publisher_info
629
-
630
- # 尾款转入接单者账户
631
- if assignee not in users_db:
632
- users_db[assignee] = {"balance": 0, "frozen_balance": 0}
633
- users_db[assignee]["balance"] = users_db[assignee].get("balance", 0) + final_payment
634
-
635
- # 更新任务状态
636
- task["finalPaid"] = True
637
- task["status"] = "completed"
638
- task["completedAt"] = int(time.time())
639
-
640
- db.save_data("users.json", users_db)
641
-
642
- # P2增强:记录交易明细
643
- _record_transaction(
644
- account=publisher,
645
- tx_type="final_pay",
646
- amount=final_payment,
647
- related_task_id=req.task_id,
648
- target_account=assignee,
649
- note=f"支付尾款: {task['title'][:20]}"
650
- )
651
- _record_transaction(
652
- account=assignee,
653
- tx_type="final_receive",
654
- amount=final_payment,
655
- related_task_id=req.task_id,
656
- target_account=publisher,
657
- note=f"收到尾款: {task['title'][:20]}"
658
- )
659
-
660
- # P2增强:发送通知
661
- total_earned = task.get("depositAmount", 0) + final_payment
662
- _send_task_notification(
663
- account=assignee,
664
- title="🎉 任务已完成",
665
- content=f"任务『{task['title']}』已验收通过!尾款 {final_payment} 积分已到账,共计收入 {total_earned} 积分。",
666
- task_id=req.task_id
667
- )
668
-
669
- message = f"验收通过!尾款 {final_payment} 积分已支付给接单者"
670
- else:
671
- # 验收不通过:任务回到进行中状态
672
- task["status"] = "in_progress"
673
- task["deliverables"] = [] # 清空交付物
674
-
675
- # P2增强:发送通知
676
- _send_task_notification(
677
- account=assignee,
678
- title="⚠️ 验收未通过",
679
- content=f"任务『{task['title']}』验收未通过,请查看反馈并重新提交。反馈:{req.feedback or '无'}",
680
- task_id=req.task_id
681
- )
682
-
683
- message = "验收不通过,请接单者重新提交"
684
-
685
- db.save_data("tasks.json", tasks_db)
686
 
687
- return {"status": "success", "message": message}
688
-
689
 
690
  # ==========================================
691
- # 取消任务(P2增强:解冻余额+交易记录+通知)
692
  # ==========================================
693
- @router.post("/api/tasks/{task_id}/cancel")
694
- async def cancel_task(task_id: str, publisher: str):
 
695
  """
696
- 发布者取消任务
697
- P2增强:取消时解,退还订金
698
  """
699
  tasks_db = db.load_data("tasks.json", default_data=[])
700
- users_db = db.load_data("users.json", default_data={})
701
-
702
- task_idx = next((i for i, t in enumerate(tasks_db) if t.get("id") == task_id), None)
703
- if task_idx is None:
704
- raise HTTPException(status_code=404, detail="任务不存在")
705
-
706
- task = tasks_db[task_idx]
707
-
708
- # 权限校验
709
- if task.get("publisher") != publisher:
710
- raise HTTPException(status_code=403, detail="无权操作此任务")
711
 
712
- status = task.get("status")
713
-
714
- # 只有 open 和 assigned 状态可以取消
715
- if status not in ["open", "assigned"]:
716
- raise HTTPException(status_code=400, detail="任务进行中或已完成,无法取消")
717
-
718
- publisher_info = users_db.get(publisher, {})
719
- frozen_amount = task.get("frozenAmount", task.get("totalPrice", 0))
720
- assignee = task.get("assignee")
721
-
722
- # P2增强:解冻余额
723
- if status == "open":
724
- # 招募中取消:解冻全部金额
725
- publisher_info["frozen_balance"] = max(0, publisher_info.get("frozen_balance", 0) - frozen_amount)
726
- users_db[publisher] = publisher_info
727
-
728
- _record_transaction(
729
- account=publisher,
730
- tx_type="task_unfreeze",
731
- amount=frozen_amount,
732
- related_task_id=task_id,
733
- note=f"取消任务解冻: {task['title'][:20]}"
734
- )
735
-
736
- elif status == "assigned":
737
- if task.get("depositPaid"):
738
- # 已支付订金:从接单者扣回订金,解冻剩余尾款
739
- deposit_amount = task.get("depositAmount", 0)
740
- final_payment = task.get("finalPayment", 0)
741
-
742
- # 从接单者扣除订金
743
- if assignee in users_db:
744
- users_db[assignee]["balance"] = max(0, users_db[assignee].get("balance", 0) - deposit_amount)
745
-
746
- # 退还给发布者
747
- publisher_info["balance"] = publisher_info.get("balance", 0) + deposit_amount
748
- # 解冻剩余尾款
749
- publisher_info["frozen_balance"] = max(0, publisher_info.get("frozen_balance", 0) - final_payment)
750
- users_db[publisher] = publisher_info
751
-
752
- # 记录退款交易
753
- _record_transaction(
754
- account=assignee,
755
- tx_type="task_refund",
756
- amount=-deposit_amount,
757
- related_task_id=task_id,
758
- target_account=publisher,
759
- note=f"任务取消退回订金: {task['title'][:20]}"
760
- )
761
- _record_transaction(
762
- account=publisher,
763
- tx_type="task_refund",
764
- amount=deposit_amount,
765
- related_task_id=task_id,
766
- target_account=assignee,
767
- note=f"任务取消收回订金: {task['title'][:20]}"
768
- )
769
 
770
- # 通知接单者
771
- _send_task_notification(
772
- account=assignee,
773
- title="⚠️ 任务已取消",
774
- content=f"任务『{task['title']}』已被发布者取消,订金 {deposit_amount} 积分已退回。",
775
- task_id=task_id
776
- )
777
- else:
778
- # 未支付订金:解冻全部金额
779
- publisher_info["frozen_balance"] = max(0, publisher_info.get("frozen_balance", 0) - frozen_amount)
780
- users_db[publisher] = publisher_info
781
-
782
- _record_transaction(
783
- account=publisher,
784
- tx_type="task_unfreeze",
785
- amount=frozen_amount,
786
- related_task_id=task_id,
787
- note=f"取消任务解冻: {task['title'][:20]}"
 
 
 
 
788
  )
789
 
790
- # 通知接单者
791
- if assignee:
792
- _send_task_notification(
793
- account=assignee,
794
- title="⚠️ 任务取消",
795
- content=f"任务『{task['title']}』已被发布者取消。",
796
- task_id=task_id
797
- )
798
-
799
- db.save_data("users.json", users_db)
800
-
801
- task["status"] = "cancelled"
802
- task["cancelledAt"] = int(time.time())
803
-
804
- db.save_data("tasks.json", tasks_db)
 
 
 
 
 
 
805
 
806
- return {"status": "success", "message": "任务已取消"}
807
-
808
 
809
  # ==========================================
810
- # 📊 获取用户相关的任务
811
  # ==========================================
812
- @router.get("/api/tasks/user/{account}")
813
- async def get_user_tasks(account: str, role: str = "all"):
 
814
  """
815
- 获取用户发布或参与的任务
816
-
817
- 查询参数:
818
- - role: publisher=我发布的, assignee=我接的, all=全部
819
  """
820
  tasks_db = db.load_data("tasks.json", default_data=[])
821
 
822
- user_tasks = []
823
  for task in tasks_db:
824
- is_publisher = task.get("publisher") == account
825
- is_assignee = task.get("assignee") == account
826
- is_applicant = any(a.get("account") == account for a in task.get("applicants", []))
827
-
828
- if role == "publisher" and is_publisher:
829
- user_tasks.append({**task, "userRole": "publisher"})
830
- elif role == "assignee" and (is_assignee or is_applicant):
831
- user_tasks.append({**task, "userRole": "assignee" if is_assignee else "applicant"})
832
- elif role == "all" and (is_publisher or is_assignee or is_applicant):
833
- user_tasks.append({
834
- **task,
835
- "userRole": "publisher" if is_publisher else ("assignee" if is_assignee else "applicant")
 
 
 
 
 
 
 
 
 
 
 
 
836
  })
 
 
837
 
838
- user_tasks.sort(key=lambda x: x.get("createdAt", 0), reverse=True)
839
-
840
- return {"status": "success", "data": user_tasks}
841
-
842
-
843
- # ==========================================
844
- # 🔧 辅助函数
845
- # ==========================================
846
- def _parse_deadline(deadline_str: str) -> int:
847
- """
848
- 解析截止时间字符串为时间戳
849
- """
850
- if not deadline_str:
851
- return 0
852
- try:
853
- # 尝试多种格式
854
- for fmt in ["%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d"]:
855
- try:
856
- dt = datetime.strptime(deadline_str, fmt)
857
- return int(dt.timestamp())
858
- except ValueError:
859
- continue
860
- return 0
861
- except:
862
- return 0
863
-
864
 
865
  # ==========================================
866
- # 💰 P2增强:获取用户交易明细
867
  # ==========================================
868
- @router.get("/api/transactions/user/{account}")
869
- async def get_user_transactions(account: str, tx_type: str = "all", page: int = 1, page_size: int = 20):
870
- """
871
- 获取用户交易明细
872
-
873
- 查询参数:
874
- - tx_type: 交易类型过滤 (all/deposit/final/freeze/refund)
875
- - page: 页码
876
- - page_size: 每页数量
877
- """
878
- transactions_db = db.load_data("transactions.json", default_data=[])
879
-
880
- # 过滤用户交易
881
- user_transactions = [t for t in transactions_db if t.get("account") == account]
882
-
883
- # 按类型��滤
884
- if tx_type != "all":
885
- type_map = {
886
- "deposit": ["deposit_pay", "deposit_receive"],
887
- "final": ["final_pay", "final_receive"],
888
- "freeze": ["task_freeze", "task_unfreeze"],
889
- "refund": ["task_refund", "expired_refund"]
890
- }
891
- target_types = type_map.get(tx_type, [])
892
- if target_types:
893
- user_transactions = [t for t in user_transactions if t.get("type") in target_types]
894
-
895
- # 分页
896
- total = len(user_transactions)
897
- start = (page - 1) * page_size
898
- end = start + page_size
899
- paginated = user_transactions[start:end]
900
-
901
- return {
902
- "status": "success",
903
- "data": paginated,
904
- "total": total,
905
- "page": page,
906
- "page_size": page_size
907
- }
908
 
909
-
910
- # ==========================================
911
- # 📈 P2增强:获取用户任务收益统计
912
- # ==========================================
913
- @router.get("/api/tasks/stats/{account}")
914
- async def get_task_stats(account: str):
915
  """
916
- 获取用户任务统计信息
 
 
 
917
  """
918
  tasks_db = db.load_data("tasks.json", default_data=[])
919
- transactions_db = db.load_data("transactions.json", default_data=[])
920
-
921
- # 统计任务数量
922
- published_tasks = [t for t in tasks_db if t.get("publisher") == account]
923
- assigned_tasks = [t for t in tasks_db if t.get("assignee") == account]
924
-
925
- # 分状态统计
926
- published_open = len([t for t in published_tasks if t.get("status") == "open"])
927
- published_in_progress = len([t for t in published_tasks if t.get("status") in ["assigned", "in_progress", "submitted"]])
928
- published_completed = len([t for t in published_tasks if t.get("status") == "completed"])
929
-
930
- assigned_in_progress = len([t for t in assigned_tasks if t.get("status") in ["in_progress", "submitted"]])
931
- assigned_completed = len([t for t in assigned_tasks if t.get("status") == "completed"])
932
-
933
- # 统计收入(从交易记录中计算)
934
- user_transactions = [t for t in transactions_db if t.get("account") == account]
935
-
936
- # 任务收入(收到的订金+尾款)
937
- task_income = sum(
938
- t.get("amount", 0)
939
- for t in user_transactions
940
- if t.get("type") in ["deposit_receive", "final_receive"]
941
- )
942
-
943
- # 任务支出(支付的订金+尾款)
944
- task_expense = sum(
945
- t.get("amount", 0)
946
- for t in user_transactions
947
- if t.get("type") in ["deposit_pay", "final_pay"]
948
- )
949
 
950
- # 冻结金额(当前招募中的任务)
951
- frozen_for_tasks = sum(
952
- t.get("frozenAmount", t.get("totalPrice", 0))
953
- for t in published_tasks
954
- if t.get("status") == "open"
955
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
956
 
957
- return {
958
- "status": "success",
959
- "data": {
960
- # 发布任务统计
961
- "published": {
962
- "total": len(published_tasks),
963
- "open": published_open,
964
- "in_progress": published_in_progress,
965
- "completed": published_completed
966
- },
967
- # 接单任务统计
968
- "assigned": {
969
- "total": len(assigned_tasks),
970
- "in_progress": assigned_in_progress,
971
- "completed": assigned_completed
972
- },
973
- # 财务统计
974
- "finance": {
975
- "task_income": task_income, # 任务收入
976
- "task_expense": task_expense, # 任务支出
977
- "frozen_for_tasks": frozen_for_tasks # 任务冻结金额
978
- }
979
- }
980
- }
981
-
982
 
983
  # ==========================================
984
- # ⚖️ P3增强:发起申诉
985
  # ==========================================
986
- @router.post("/api/tasks/dispute")
987
- async def create_dispute(req: TaskDispute):
 
 
 
988
  """
989
- 发起任务申诉
990
- 由发布者或接单者发起
991
-
992
- 触发条件:
993
- - 验收不通过后,接单者可在 3 天内发起申诉
994
- - 验收通过后,发布者可在 7 天内发起申诉(发现质量问题)
995
  """
996
  tasks_db = db.load_data("tasks.json", default_data=[])
997
  disputes_db = db.load_data("disputes.json", default_data=[])
998
- users_db = db.load_data("users.json", default_data={})
999
-
1000
- # 查找任务
1001
- task = next((t for t in tasks_db if t.get("id") == req.task_id), None)
1002
- if not task:
1003
- raise HTTPException(status_code=404, detail="任务不存在")
1004
-
1005
- publisher = task.get("publisher")
1006
- assignee = task.get("assignee")
1007
-
1008
- # 权限校验:只有发布者或接单者可以发起申诉
1009
- if req.initiator not in [publisher, assignee]:
1010
- raise HTTPException(status_code=403, detail="您不是该任务的参与者,无法发起申诉")
1011
-
1012
- # 状态校验:只有特定状态可以申诉
1013
- status = task.get("status")
1014
-
1015
- # 接单者申诉:验收不���过后可申诉
1016
- if req.initiator == assignee:
1017
- if status != "in_progress": # 验收不通过后任务回到 in_progress
1018
- raise HTTPException(status_code=400, detail="当前状态不允许申诉,仅在验收不通过后可发起申诉")
1019
-
1020
- # 发布者申诉:验收通过后发现问题可申诉
1021
- if req.initiator == publisher:
1022
- if status not in ["completed", "in_progress"]:
1023
- raise HTTPException(status_code=400, detail="当前状态不允许申诉")
1024
-
1025
- # 检查是否已有未处理的申诉
1026
- existing_dispute = next(
1027
- (d for d in disputes_db if d.get("task_id") == req.task_id and d.get("status") in ["pending", "responded"]),
1028
- None
1029
- )
1030
- if existing_dispute:
1031
- raise HTTPException(status_code=400, detail="该任务已有未处理的申诉")
1032
-
1033
- # 确定被申诉方
1034
- respondent = assignee if req.initiator == publisher else publisher
1035
- respondent_info = users_db.get(respondent, {})
1036
- initiator_info = users_db.get(req.initiator, {})
1037
 
1038
- # 创建申诉记录
1039
- dispute_id = f"dispute_{uuid.uuid4().hex[:12]}"
1040
- dispute = {
1041
- "id": dispute_id,
1042
- "task_id": req.task_id,
1043
- "task_title": task.get("title", ""),
1044
-
1045
- # 申诉方信息
1046
- "initiator": req.initiator,
1047
- "initiator_name": initiator_info.get("name", req.initiator),
1048
- "initiator_avatar": initiator_info.get("avatarDataUrl", ""),
1049
- "initiator_role": "publisher" if req.initiator == publisher else "assignee",
1050
- "reason": req.reason,
1051
- "evidence": req.evidence or [],
1052
-
1053
- # 被申诉方信息
1054
- "respondent": respondent,
1055
- "respondent_name": respondent_info.get("name", respondent),
1056
- "respondent_avatar": respondent_info.get("avatarDataUrl", ""),
1057
- "response": None,
1058
- "response_evidence": [],
1059
- "responded_at": None,
1060
-
1061
- # 状态与时间
1062
- "status": "pending", # pending -> responded -> resolved
1063
- "created_at": int(time.time()),
1064
-
1065
- # 仲裁信息
1066
- "admin_account": None,
1067
- "admin_result": None,
1068
- "admin_note": None,
1069
- "resolved_at": None,
1070
-
1071
- # 涉及金额(争议金额)
1072
- "disputed_amount": task.get("finalPayment", 0) if status == "in_progress" else task.get("totalPrice", 0)
1073
- }
1074
-
1075
- # 更新任务状态为申诉中
1076
- task_idx = next((i for i, t in enumerate(tasks_db) if t.get("id") == req.task_id), None)
1077
- if task_idx is not None:
1078
- tasks_db[task_idx]["status"] = "disputed"
1079
- tasks_db[task_idx]["dispute_id"] = dispute_id
1080
-
1081
- disputes_db.insert(0, dispute)
1082
- db.save_data("disputes.json", disputes_db)
1083
- db.save_data("tasks.json", tasks_db)
1084
-
1085
- # 发送通知给被申诉方
1086
- _send_task_notification(
1087
- account=respondent,
1088
- title="⚠️ 您有新的任务申诉",
1089
- content=f"任务『{task.get('title')}』被发起申诉,请尽快回应。申诉理由:{req.reason[:50]}...",
1090
- task_id=req.task_id
1091
- )
1092
-
1093
- # 记录交易
1094
- _record_transaction(
1095
- account=req.initiator,
1096
- tx_type="dispute_initiated",
1097
- amount=0,
1098
- related_task_id=req.task_id,
1099
- target_account=respondent,
1100
- note=f"发起申诉: {task.get('title', '')[:20]}"
1101
- )
1102
 
1103
- return {"status": "success", "data": dispute, "message": "申诉已提交,请等待对方回应"}
1104
 
1105
-
1106
- # ==========================================
1107
- # ⚖️ P3增强:被申诉方回应
1108
- # ==========================================
1109
- @router.post("/api/tasks/dispute/respond")
1110
- async def respond_dispute(req: TaskDisputeResponse):
1111
  """
1112
- 申诉方回应申诉
1113
  """
1114
  disputes_db = db.load_data("disputes.json", default_data=[])
 
1115
 
1116
- # 查找申诉
1117
- dispute_idx = next((i for i, d in enumerate(disputes_db) if d.get("id") == req.dispute_id), None)
1118
- if dispute_idx is None:
1119
- raise HTTPException(status_code=404, detail="申诉不存在")
1120
-
1121
- dispute = disputes_db[dispute_idx]
1122
 
1123
- # 权限校验
1124
- if dispute.get("respondent") != req.respondent:
1125
- raise HTTPException(status_code=403, detail="您不是被申诉方,无法回应")
1126
-
1127
- # 状态校验
1128
- if dispute.get("status") != "pending":
1129
- raise HTTPException(status_code=400, detail="该申诉已回应或已处理")
1130
-
1131
- # 更新申诉记录
1132
- dispute["response"] = req.response
1133
- dispute["response_evidence"] = req.evidence or []
1134
- dispute["responded_at"] = int(time.time())
1135
- dispute["status"] = "responded"
1136
-
1137
- disputes_db[dispute_idx] = dispute
1138
- db.save_data("disputes.json", disputes_db)
1139
-
1140
- # 发送通知给申诉方
1141
- _send_task_notification(
1142
- account=dispute.get("initiator"),
1143
- title="📝 申诉已收到回应",
1144
- content=f"您对任务『{dispute.get('task_title')}』的申诉已收到对方回应,等待管理员仲裁。",
1145
- task_id=dispute.get("task_id")
1146
- )
1147
 
1148
- return {"status": "success", "message": "回应已提交,等待管理员仲裁"}
1149
-
1150
 
1151
- # ==========================================
1152
- # ⚖️ P3增强:管理员仲裁
1153
- # ==========================================
1154
- @router.post("/api/tasks/dispute/resolve")
1155
- async def resolve_dispute(req: TaskDisputeResolve):
1156
  """
1157
- 管理员仲裁申诉
1158
-
1159
- 仲裁结果:
1160
- - support_initiator: 支持申诉方,争议金额全额给申诉方
1161
- - support_respondent: 支持被申诉方,争议金额全额给被申诉方
1162
- - split: 双方分成,按 split_ratio 分配
1163
  """
1164
- # 管理员权限校验
1165
- if req.admin_account != "123456": # 管理员账号
1166
- raise HTTPException(status_code=403, detail="无管理���权限")
1167
-
1168
  disputes_db = db.load_data("disputes.json", default_data=[])
1169
- tasks_db = db.load_data("tasks.json", default_data=[])
1170
- users_db = db.load_data("users.json", default_data={})
1171
-
1172
- # 查找申诉
1173
- dispute_idx = next((i for i, d in enumerate(disputes_db) if d.get("id") == req.dispute_id), None)
1174
- if dispute_idx is None:
1175
- raise HTTPException(status_code=404, detail="申诉不存在")
1176
-
1177
- dispute = disputes_db[dispute_idx]
1178
-
1179
- # 状态校验
1180
- if dispute.get("status") == "resolved":
1181
- raise HTTPException(status_code=400, detail="该申诉已处理")
1182
-
1183
- task_id = dispute.get("task_id")
1184
- task = next((t for t in tasks_db if t.get("id") == task_id), None)
1185
- if not task:
1186
- raise HTTPException(status_code=404, detail="关联任务不存在")
1187
 
1188
- initiator = dispute.get("initiator")
1189
- respondent = dispute.get("respondent")
1190
- disputed_amount = dispute.get("disputed_amount", 0)
1191
- publisher = task.get("publisher")
1192
- assignee = task.get("assignee")
1193
-
1194
- # 根据仲裁结果分配资金
1195
- if req.result == "support_initiator":
1196
- # 支持申诉方
1197
- winner = initiator
1198
- loser = respondent
1199
- winner_amount = disputed_amount
1200
- loser_amount = 0
1201
- result_msg = f"申诉成功,争议金额 {disputed_amount} 积分已分配给申诉方"
1202
-
1203
- elif req.result == "support_respondent":
1204
- # 支持被申诉方
1205
- winner = respondent
1206
- loser = initiator
1207
- winner_amount = disputed_amount
1208
- loser_amount = 0
1209
- result_msg = f"申诉被驳回,争议金额 {disputed_amount} 积分已分配给被申诉方"
1210
-
1211
- elif req.result == "split":
1212
- # 双方分成
1213
- split_ratio = req.split_ratio or 50
1214
- winner_amount = int(disputed_amount * split_ratio / 100)
1215
- loser_amount = disputed_amount - winner_amount
1216
- result_msg = f"申诉调解成功,申诉方获得 {winner_amount} 积分被申诉方获得 {loser_amount} 积分"
1217
- else:
1218
- raise HTTPException(status_code=400, detail="无效的仲裁结果")
1219
-
1220
- # 执行资金转移
1221
- # 如果申诉方是接单者且胜诉,尾款从发布者冻结中扣除并转给接单者
1222
- # 如果申诉方是发布者且胜诉,需要从接单者账户扣除并退还给发布者
1223
-
1224
- if req.result == "support_initiator":
1225
- if dispute.get("initiator_role") == "assignee":
1226
- # 接单者胜诉:尾款转给接单者
1227
- _execute_dispute_payment(users_db, publisher, assignee, disputed_amount, task, "assignee_wins")
1228
- else:
1229
- # 发布者胜诉:从接单者扣除已支付的订金
1230
- _execute_dispute_payment(users_db, assignee, publisher, disputed_amount, task, "publisher_wins")
1231
- elif req.result == "support_respondent":
1232
- if dispute.get("initiator_role") == "assignee":
1233
- # 接单者申诉失败:不进行额外资金转移,任务继续
1234
- pass
1235
- else:
1236
- # 发布者申诉失败:尾款继续留给接单者
1237
- pass
1238
- elif req.result == "split":
1239
- # 分成处理
1240
- _execute_dispute_split(users_db, publisher, assignee, initiator, respondent, winner_amount, loser_amount, task)
1241
-
1242
- db.save_data("users.json", users_db)
1243
-
1244
- # 更新申诉状态
1245
- dispute["status"] = "resolved"
1246
- dispute["admin_account"] = req.admin_account
1247
- dispute["admin_result"] = req.result
1248
- dispute["admin_note"] = req.admin_note
1249
- dispute["resolved_at"] = int(time.time())
1250
- if req.result == "split":
1251
- dispute["split_ratio"] = req.split_ratio
1252
-
1253
- disputes_db[dispute_idx] = dispute
1254
- db.save_data("disputes.json", disputes_db)
1255
-
1256
- # 更新任务状态
1257
- task_idx = next((i for i, t in enumerate(tasks_db) if t.get("id") == task_id), None)
1258
- if task_idx is not None:
1259
- tasks_db[task_idx]["status"] = "resolved" # 申诉已解决
1260
- tasks_db[task_idx]["dispute_result"] = req.result
1261
- db.save_data("tasks.json", tasks_db)
1262
-
1263
- # 发送通知给双方
1264
- _send_task_notification(
1265
- account=initiator,
1266
- title="⚖️ 申诉已处理",
1267
- content=f"任务『{task.get('title')}』的申诉已由管理员处理。{result_msg}",
1268
- task_id=task_id
1269
- )
1270
- _send_task_notification(
1271
- account=respondent,
1272
- title="⚖️ 申诉已处理",
1273
- content=f"任务『{task.get('title')}』的申诉已由管理员处理。{result_msg}",
1274
- task_id=task_id
1275
- )
1276
-
1277
- # 记录交易
1278
- _record_transaction(
1279
- account=req.admin_account,
1280
- tx_type="dispute_resolved",
1281
- amount=disputed_amount,
1282
- related_task_id=task_id,
1283
- note=f"仲裁申诉: {task.get('title', '')[:20]} - {req.result}"
1284
- )
1285
 
1286
- return {"status": "success", "message": result_msg}
1287
-
1288
 
1289
- def _execute_dispute_payment(users_db: dict, from_account: str, to_account: str, amount: int, task: dict, scenario: str):
 
1290
  """
1291
- 执行申诉仲裁后的资金转移
 
1292
  """
1293
- from_info = users_db.get(from_account, {})
1294
- to_info = users_db.get(to_account, {})
1295
 
1296
- if scenario == "assignee_wins":
1297
- # 接单者胜诉:从发布者冻结中扣除尾款并转给接单者
1298
- from_info["balance"] = from_info.get("balance", 0) - amount
1299
- from_info["frozen_balance"] = max(0, from_info.get("frozen_balance", 0) - amount)
1300
- to_info["balance"] = to_info.get("balance", 0) + amount
1301
-
1302
- _record_transaction(from_account, "dispute_loss", amount, task.get("id"), to_account, "申诉裁决扣款")
1303
- _record_transaction(to_account, "dispute_win", amount, task.get("id"), from_account, "申诉裁决获得")
1304
-
1305
- elif scenario == "publisher_wins":
1306
- # 发布者胜诉:从接单者扣除已收到的订金并退给发布者
1307
- deposit_amount = task.get("depositAmount", 0)
1308
- from_info["balance"] = max(0, from_info.get("balance", 0) - deposit_amount)
1309
- to_info["balance"] = to_info.get("balance", 0) + deposit_amount
1310
-
1311
- _record_transaction(from_account, "dispute_loss", deposit_amount, task.get("id"), to_account, "申诉裁决退还订金")
1312
- _record_transaction(to_account, "dispute_win", deposit_amount, task.get("id"), from_account, "申诉裁决收回订金")
1313
-
1314
- users_db[from_account] = from_info
1315
- users_db[to_account] = to_info
1316
-
 
 
 
 
1317
 
1318
- def _execute_dispute_split(users_db: dict, publisher: str, assignee: str, initiator: str, respondent: str,
1319
- initiator_amount: int, respondent_amount: int, task: dict):
1320
  """
1321
- 执行申诉分成处理
 
 
 
1322
  """
1323
- # 从发布者冻结中扣除尾款
1324
- publisher_info = users_db.get(publisher, {})
1325
- final_payment = task.get("finalPayment", 0)
1326
-
1327
- publisher_info["balance"] = publisher_info.get("balance", 0) - final_payment
1328
- publisher_info["frozen_balance"] = max(0, publisher_info.get("frozen_balance", 0) - final_payment)
1329
- users_db[publisher] = publisher_info
1330
-
1331
- # 分配给双方
1332
- initiator_info = users_db.get(initiator, {})
1333
- respondent_info = users_db.get(respondent, {})
1334
 
1335
- initiator_info["balance"] = initiator_info.get("balance", 0) + initiator_amount
1336
- respondent_info["balance"] = respondent_info.get("balance", 0) + respondent_amount
1337
 
1338
- users_db[initiator] = initiator_info
1339
- users_db[respondent] = respondent_info
1340
 
1341
- _record_transaction(initiator, "dispute_split", initiator_amount, task.get("id"), note="申诉调解分成")
1342
- _record_transaction(respondent, "dispute_split", respondent_amount, task.get("id"), note="申诉调解分成")
1343
-
1344
-
1345
- # ==========================================
1346
- # ⚖️ P3增强:获取申诉列表
1347
- # ==========================================
1348
- @router.get("/api/tasks/disputes")
1349
- async def get_disputes(status: str = "all", page: int = 1, page_size: int = 20):
1350
- """
1351
- 获取申诉列表(管理员用)
1352
- """
1353
  disputes_db = db.load_data("disputes.json", default_data=[])
 
 
1354
 
1355
- # 过滤
1356
- if status != "all":
1357
- disputes_db = [d for d in disputes_db if d.get("status") == status]
1358
-
1359
- # 排序:未处理的优先
1360
- disputes_db.sort(key=lambda x: (0 if x.get("status") in ["pending", "responded"] else 1, -x.get("created_at", 0)))
1361
-
1362
- # 分页
1363
- total = len(disputes_db)
1364
- start = (page - 1) * page_size
1365
- end = start + page_size
1366
- paginated = disputes_db[start:end]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1367
 
1368
- return {
1369
- "status": "success",
1370
- "data": paginated,
1371
- "total": total,
1372
- "page": page,
1373
- "page_size": page_size
1374
- }
1375
-
1376
 
1377
  # ==========================================
1378
- # ⚖️ P3增强:获取单个申诉详情
1379
  # ==========================================
1380
- @router.get("/api/tasks/disputes/{dispute_id}")
1381
- async def get_dispute_detail(dispute_id: str):
1382
- """
1383
- 获取申诉详情
1384
- """
1385
- disputes_db = db.load_data("disputes.json", default_data=[])
1386
-
1387
- dispute = next((d for d in disputes_db if d.get("id") == dispute_id), None)
1388
- if not dispute:
1389
- raise HTTPException(status_code=404, detail="申诉不存在")
1390
-
1391
- return {"status": "success", "data": dispute}
1392
 
1393
-
1394
- # ==========================================
1395
- # ⚖️ P3增强:获取用户相关的申诉
1396
- # ==========================================
1397
- @router.get("/api/tasks/disputes/user/{account}")
1398
- async def get_user_disputes(account: str):
1399
  """
1400
- 获取用户发起或参与申诉
 
 
1401
  """
1402
- disputes_db = db.load_data("disputes.json", default_data=[])
1403
 
1404
- user_disputes = [
1405
- d for d in disputes_db
1406
- if d.get("initiator") == account or d.get("respondent") == account
1407
- ]
1408
 
1409
- user_disputes.sort(key=lambda x: x.get("created_at", 0), reverse=True)
 
1410
 
1411
- return {"status": "success", "data": user_disputes}
 
1
+ # 云端Space代码/router_tasks.py
2
  # ==========================================
3
+ # 📝 任务榜API路由
4
  # ==========================================
5
+ # 功能:任务发布、接单、提交、验收、申诉全流程
6
+ # 状态open → in_progress → submitted → completed/disputed
7
+ # 💳 P6支付增强:
8
+ # - 发布时冻结全额,避免支付不足
9
+ # - 记录每笔交易流水
10
+ # - 超时自动退款
 
 
 
 
 
 
 
 
11
  # - 支付通知推送
 
 
 
 
12
  # ==========================================
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
+ from 数据库连接 import db
18
+ from 安全认证 import require_auth
19
+ from notifications import add_notification
20
+ from database_sql import get_db
21
+ from models_sql import Wallet, Transaction
22
  import time
23
  import uuid
24
+ import hashlib
25
+ import logging
26
 
 
27
  router = APIRouter()
28
 
29
+ # 📝 审计日志
30
+ logger = logging.getLogger("ComfyUI-Ranking.Tasks")
31
 
32
  # ==========================================
33
+ # 💳 交易记录工具函数
34
  # ==========================================
35
+ def calculate_tx_hash(tx_id, account, tx_type, amount, prev_hash):
36
+ data = f"{tx_id}{account}{tx_type}{amount}{prev_hash}"
37
+ return hashlib.sha256(data.encode()).hexdigest()
38
+
39
+ def create_task_transaction(db_session: Session, account: str, tx_type: str, amount: int, related_account: str = None, task_id: str = None):
40
  """
41
+ 创建任务相关交易记录
42
+ tx_type: TASK_FREEZE, TASK_DEPOSIT, TASK_PAYMENT, TASK_INCOME, TASK_REFUND
 
 
 
 
 
 
 
 
 
43
  """
44
+ tx_id = f"TASK_{tx_type}_{int(time.time())}_{uuid.uuid4().hex[:6]}"
45
+
46
+ last_tx = db_session.query(Transaction).filter(Transaction.account == account).order_by(Transaction.created_at.desc()).first()
47
+ prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
48
+ tx_hash = calculate_tx_hash(tx_id, account, tx_type, amount, prev_hash)
49
+
50
+ new_tx = Transaction(
51
+ tx_id=tx_id,
52
+ account=account,
53
+ tx_type=tx_type,
54
+ amount=amount,
55
+ related_account=related_account,
56
+ item_id=task_id, # 复用 item_id 字段存储任务ID
57
+ prev_hash=prev_hash,
58
+ tx_hash=tx_hash
59
+ )
60
+ db_session.add(new_tx)
61
 
62
+ logger.info(f"TASK_TX | type={tx_type} | account={account} | amount={amount} | task={task_id} | tx={tx_id}")
63
+ return tx_id
64
 
65
+ # ==========================================
66
+ # 📋 任务状态常量
67
+ # ==========================================
68
+ TASK_STATUS = {
69
+ "open": "开放接单",
70
+ "in_progress": "进行中",
71
+ "submitted": "待验收",
72
+ "completed": "已完成",
73
+ "disputed": "争议中",
74
+ "cancelled": "已取消",
75
+ "expired": "已过期"
76
+ }
77
+
78
+ # ==========================================
79
+ # 📝 任务CRUD接口
80
+ # ==========================================
81
 
82
+ # ==========================================
83
+ # 🔄 过期任务自动处理 + 自动退款
84
+ # ==========================================
85
+ import datetime
86
+
87
+ def check_and_update_expired_tasks(tasks_db, db_session=None):
88
  """
89
+ 检查并更新过期任务状态
90
+ - open 状态且超过截止日期:自动取消,退还冻结金额
91
+ - in_progress 状态且超过截止日期:标记为过期(不自动取消,需双方处理)
92
+ 💳 P6支付增强:过期时自动退款
93
  """
94
+ today = datetime.date.today().isoformat() # "2026-03-30"
95
+ updated = False
96
+ refund_tasks = [] # 需要退款的任务
 
97
 
98
+ for task in tasks_db:
99
+ deadline = task.get("deadline", "")
100
+ status = task.get("status", "")
101
+
102
+ if not deadline:
103
+ continue
104
+
105
+ # 检查是否过期
106
+ if deadline < today:
107
+ if status == "open":
108
+ # 开放接单状态且过期:自动取消,退还冻结金额
109
+ task["status"] = "expired"
110
+ task["expired_at"] = int(time.time())
111
+ updated = True
112
+
113
+ # 💳 记录需要退款的任务
114
+ frozen_amount = task.get("frozen_amount", task.get("total_price", 0))
115
+ if frozen_amount > 0:
116
+ refund_tasks.append({
117
+ "task_id": task["id"],
118
+ "publisher": task.get("publisher"),
119
+ "amount": frozen_amount,
120
+ "title": task.get("title", "")
121
+ })
122
+ task["refunded"] = True
123
+ task["refund_amount"] = frozen_amount
124
+
125
+ elif status == "in_progress":
126
+ # 进行中且过期:标记过期但不取消
127
+ task["is_overdue"] = True
128
+ updated = True
129
+
130
+ # 💳 执行退款操作
131
+ if refund_tasks and db_session:
132
+ for refund in refund_tasks:
133
+ try:
134
+ wallet = db_session.query(Wallet).filter(Wallet.account == refund["publisher"]).with_for_update().first()
135
+ if wallet:
136
+ wallet.frozen_balance = max(0, wallet.frozen_balance - refund["amount"]) # 减少冻结
137
+ wallet.balance += refund["amount"] # 返还余额
138
+
139
+ # 记录退款交易
140
+ create_task_transaction(
141
+ db_session, refund["publisher"], "TASK_REFUND",
142
+ refund["amount"], task_id=refund["task_id"]
143
+ )
144
+
145
+ # 发送退款通知
146
+ add_notification(refund["publisher"], {
147
+ "type": "task_refund",
148
+ "from_user": "system",
149
+ "target_item_id": refund["task_id"],
150
+ "target_item_title": refund["title"],
151
+ "content": f"💰 任务《{refund['title']}》已过期自动取消,{refund['amount']}积分已退还"
152
+ })
153
+
154
+ logger.info(f"TASK_REFUND | publisher={refund['publisher']} | task={refund['task_id']} | amount={refund['amount']}")
155
+ except Exception as e:
156
+ logger.error(f"TASK_REFUND_ERROR | task={refund['task_id']} | error={str(e)}")
157
+
158
+ db_session.commit()
159
 
160
+ return updated
161
 
 
 
 
 
162
  @router.get("/api/tasks")
163
+ async def get_tasks(
164
+ page: int = 1,
165
+ limit: int = 20,
166
+ status: str = None,
167
+ sort: str = "latest",
168
+ db_session: Session = Depends(get_db)
169
+ ):
170
  """
171
+ 获取任务列表(分页)
172
+ - status: 筛选���态(open/in_progress/completed/expired等)
173
+ - sort: latest(最新)/price(价格高)/deadline(截止日期近)
 
 
 
 
174
  """
175
  tasks_db = db.load_data("tasks.json", default_data=[])
176
+ users_db = db.load_data("users.json", default_data=[])
 
177
 
178
+ # 自动检查并更新过期任务(带自动退款)
179
+ if check_and_update_expired_tasks(tasks_db, db_session):
180
+ db.save_data("tasks.json", tasks_db)
181
 
182
+ user_map = {u["account"]: u for u in users_db}
 
 
 
 
 
 
 
 
 
 
 
 
183
 
184
+ # 状态筛选(默认排除已取消和过期的)
185
+ filtered = tasks_db
186
+ if status:
187
+ filtered = [t for t in filtered if t.get("status") == status]
188
+ else:
189
+ # 默认不显示已取消和过期的任务
190
+ filtered = [t for t in filtered if t.get("status") not in ["cancelled", "expired"]]
191
 
192
  # 排序
193
  if sort == "price":
194
+ filtered = sorted(filtered, key=lambda x: x.get("total_price", 0), reverse=True)
195
+ elif sort == "deadline":
196
+ filtered = sorted(filtered, key=lambda x: x.get("deadline", "9999"))
197
+ else: # latest
198
+ filtered = sorted(filtered, key=lambda x: x.get("created_at", 0), reverse=True)
199
 
200
  # 分页
201
+ start = (page - 1) * limit
202
+ end = start + limit
203
+ paged = filtered[start:end]
204
+
205
+ # 附加发布者信息
206
+ result = []
207
+ for task in paged:
208
+ publisher_info = user_map.get(task.get("publisher"), {})
209
+ result.append({
210
+ **task,
211
+ "publisher_name": publisher_info.get("name", task.get("publisher")),
212
+ "publisher_avatar": publisher_info.get("avatar", ""),
213
+ "status_text": TASK_STATUS.get(task.get("status"), "未知")
214
+ })
215
 
216
  return {
217
  "status": "success",
218
+ "data": result,
219
+ "total": len(filtered),
220
  "page": page,
221
+ "limit": limit
222
  }
223
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  @router.get("/api/tasks/{task_id}")
225
+ async def get_task_detail(task_id: str, current_user: str = None):
226
  """
227
  获取任务详情
228
  """
229
  tasks_db = db.load_data("tasks.json", default_data=[])
230
+ users_db = db.load_data("users.json", default_data=[])
231
 
232
+ user_map = {u["account"]: u for u in users_db}
 
 
233
 
234
+ for task in tasks_db:
235
+ if task["id"] == task_id:
236
+ publisher_info = user_map.get(task.get("publisher"), {})
237
+ assignee_info = user_map.get(task.get("assignee"), {}) if task.get("assignee") else None
238
+
239
+ # 构建申请者列表(带用户信息)
240
+ applicants_with_info = []
241
+ for app in task.get("applicants", []):
242
+ app_user = user_map.get(app.get("account"), {})
243
+ applicants_with_info.append({
244
+ **app,
245
+ "name": app_user.get("name", app.get("account")),
246
+ "avatar": app_user.get("avatar", "")
247
+ })
248
+
249
+ return {
250
+ "status": "success",
251
+ "data": {
252
+ **task,
253
+ "publisher_name": publisher_info.get("name", task.get("publisher")),
254
+ "publisher_avatar": publisher_info.get("avatar", ""),
255
+ "assignee_name": assignee_info.get("name") if assignee_info else None,
256
+ "assignee_avatar": assignee_info.get("avatar") if assignee_info else None,
257
+ "applicants": applicants_with_info,
258
+ "status_text": TASK_STATUS.get(task.get("status"), "未知")
259
+ }
260
+ }
261
+
262
+ raise HTTPException(status_code=404, detail="任务不存在")
263
 
 
 
 
264
  @router.post("/api/tasks")
265
+ async def create_task(task: TaskCreate, current_user: str = Depends(require_auth), db_session: Session = Depends(get_db)):
266
  """
267
  发布新任务
268
+ 💳 P6支付增强:发布时冻结,避免支付时余额不足
269
  """
270
  tasks_db = db.load_data("tasks.json", default_data=[])
 
271
 
272
+ # 验证订金比例
273
+ if task.depositRatio not in [10, 20, 30, 50]:
274
+ raise HTTPException(status_code=400, detail="订金比例必须是 10/20/30/50 之一")
275
 
276
+ # 验证价格
277
+ if task.totalPrice < 10:
278
+ raise HTTPException(status_code=400, detail="任务价格不能低于10积分")
 
279
 
280
+ # 💳 使用SQL钱包检查余额并冻结
281
+ wallet = db_session.query(Wallet).filter(Wallet.account == current_user).with_for_update().first()
282
+ if not wallet:
283
+ wallet = Wallet(account=current_user, balance=0)
284
+ db_session.add(wallet)
285
+ db_session.flush()
286
 
287
+ if wallet.balance < task.totalPrice:
288
+ raise HTTPException(status_code=400, detail=f"余额不足,需要{task.totalPrice}积分,当前{wallet.balance}积分")
 
289
 
290
+ # 💳 冻结(从余额转移到冻结余额)
291
+ wallet.balance -= task.totalPrice
292
+ wallet.frozen_balance += task.totalPrice
 
293
 
294
+ # 计算订金金额
295
+ deposit_amount = int(task.totalPrice * task.depositRatio / 100)
296
+
297
+ task_id = f"task_{int(time.time())}_{uuid.uuid4().hex[:6]}"
 
 
 
298
 
 
 
299
  new_task = {
300
  "id": task_id,
301
+ "title": task.title,
302
+ "description": task.description,
303
+ "reference_images": (task.referenceImages or [])[:6],
304
+ "reference_link": task.referenceLink,
305
+ "total_price": task.totalPrice,
306
+ "deposit_ratio": task.depositRatio,
307
+ "deposit_amount": deposit_amount,
308
+ "deadline": task.deadline,
309
+ "publisher": current_user,
310
+ "status": "open",
311
+ "created_at": int(time.time()),
312
+ # 接单相关
313
+ "applicants": [], # 申请接单的用户列表
314
+ "assignee": None, # 指派的接单者
315
+ "assigned_at": None, # 指派时间
316
+ # 交付相关
317
+ "deliverables": [], # 提交的成果
318
+ "submit_note": None, # 提交备注
319
+ "submitted_at": None, # 提交时间
320
+ # 验收相关
321
+ "completed_at": None, # 完成时间
322
+ "feedback": None, # 验收反馈
323
+ # 申诉相关
324
+ "dispute_id": None, # 关联的申诉ID
325
+ # 💳 支付状态
326
+ "frozen_amount": task.totalPrice # 已冻结金额
 
 
 
 
 
 
 
 
 
327
  }
328
 
329
  tasks_db.insert(0, new_task)
330
  db.save_data("tasks.json", tasks_db)
331
 
332
+ # 💳 记录冻结交易
333
+ create_task_transaction(
334
+ db_session, current_user, "TASK_FREEZE",
335
+ -task.totalPrice, task_id=task_id
336
+ )
337
+ db_session.commit()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
338
 
339
+ logger.info(f"TASK_CREATE | publisher={current_user} | task={task_id} | price={task.totalPrice} | frozen={task.totalPrice}")
340
 
341
+ return {"status": "success", "data": new_task, "frozen_amount": task.totalPrice}
342
 
343
+ @router.put("/api/tasks/{task_id}")
344
+ async def update_task(task_id: str, update_data: TaskUpdate, current_user: str = Depends(require_auth)):
 
 
 
 
345
  """
346
+ 更新任务(仅发布者可操作,且仅在 open 状态时可修改)
347
  """
348
  tasks_db = db.load_data("tasks.json", default_data=[])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
349
 
350
+ for task in tasks_db:
351
+ if task["id"] == task_id:
352
+ if task.get("publisher") != current_user:
353
+ raise HTTPException(status_code=403, detail="无权修改他人任务")
354
+
355
+ if task.get("status") != "open":
356
+ raise HTTPException(status_code=400, detail="只能修改开放状态的任务")
357
+
358
+ if update_data.title is not None:
359
+ task["title"] = update_data.title
360
+ if update_data.description is not None:
361
+ task["description"] = update_data.description
362
+ if update_data.referenceImages is not None:
363
+ task["reference_images"] = update_data.referenceImages[:6]
364
+ if update_data.referenceLink is not None:
365
+ task["reference_link"] = update_data.referenceLink
366
+ if update_data.totalPrice is not None:
367
+ task["total_price"] = update_data.totalPrice
368
+ task["deposit_amount"] = int(update_data.totalPrice * task["deposit_ratio"] / 100)
369
+ if update_data.depositRatio is not None and update_data.depositRatio in [10, 20, 30, 50]:
370
+ task["deposit_ratio"] = update_data.depositRatio
371
+ task["deposit_amount"] = int(task["total_price"] * update_data.depositRatio / 100)
372
+ if update_data.deadline is not None:
373
+ task["deadline"] = update_data.deadline
374
+
375
+ db.save_data("tasks.json", tasks_db)
376
+ return {"status": "success"}
377
 
378
+ raise HTTPException(status_code=404, detail="任务不存在")
379
 
380
+ @router.delete("/api/tasks/{task_id}")
381
+ async def cancel_task(task_id: str, current_user: str = Depends(require_auth)):
 
 
 
 
382
  """
383
+ 取消任务(仅发布者可操作,且仅在 open 状态时可取消)
 
384
  """
385
  tasks_db = db.load_data("tasks.json", default_data=[])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
386
 
387
+ for task in tasks_db:
388
+ if task["id"] == task_id:
389
+ if task.get("publisher") != current_user:
390
+ raise HTTPException(status_code=403, detail="无权取消他人任务")
391
+
392
+ if task.get("status") != "open":
393
+ raise HTTPException(status_code=400, detail="只能取消开放状态的任务")
394
+
395
+ task["status"] = "cancelled"
396
+ db.save_data("tasks.json", tasks_db)
397
+ return {"status": "success"}
398
 
399
+ raise HTTPException(status_code=404, detail="任务不存在")
 
400
 
401
  # ==========================================
402
+ # 🙋 申请接单
403
  # ==========================================
404
+
405
+ @router.post("/api/tasks/{task_id}/apply")
406
+ async def apply_task(task_id: str, message: str = None, current_user: str = Depends(require_auth)):
407
  """
408
+ 申请接单
409
  """
410
  tasks_db = db.load_data("tasks.json", default_data=[])
411
 
412
+ for task in tasks_db:
413
+ if task["id"] == task_id:
414
+ if task.get("status") != "open":
415
+ raise HTTPException(status_code=400, detail="该任务不在开放接单状态")
416
+
417
+ if task.get("publisher") == current_user:
418
+ raise HTTPException(status_code=400, detail="不能申请自己发布的任务")
419
+
420
+ # 检查否已申请
421
+ applicants = task.get("applicants", [])
422
+ if any(a["account"] == current_user for a in applicants):
423
+ raise HTTPException(status_code=400, detail="您已申请过该任务")
424
+
425
+ # 添加申请
426
+ applicants.append({
427
+ "account": current_user,
428
+ "message": message or "",
429
+ "applied_at": int(time.time())
430
+ })
431
+ task["applicants"] = applicants
432
+
433
+ db.save_data("tasks.json", tasks_db)
434
+
435
+ # 🔔 通知发布者:有人申请接单
436
+ add_notification(task.get("publisher"), {
437
+ "type": "task_apply",
438
+ "from_user": current_user,
439
+ "target_item_id": task_id,
440
+ "target_item_title": task.get("title", ""),
441
+ "content": f"申请了您的任务《{task.get('title', '')}》"
442
+ })
443
+
444
+ return {"status": "success", "message": "申请成功,等待发布者选择"}
445
 
446
+ raise HTTPException(status_code=404, detail="任务不存在")
447
 
448
+ @router.delete("/api/tasks/{task_id}/apply")
449
+ async def cancel_apply(task_id: str, current_user: str = Depends(require_auth)):
 
 
 
 
450
  """
451
+ 取消申请接单
 
452
  """
453
  tasks_db = db.load_data("tasks.json", default_data=[])
 
 
 
 
 
 
 
 
 
 
 
454
 
455
+ for task in tasks_db:
456
+ if task["id"] == task_id:
457
+ if task.get("status") != "open":
458
+ raise HTTPException(status_code=400, detail="任务已开始,无法取消申请")
459
+
460
+ applicants = task.get("applicants", [])
461
+ new_applicants = [a for a in applicants if a["account"] != current_user]
462
+
463
+ if len(new_applicants) == len(applicants):
464
+ raise HTTPException(status_code=400, detail="您未申请该任务")
465
+
466
+ task["applicants"] = new_applicants
467
+ db.save_data("tasks.json", tasks_db)
468
+ return {"status": "success"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
469
 
470
+ raise HTTPException(status_code=404, detail="任务不存在")
 
471
 
472
  # ==========================================
473
+ # 🎯 指派接单者
474
  # ==========================================
475
+
476
+ @router.post("/api/tasks/{task_id}/assign")
477
+ async def assign_task(task_id: str, assignee: str, current_user: str = Depends(require_auth), db_session: Session = Depends(get_db)):
478
  """
479
+ 发布者选择接单者
480
+ 💳 P6支付增强:结金中扣除订金,记录交易
481
  """
482
  tasks_db = db.load_data("tasks.json", default_data=[])
 
 
 
 
 
 
 
 
 
 
 
483
 
484
+ for task in tasks_db:
485
+ if task["id"] == task_id:
486
+ if task.get("publisher") != current_user:
487
+ raise HTTPException(status_code=403, detail="只有发布者可以指派接单者")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
488
 
489
+ if task.get("status") != "open":
490
+ raise HTTPException(status_code=400, detail="该任务不在开放状态")
491
+
492
+ # 验证接单者是否在申请列表中
493
+ applicants = task.get("applicants", [])
494
+ if not any(a["account"] == assignee for a in applicants):
495
+ raise HTTPException(status_code=400, detail="该用户未申请此任务")
496
+
497
+ deposit = task.get("deposit_amount", 0)
498
+
499
+ # 💳 从冻结金额中扣除订金(订金暂时不给接单者,待任务完成后一起给)
500
+ wallet = db_session.query(Wallet).filter(Wallet.account == current_user).with_for_update().first()
501
+ if not wallet or wallet.frozen_balance < deposit:
502
+ raise HTTPException(status_code=400, detail="冻结余额异常")
503
+
504
+ # 订金从冻结金额中扣除(但还不给接单者,待任务完成后一起支付)
505
+ wallet.frozen_balance -= deposit
506
+
507
+ # 💳 记录订金支付交易
508
+ create_task_transaction(
509
+ db_session, current_user, "TASK_DEPOSIT",
510
+ -deposit, related_account=assignee, task_id=task_id
511
  )
512
 
513
+ # 更新任务状态
514
+ task["assignee"] = assignee
515
+ task["assigned_at"] = int(time.time())
516
+ task["status"] = "in_progress"
517
+ task["deposit_paid"] = deposit # 💳 记录支付订金
518
+
519
+ db.save_data("tasks.json", tasks_db)
520
+ db_session.commit()
521
+
522
+ # 🔔 通知接单者:被指派接单
523
+ add_notification(assignee, {
524
+ "type": "task_assigned",
525
+ "from_user": current_user,
526
+ "target_item_id": task_id,
527
+ "target_item_title": task.get("title", ""),
528
+ "content": f"您已被选为任务《{task.get('title', '')}》的接单者,订金{deposit}积分已缓冲"
529
+ })
530
+
531
+ logger.info(f"TASK_ASSIGN | publisher={current_user} | assignee={assignee} | task={task_id} | deposit={deposit}")
532
+
533
+ return {"status": "success", "message": f"已指派 {assignee} 接单,订金 {deposit} 积分已开始缓冲"}
534
 
535
+ raise HTTPException(status_code=404, detail="任务不存在")
 
536
 
537
  # ==========================================
538
+ # 📤 提交成果
539
  # ==========================================
540
+
541
+ @router.post("/api/tasks/{task_id}/submit")
542
+ async def submit_task(task_id: str, deliverables: list, note: str = None, current_user: str = Depends(require_auth)):
543
  """
544
+ 接单者提交成果
 
 
 
545
  """
546
  tasks_db = db.load_data("tasks.json", default_data=[])
547
 
 
548
  for task in tasks_db:
549
+ if task["id"] == task_id:
550
+ if task.get("assignee") != current_user:
551
+ raise HTTPException(status_code=403, detail="只有接单者可以提交成果")
552
+
553
+ if task.get("status") != "in_progress":
554
+ raise HTTPException(status_code=400, detail="任务状态不允许提交")
555
+
556
+ if not deliverables:
557
+ raise HTTPException(status_code=400, detail="请上传交付成果")
558
+
559
+ task["deliverables"] = deliverables
560
+ task["submit_note"] = note
561
+ task["submitted_at"] = int(time.time())
562
+ task["status"] = "submitted"
563
+
564
+ db.save_data("tasks.json", tasks_db)
565
+
566
+ # 🔔 通知发布者:接单者已提交成果
567
+ add_notification(task.get("publisher"), {
568
+ "type": "task_submitted",
569
+ "from_user": current_user,
570
+ "target_item_id": task_id,
571
+ "target_item_title": task.get("title", ""),
572
+ "content": f"已提交任务《{task.get('title', '')}》的成果,请验收"
573
  })
574
+
575
+ return {"status": "success", "message": "成果已提交,等待发布者验收"}
576
 
577
+ raise HTTPException(status_code=404, detail="任务不存在")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
578
 
579
  # ==========================================
580
+ # 验收成果
581
  # ==========================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
582
 
583
+ @router.post("/api/tasks/{task_id}/accept")
584
+ async def accept_task(task_id: str, is_accepted: bool, feedback: str = None, current_user: str = Depends(require_auth), db_session: Session = Depends(get_db)):
 
 
 
 
585
  """
586
+ 发布者验收成果
587
+ - is_accepted=True: 验收通过,支付尾款给接单者
588
+ - is_accepted=False: 验收不通过,可以要求修改或发起申诉
589
+ 💳 P6支付增强:使用SQL钱包支付,记录交易流水,发送支付通知
590
  """
591
  tasks_db = db.load_data("tasks.json", default_data=[])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
592
 
593
+ for task in tasks_db:
594
+ if task["id"] == task_id:
595
+ if task.get("publisher") != current_user:
596
+ raise HTTPException(status_code=403, detail="只有发布者可以验收")
597
+
598
+ if task.get("status") != "submitted":
599
+ raise HTTPException(status_code=400, detail="任务状态不允许验收")
600
+
601
+ task["feedback"] = feedback
602
+ assignee_account = task.get("assignee")
603
+ total_price = task.get("total_price", 0)
604
+ deposit = task.get("deposit_paid", task.get("deposit_amount", 0)) # 优先使用已记录的支付订金
605
+ remaining = total_price - deposit # 尾款
606
+
607
+ if is_accepted:
608
+ # 💳 验收通过:支付尾款给接单者
609
+ publisher_wallet = db_session.query(Wallet).filter(Wallet.account == current_user).with_for_update().first()
610
+ if not publisher_wallet or publisher_wallet.frozen_balance < remaining:
611
+ raise HTTPException(status_code=400, detail="冻结余额不足支付尾款")
612
+
613
+ # 扣除发布者尾款(从冻结余额)
614
+ publisher_wallet.frozen_balance -= remaining
615
+
616
+ # 给接单者全款(订金+尾款)
617
+ assignee_wallet = db_session.query(Wallet).filter(Wallet.account == assignee_account).with_for_update().first()
618
+ if not assignee_wallet:
619
+ assignee_wallet = Wallet(account=assignee_account, balance=0)
620
+ db_session.add(assignee_wallet)
621
+ db_session.flush()
622
+
623
+ assignee_wallet.balance += total_price # 全款进入可用余额
624
+
625
+ # 💳 记录交易流水
626
+ # 1. 发布者支付尾款
627
+ create_task_transaction(
628
+ db_session, current_user, "TASK_PAYMENT",
629
+ -remaining, related_account=assignee_account, task_id=task_id
630
+ )
631
+ # 2. 接单者收入
632
+ create_task_transaction(
633
+ db_session, assignee_account, "TASK_INCOME",
634
+ total_price, related_account=current_user, task_id=task_id
635
+ )
636
+
637
+ task["status"] = "completed"
638
+ task["completed_at"] = int(time.time())
639
+
640
+ db_session.commit()
641
+
642
+ logger.info(f"TASK_COMPLETE | publisher={current_user} | assignee={assignee_account} | task={task_id} | total={total_price}")
643
+ message = f"验收通过,已支付 {total_price} 积分给接单者"
644
+
645
+ # 🔔 支付通知:接单者收到款项
646
+ add_notification(assignee_account, {
647
+ "type": "task_payment",
648
+ "from_user": current_user,
649
+ "target_item_id": task_id,
650
+ "target_item_title": task.get("title", ""),
651
+ "content": f"💰 任务《{task.get('title', '')}》验收通过,{total_price}积分已到账"
652
+ })
653
+ else:
654
+ # 验收不通过:回到进行中状态,允许修改后重新提交
655
+ task["status"] = "in_progress"
656
+ task["deliverables"] = []
657
+ task["submitted_at"] = None
658
+ message = "验收不通过,接单者可以修改后重新提交"
659
+
660
+ # 🔔 通知接单者:验收未通过
661
+ add_notification(assignee_account, {
662
+ "type": "task_rejected",
663
+ "from_user": current_user,
664
+ "target_item_id": task_id,
665
+ "target_item_title": task.get("title", ""),
666
+ "content": f"任务《{task.get('title', '')}》验收未通过,请修改后重新提交"
667
+ })
668
+
669
+ db.save_data("tasks.json", tasks_db)
670
+
671
+ return {"status": "success", "message": message}
672
 
673
+ raise HTTPException(status_code=404, detail="任务不存在")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
674
 
675
  # ==========================================
676
+ # ⚖️ 申诉仲裁系统
677
  # ==========================================
678
+ import os
679
+ ADMIN_ACCOUNTS = [a.strip() for a in os.getenv("ADMIN_ACCOUNTS", "admin").split(",")]
680
+
681
+ @router.post("/api/tasks/{task_id}/dispute")
682
+ async def create_dispute(task_id: str, reason: str, evidence: list = None, current_user: str = Depends(require_auth)):
683
  """
684
+ 发起申诉(发布者或接单者均可)
685
+ - evidence: 证据图片URL列表(选)
 
 
 
 
686
  """
687
  tasks_db = db.load_data("tasks.json", default_data=[])
688
  disputes_db = db.load_data("disputes.json", default_data=[])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
689
 
690
+ for task in tasks_db:
691
+ if task["id"] == task_id:
692
+ # 验证权限
693
+ if current_user not in [task.get("publisher"), task.get("assignee")]:
694
+ raise HTTPException(status_code=403, detail="只有发布者或接单者可以发起申诉")
695
+
696
+ # 验证状态
697
+ if task.get("status") not in ["in_progress", "submitted"]:
698
+ raise HTTPException(status_code=400, detail="当前状态不允许申诉")
699
+
700
+ # 检查是否已有申诉
701
+ if task.get("dispute_id"):
702
+ raise HTTPException(status_code=400, detail="该任务已有进行中的申诉")
703
+
704
+ # 确定角色
705
+ is_publisher = current_user == task.get("publisher")
706
+ role = "publisher" if is_publisher else "assignee"
707
+
708
+ # 创建申诉
709
+ dispute = {
710
+ "id": f"dispute_{int(time.time())}_{uuid.uuid4().hex[:6]}",
711
+ "task_id": task_id,
712
+ "task_title": task.get("title", ""),
713
+ "publisher": task.get("publisher"),
714
+ "assignee": task.get("assignee"),
715
+ "initiator": current_user,
716
+ "initiator_role": role,
717
+ "reason": reason,
718
+ "evidence": (evidence or [])[:6],
719
+ "status": "pending", # pending/responded/resolved
720
+ "created_at": int(time.time()),
721
+ # 被申诉方回应
722
+ "response": None,
723
+ "response_evidence": [],
724
+ "responded_at": None,
725
+ # 仲裁结果
726
+ "resolution": None, # favor_initiator/favor_respondent/split
727
+ "resolution_ratio": None, # 分成比例(如果是split)
728
+ "resolution_note": None,
729
+ "resolved_by": None,
730
+ "resolved_at": None
731
+ }
732
+
733
+ disputes_db.append(dispute)
734
+ task["status"] = "disputed"
735
+ task["dispute_id"] = dispute["id"]
736
+
737
+ db.save_data("tasks.json", tasks_db)
738
+ db.save_data("disputes.json", disputes_db)
739
+
740
+ # 🔔 通知对方:已发起申诉
741
+ other_party = task.get("assignee") if is_publisher else task.get("publisher")
742
+ add_notification(other_party, {
743
+ "type": "task_disputed",
744
+ "from_user": current_user,
745
+ "target_item_id": task_id,
746
+ "target_item_title": task.get("title", ""),
747
+ "content": f"对任务《{task.get('title', '')}》发起了申诉,请回应"
748
+ })
749
+
750
+ return {"status": "success", "data": dispute, "message": "申诉已提交,等待对方回应"}
 
 
 
751
 
752
+ raise HTTPException(status_code=404, detail="任务不存在")
753
 
754
+ @router.get("/api/disputes/{dispute_id}")
755
+ async def get_dispute_detail(dispute_id: str):
 
 
 
 
756
  """
757
+ 获取申诉详情
758
  """
759
  disputes_db = db.load_data("disputes.json", default_data=[])
760
+ users_db = db.load_data("users.json", default_data=[])
761
 
762
+ user_map = {u["account"]: u for u in users_db}
 
 
 
 
 
763
 
764
+ for dispute in disputes_db:
765
+ if dispute["id"] == dispute_id:
766
+ publisher_info = user_map.get(dispute.get("publisher"), {})
767
+ assignee_info = user_map.get(dispute.get("assignee"), {})
768
+
769
+ return {
770
+ "status": "success",
771
+ "data": {
772
+ **dispute,
773
+ "publisher_name": publisher_info.get("name", dispute.get("publisher")),
774
+ "publisher_avatar": publisher_info.get("avatar", ""),
775
+ "assignee_name": assignee_info.get("name", dispute.get("assignee")),
776
+ "assignee_avatar": assignee_info.get("avatar", "")
777
+ }
778
+ }
 
 
 
 
 
 
 
 
 
779
 
780
+ raise HTTPException(status_code=404, detail="申诉不存在")
 
781
 
782
+ @router.post("/api/disputes/{dispute_id}/respond")
783
+ async def respond_dispute(dispute_id: str, response: str, evidence: list = None, current_user: str = Depends(require_auth)):
 
 
 
784
  """
785
+ 申诉方回应申诉
 
 
 
 
 
786
  """
 
 
 
 
787
  disputes_db = db.load_data("disputes.json", default_data=[])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
788
 
789
+ for dispute in disputes_db:
790
+ if dispute["id"] == dispute_id:
791
+ # 验���权限:必须是被申诉方
792
+ is_publisher = dispute.get("initiator_role") == "publisher"
793
+ respondent = dispute.get("assignee") if is_publisher else dispute.get("publisher")
794
+
795
+ if current_user != respondent:
796
+ raise HTTPException(status_code=403, detail="只有被申诉方可以回应")
797
+
798
+ if dispute.get("status") != "pending":
799
+ raise HTTPException(status_code=400, detail="该申诉已回应或已解决")
800
+
801
+ dispute["response"] = response
802
+ dispute["response_evidence"] = (evidence or [])[:6]
803
+ dispute["responded_at"] = int(time.time())
804
+ dispute["status"] = "responded"
805
+
806
+ db.save_data("disputes.json", disputes_db)
807
+
808
+ # 🔔 通知申诉方:对方已回应
809
+ add_notification(dispute.get("initiator"), {
810
+ "type": "dispute_responded",
811
+ "from_user": current_user,
812
+ "target_item_id": dispute.get("task_id"),
813
+ "target_item_title": dispute.get("task_title", ""),
814
+ "content": f"对方已回应您关于任务《{dispute.get('task_title', '')}》的申诉"
815
+ })
816
+
817
+ return {"status": "success", "message": "回应已提交等待管理员仲裁"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
818
 
819
+ raise HTTPException(status_code=404, detail="申诉不存在")
 
820
 
821
+ @router.get("/api/admin/disputes")
822
+ async def get_admin_disputes(status: str = None, current_user: str = Depends(require_auth)):
823
  """
824
+ 管理员获取申诉列表
825
+ - status: pending/responded/resolved
826
  """
827
+ if current_user not in ADMIN_ACCOUNTS:
828
+ raise HTTPException(status_code=403, detail="需要管理员权限")
829
 
830
+ disputes_db = db.load_data("disputes.json", default_data=[])
831
+ users_db = db.load_data("users.json", default_data=[])
832
+
833
+ user_map = {u["account"]: u for u in users_db}
834
+
835
+ # 筛选
836
+ filtered = disputes_db
837
+ if status:
838
+ filtered = [d for d in filtered if d.get("status") == status]
839
+
840
+ # 按时间倒序
841
+ filtered = sorted(filtered, key=lambda x: x.get("created_at", 0), reverse=True)
842
+
843
+ # 附加用户信息
844
+ result = []
845
+ for dispute in filtered:
846
+ publisher_info = user_map.get(dispute.get("publisher"), {})
847
+ assignee_info = user_map.get(dispute.get("assignee"), {})
848
+ result.append({
849
+ **dispute,
850
+ "publisher_name": publisher_info.get("name", dispute.get("publisher")),
851
+ "assignee_name": assignee_info.get("name", dispute.get("assignee"))
852
+ })
853
+
854
+ return {"status": "success", "data": result}
855
 
856
+ @router.post("/api/admin/disputes/{dispute_id}/resolve")
857
+ async def resolve_dispute(dispute_id: str, resolution: str, ratio: int = None, note: str = None, current_user: str = Depends(require_auth)):
858
  """
859
+ 管理员裁决申诉
860
+ - resolution: favor_initiator(支持申诉方) / favor_respondent(支持被申诉方) / split(按比例分成)
861
+ - ratio: 分成比例(0-100),表示申诉方获得的比例。仅split时有效
862
+ - note: 裁决说明
863
  """
864
+ if current_user not in ADMIN_ACCOUNTS:
865
+ raise HTTPException(status_code=403, detail="需要管理员权限")
 
 
 
 
 
 
 
 
 
866
 
867
+ if resolution not in ["favor_initiator", "favor_respondent", "split"]:
868
+ raise HTTPException(status_code=400, detail="无效的裁决结果")
869
 
870
+ if resolution == "split" and (ratio is None or ratio < 0 or ratio > 100):
871
+ raise HTTPException(status_code=400, detail="分成比例必须在0-100之间")
872
 
 
 
 
 
 
 
 
 
 
 
 
 
873
  disputes_db = db.load_data("disputes.json", default_data=[])
874
+ tasks_db = db.load_data("tasks.json", default_data=[])
875
+ users_db = db.load_data("users.json", default_data=[])
876
 
877
+ for dispute in disputes_db:
878
+ if dispute["id"] == dispute_id:
879
+ if dispute.get("status") == "resolved":
880
+ raise HTTPException(status_code=400, detail="该申诉已裁决")
881
+
882
+ # 查找关联任务
883
+ task = next((t for t in tasks_db if t["id"] == dispute.get("task_id")), None)
884
+ if not task:
885
+ raise HTTPException(status_code=404, detail="关联任务不存在")
886
+
887
+ total_price = task.get("total_price", 0)
888
+ deposit = task.get("deposit_amount", 0)
889
+
890
+ publisher = next((u for u in users_db if u["account"] == dispute.get("publisher")), None)
891
+ assignee = next((u for u in users_db if u["account"] == dispute.get("assignee")), None)
892
+
893
+ is_publisher_initiator = dispute.get("initiator_role") == "publisher"
894
+ initiator = publisher if is_publisher_initiator else assignee
895
+ respondent = assignee if is_publisher_initiator else publisher
896
+
897
+ # 计算资金分配
898
+ if resolution == "favor_initiator":
899
+ # 支持申诉方
900
+ if is_publisher_initiator:
901
+ # 发布者胜诉:退还订金
902
+ if publisher:
903
+ publisher["balance"] = publisher.get("balance", 0) + deposit
904
+ initiator_amount = deposit
905
+ respondent_amount = 0
906
+ else:
907
+ # 接单者胜诉:获得全款
908
+ if publisher:
909
+ remaining = total_price - deposit
910
+ publisher["balance"] = publisher.get("balance", 0) - remaining
911
+ if assignee:
912
+ assignee["balance"] = assignee.get("balance", 0) + total_price
913
+ initiator_amount = total_price
914
+ respondent_amount = 0
915
+ elif resolution == "favor_respondent":
916
+ # 支持被申诉方
917
+ if is_publisher_initiator:
918
+ # 接单者胜诉:获得全款
919
+ if publisher:
920
+ remaining = total_price - deposit
921
+ publisher["balance"] = publisher.get("balance", 0) - remaining
922
+ if assignee:
923
+ assignee["balance"] = assignee.get("balance", 0) + total_price
924
+ initiator_amount = 0
925
+ respondent_amount = total_price
926
+ else:
927
+ # 发布者胜诉:退还订金
928
+ if publisher:
929
+ publisher["balance"] = publisher.get("balance", 0) + deposit
930
+ initiator_amount = 0
931
+ respondent_amount = deposit
932
+ else:
933
+ # 按比例分成
934
+ initiator_share = int(total_price * ratio / 100)
935
+ respondent_share = total_price - initiator_share
936
+
937
+ # 处理资金:发布者支付尾款,然后按比例分配
938
+ if publisher:
939
+ remaining = total_price - deposit
940
+ publisher["balance"] = publisher.get("balance", 0) - remaining
941
+
942
+ # 发布者获得退款部分,接单者获得报酬部分
943
+ if is_publisher_initiator:
944
+ # 申诉方是发布者
945
+ pub_refund = initiator_share
946
+ assignee_earn = respondent_share
947
+ else:
948
+ # 申诉方是接单者
949
+ assignee_earn = initiator_share
950
+ pub_refund = respondent_share
951
+
952
+ if publisher:
953
+ publisher["balance"] = publisher.get("balance", 0) + pub_refund
954
+ if assignee:
955
+ assignee["balance"] = assignee.get("balance", 0) + assignee_earn
956
+
957
+ initiator_amount = initiator_share
958
+ respondent_amount = respondent_share
959
+
960
+ # 更新申诉状态
961
+ dispute["resolution"] = resolution
962
+ dispute["resolution_ratio"] = ratio
963
+ dispute["resolution_note"] = note
964
+ dispute["resolved_by"] = current_user
965
+ dispute["resolved_at"] = int(time.time())
966
+ dispute["status"] = "resolved"
967
+
968
+ # 更新任务状态
969
+ task["status"] = "completed"
970
+ task["completed_at"] = int(time.time())
971
+ task["feedback"] = f"申诉裁决:{note or '无'}(结果:{resolution})"
972
+
973
+ db.save_data("disputes.json", disputes_db)
974
+ db.save_data("tasks.json", tasks_db)
975
+ db.save_data("users.json", users_db)
976
+
977
+ # 🔔 通知双方:仲裁结果
978
+ resolution_text = {
979
+ "favor_initiator": "支持申诉方",
980
+ "favor_respondent": "支持被申诉方",
981
+ "split": f"双方协商分成({ratio}%:{100-ratio}%)"
982
+ }.get(resolution, "已裁决")
983
+
984
+ # 通知申诉方
985
+ add_notification(dispute.get("initiator"), {
986
+ "type": "dispute_resolved",
987
+ "from_user": current_user,
988
+ "target_item_id": dispute.get("task_id"),
989
+ "target_item_title": dispute.get("task_title", ""),
990
+ "content": f"您的申诉已裁决:{resolution_text},您获得{initiator_amount}积分"
991
+ })
992
+
993
+ # 通知被申诉方
994
+ respondent_account = dispute.get("assignee") if is_publisher_initiator else dispute.get("publisher")
995
+ add_notification(respondent_account, {
996
+ "type": "dispute_resolved",
997
+ "from_user": current_user,
998
+ "target_item_id": dispute.get("task_id"),
999
+ "target_item_title": dispute.get("task_title", ""),
1000
+ "content": f"任务申诉已裁决:{resolution_text},您获得{respondent_amount}积分"
1001
+ })
1002
+
1003
+ return {"status": "success", "message": f"裁决完成:{resolution_text}"}
1004
 
1005
+ raise HTTPException(status_code=404, detail="申诉不存在")
 
 
 
 
 
 
 
1006
 
1007
  # ==========================================
1008
+ # 📊 我的任务
1009
  # ==========================================
 
 
 
 
 
 
 
 
 
 
 
 
1010
 
1011
+ @router.get("/api/my-tasks")
1012
+ async def get_my_tasks(role: str = "publisher", current_user: str = Depends(require_auth)):
 
 
 
 
1013
  """
1014
+ 获取任务
1015
+ - role=publisher: 我发布的任务
1016
+ - role=assignee: 我接的任务
1017
  """
1018
+ tasks_db = db.load_data("tasks.json", default_data=[])
1019
 
1020
+ if role == "publisher":
1021
+ my_tasks = [t for t in tasks_db if t.get("publisher") == current_user]
1022
+ else:
1023
+ my_tasks = [t for t in tasks_db if t.get("assignee") == current_user]
1024
 
1025
+ # 按创建时间倒序
1026
+ my_tasks = sorted(my_tasks, key=lambda x: x.get("created_at", 0), reverse=True)
1027
 
1028
+ return {"status": "success", "data": my_tasks}
router_users_auth.py CHANGED
@@ -133,7 +133,8 @@ async def send_code_api(req: SendCodeRequest):
133
  # - 数据库连接.py (保存新用户到 users.json)
134
  # - 前端 注册表单组件.js
135
  @router.post("/api/users/register")
136
- async def register_user(user: UserRegister):
 
137
  """
138
  用户注册接口
139
 
@@ -273,6 +274,7 @@ async def login_user(request: Request, user: UserLogin):
273
  # - 前端 重置密码表单组件.js
274
  # 特点:万能解析器,兼容各种前端数据格式
275
  @router.post("/api/users/reset_password")
 
276
  async def reset_password(request: Request):
277
  """
278
  重置密码接口(万能兼容版)
@@ -289,12 +291,12 @@ async def reset_password(request: Request):
289
  # 处理前端可能造成的"双重字符串化"问题
290
  if isinstance(data, str):
291
  data = json.loads(data)
292
- except:
293
  # 降级尝试 FormData 格式
294
  try:
295
  form = await request.form()
296
  data = dict(form)
297
- except:
298
  raise HTTPException(status_code=400, detail="请求数据解析失败,请检查网络")
299
 
300
  if not isinstance(data, dict):
 
133
  # - 数据库连接.py (保存新用户到 users.json)
134
  # - 前端 注册表单组件.js
135
  @router.post("/api/users/register")
136
+ @limiter.limit("3/minute") # 🔒 P0安全优化:注册每分钟最多3次
137
+ async def register_user(request: Request, user: UserRegister):
138
  """
139
  用户注册接口
140
 
 
274
  # - 前端 重置密码表单组件.js
275
  # 特点:万能解析器,兼容各种前端数据格式
276
  @router.post("/api/users/reset_password")
277
+ @limiter.limit("3/minute") # 🔒 P0安全优化:重置密码每分钟最多3次
278
  async def reset_password(request: Request):
279
  """
280
  重置密码接口(万能兼容版)
 
291
  # 处理前端可能造成的"双重字符串化"问题
292
  if isinstance(data, str):
293
  data = json.loads(data)
294
+ except Exception:
295
  # 降级尝试 FormData 格式
296
  try:
297
  form = await request.form()
298
  data = dict(form)
299
+ except Exception:
300
  raise HTTPException(status_code=400, detail="请求数据解析失败,请检查网络")
301
 
302
  if not isinstance(data, dict):
router_wallet.py CHANGED
@@ -7,6 +7,7 @@
7
  # - verify_code_engine.py (提现验证码缓存)
8
  # - database_sql.py (SQL数据库连接)
9
  # - models_sql.py (Wallet, Transaction, Ownership 模型)
 
10
  # ==========================================
11
 
12
  from fastapi import APIRouter, Depends, HTTPException, Request
@@ -19,10 +20,20 @@ import os
19
  import datetime
20
  import logging
21
  from database_sql import get_db
22
- from models_sql import Wallet, Transaction, Ownership
23
  from models import RechargeRequest, WithdrawRequest, PurchaseRequest, TipRequest
24
  import 数据库连接 as json_db
25
 
 
 
 
 
 
 
 
 
 
 
26
  # 📝 P2优化:审计日志
27
  logger = logging.getLogger("ComfyUI-Ranking.Wallet")
28
 
@@ -122,9 +133,13 @@ async def check_order(order_id: str, db: Session = Depends(get_db)):
122
  async def get_wallet(account: str, db: Session = Depends(get_db)):
123
  wallet = db.query(Wallet).filter(Wallet.account == account).first()
124
 
125
- # 🚀 新增计算历史累计提现总积分 (于前端 100元免责额度的手续费计算)
126
- withdrawals = db.query(Transaction).filter(Transaction.account == account, Transaction.tx_type == 'WITHDRAW').all()
127
- total_withdrawn = sum(w.amount for w in withdrawals)
 
 
 
 
128
 
129
  if not wallet:
130
  return {"status": "success", "balance": 0, "earn_balance": 0, "tip_balance": 0, "frozen_balance": 0, "total_withdrawn": total_withdrawn}
@@ -139,22 +154,65 @@ async def get_wallet(account: str, db: Session = Depends(get_db)):
139
  }
140
 
141
  @router.post("/api/wallet/purchase")
142
- async def purchase_item(req: PurchaseRequest, db: Session = Depends(get_db)):
 
143
  items_db = json_db.load_data("items.json", default_data=[])
144
  item = next((i for i in items_db if i["id"] == req.item_id), None)
145
 
146
  if not item:
147
  raise HTTPException(status_code=404, detail="商品不存在")
148
-
149
- price = int(item.get("price", 0))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  seller_account = item.get("author")
151
 
152
  if price <= 0 or req.account == seller_account:
153
- return {"status": "success", "already_owned": True}
154
-
155
- owned = db.query(Ownership).filter(Ownership.account == req.account, Ownership.item_id == req.item_id).first()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  if owned:
157
- return {"status": "success", "already_owned": True}
 
 
 
 
 
 
158
 
159
  buyer_wallet = db.query(Wallet).filter(Wallet.account == req.account).with_for_update().first()
160
  if not buyer_wallet or buyer_wallet.balance < price:
@@ -168,7 +226,8 @@ async def purchase_item(req: PurchaseRequest, db: Session = Depends(get_db)):
168
  buyer_wallet.balance -= price
169
  seller_wallet.earn_balance += price
170
 
171
- new_ownership = Ownership(account=req.account, item_id=req.item_id)
 
172
  db.add(new_ownership)
173
 
174
  tx_id = f"BUY_{int(time.time())}_{uuid.uuid4().hex[:6]}"
@@ -179,7 +238,7 @@ async def purchase_item(req: PurchaseRequest, db: Session = Depends(get_db)):
179
  # 创建交易记录 (字段名为 related_account,与 models_sql.py 中 Transaction 模型保持一致)
180
  new_tx = Transaction(
181
  tx_id=tx_id, account=req.account, tx_type="PURCHASE", amount=-price,
182
- related_account=seller_account, prev_hash=prev_hash, tx_hash=tx_hash
183
  )
184
  db.add(new_tx)
185
  db.commit()
@@ -187,10 +246,17 @@ async def purchase_item(req: PurchaseRequest, db: Session = Depends(get_db)):
187
  # 📝 P2优化:购买审计日志
188
  logger.info(f"PURCHASE | buyer={req.account} | seller={seller_account} | item={req.item_id} | amount={price} | tx={tx_id}")
189
 
190
- return {"status": "success", "already_owned": False}
 
 
 
 
 
 
191
 
192
  @router.post("/api/wallet/tip")
193
- async def tip_user(req: TipRequest, db: Session = Depends(get_db)):
 
194
  if req.amount <= 0:
195
  raise HTTPException(status_code=400, detail="打赏金额必须大于0")
196
  if req.sender_account == req.target_account:
@@ -274,7 +340,8 @@ async def tip_user(req: TipRequest, db: Session = Depends(get_db)):
274
  return {"status": "success", "balance": sender_wallet.balance}
275
 
276
  @router.post("/api/wallet/withdraw")
277
- async def withdraw(req: WithdrawRequest, db: Session = Depends(get_db)):
 
278
  key = f"{req.account}_withdraw"
279
  code_data = VERIFY_CODES.get(key)
280
  # 🔒 P0安全修复:统一使用 expires_at 字段,兼容旧版 expires
@@ -288,11 +355,13 @@ async def withdraw(req: WithdrawRequest, db: Session = Depends(get_db)):
288
 
289
  # 🚀 核心新增:阶梯手续费计算 (与前端逻辑统一)
290
  # 查询历史累计提现总额 (WITHDRAW 类型的 amount 是负数,需要取绝对值)
291
- withdrawals = db.query(Transaction).filter(
 
 
292
  Transaction.account == req.account,
293
  Transaction.tx_type == 'WITHDRAW'
294
- ).all()
295
- total_withdrawn = abs(sum(w.amount for w in withdrawals))
296
 
297
  # 手续费规则:100元 = 10000积分 免手续费额度,超出部分收取 10%
298
  free_quota = max(0, 10000 - total_withdrawn) # 剩余免责额度
@@ -349,4 +418,242 @@ async def withdraw(req: WithdrawRequest, db: Session = Depends(get_db)):
349
  "fee_amount": fee_amount,
350
  "net_amount": net_amount,
351
  "free_quota_used": min(req.amount, free_quota + total_withdrawn) - total_withdrawn # 本次消耗的免责额度
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
  }
 
7
  # - verify_code_engine.py (提现验证码缓存)
8
  # - database_sql.py (SQL数据库连接)
9
  # - models_sql.py (Wallet, Transaction, Ownership 模型)
10
+ # 🔒 P0安全优化:API限流
11
  # ==========================================
12
 
13
  from fastapi import APIRouter, Depends, HTTPException, Request
 
20
  import datetime
21
  import logging
22
  from database_sql import get_db
23
+ from models_sql import Wallet, Transaction, Ownership, Refund
24
  from models import RechargeRequest, WithdrawRequest, PurchaseRequest, TipRequest
25
  import 数据库连接 as json_db
26
 
27
+ # 🔒 P0安全优化:API限流
28
+ from slowapi import Limiter
29
+ from slowapi.util import get_remote_address
30
+ limiter = Limiter(key_func=get_remote_address)
31
+
32
+ # 🔄 P7后悔模式:24小时退款窗口
33
+ REFUND_WINDOW_HOURS = 24
34
+ # 🔄 P7后悔模式:退款后30天禁购
35
+ REFUND_BAN_DAYS = 30
36
+
37
  # 📝 P2优化:审计日志
38
  logger = logging.getLogger("ComfyUI-Ranking.Wallet")
39
 
 
133
  async def get_wallet(account: str, db: Session = Depends(get_db)):
134
  wallet = db.query(Wallet).filter(Wallet.account == account).first()
135
 
136
+ # 🚀 P1性能优化使聚合函数代替 .all() + sum
137
+ from sqlalchemy import func
138
+ total_withdrawn = db.query(func.coalesce(func.sum(Transaction.amount), 0)).filter(
139
+ Transaction.account == account,
140
+ Transaction.tx_type == 'WITHDRAW'
141
+ ).scalar() or 0
142
+ total_withdrawn = abs(total_withdrawn) # 提现金额是负数
143
 
144
  if not wallet:
145
  return {"status": "success", "balance": 0, "earn_balance": 0, "tip_balance": 0, "frozen_balance": 0, "total_withdrawn": total_withdrawn}
 
154
  }
155
 
156
  @router.post("/api/wallet/purchase")
157
+ @limiter.limit("10/minute") # 🔒 P0安全优化:购买每分钟最多10次
158
+ async def purchase_item(request: Request, req: PurchaseRequest, db: Session = Depends(get_db)):
159
  items_db = json_db.load_data("items.json", default_data=[])
160
  item = next((i for i in items_db if i["id"] == req.item_id), None)
161
 
162
  if not item:
163
  raise HTTPException(status_code=404, detail="商品不存在")
164
+
165
+ # 🔄 P7后悔模式:检查价格是否延迟生效
166
+ actual_price = item.get("price", 0)
167
+ pending_price = item.get("pending_price")
168
+ pending_price_effective = item.get("pending_price_effective_at")
169
+ if pending_price is not None and pending_price_effective:
170
+ # 检查是否已过生效时间
171
+ effective_time = datetime.datetime.fromisoformat(pending_price_effective)
172
+ if datetime.datetime.now() >= effective_time:
173
+ actual_price = pending_price
174
+ # 更新实际价格,清除待生效价格
175
+ item["price"] = pending_price
176
+ item["pending_price"] = None
177
+ item["pending_price_effective_at"] = None
178
+ json_db.save_data("items.json", items_db)
179
+
180
+ price = int(actual_price)
181
  seller_account = item.get("author")
182
 
183
  if price <= 0 or req.account == seller_account:
184
+ # ☁️ 免费资源或作者本人,也返回网盘密码
185
+ return {
186
+ "status": "success",
187
+ "already_owned": True,
188
+ "netdisk_password": item.get("netdisk_password"), # ☁️
189
+ "is_netdisk": item.get("is_netdisk", False) # ☁️
190
+ }
191
+
192
+ # 🔄 P7后悔模式:检查30天禁购
193
+ refund_ban = db.query(Refund).filter(
194
+ Refund.account == req.account,
195
+ Refund.item_id == req.item_id,
196
+ Refund.ban_until > datetime.datetime.utcnow()
197
+ ).first()
198
+ if refund_ban:
199
+ days_left = (refund_ban.ban_until - datetime.datetime.utcnow()).days + 1
200
+ raise HTTPException(status_code=403, detail=f"您已退款过此商品,{days_left}天内禁止再次购买")
201
+
202
+ # 检查是否已拥有(排除已退款的记录)
203
+ owned = db.query(Ownership).filter(
204
+ Ownership.account == req.account,
205
+ Ownership.item_id == req.item_id,
206
+ Ownership.is_refunded == False
207
+ ).first()
208
  if owned:
209
+ # ☁️ 已购买用户,返回���盘密码
210
+ return {
211
+ "status": "success",
212
+ "already_owned": True,
213
+ "netdisk_password": item.get("netdisk_password"), # ☁️
214
+ "is_netdisk": item.get("is_netdisk", False) # ☁️
215
+ }
216
 
217
  buyer_wallet = db.query(Wallet).filter(Wallet.account == req.account).with_for_update().first()
218
  if not buyer_wallet or buyer_wallet.balance < price:
 
226
  buyer_wallet.balance -= price
227
  seller_wallet.earn_balance += price
228
 
229
+ # 🔄 P7后悔模式:记录购买价格
230
+ new_ownership = Ownership(account=req.account, item_id=req.item_id, price_paid=price)
231
  db.add(new_ownership)
232
 
233
  tx_id = f"BUY_{int(time.time())}_{uuid.uuid4().hex[:6]}"
 
238
  # 创建交易记录 (字段名为 related_account,与 models_sql.py 中 Transaction 模型保持一致)
239
  new_tx = Transaction(
240
  tx_id=tx_id, account=req.account, tx_type="PURCHASE", amount=-price,
241
+ related_account=seller_account, item_id=req.item_id, prev_hash=prev_hash, tx_hash=tx_hash
242
  )
243
  db.add(new_tx)
244
  db.commit()
 
246
  # 📝 P2优化:购买审计日志
247
  logger.info(f"PURCHASE | buyer={req.account} | seller={seller_account} | item={req.item_id} | amount={price} | tx={tx_id}")
248
 
249
+ # ☁️ 购买成功后返回网盘密码
250
+ return {
251
+ "status": "success",
252
+ "already_owned": False,
253
+ "netdisk_password": item.get("netdisk_password"), # ☁️ 只有购买成功才返回
254
+ "is_netdisk": item.get("is_netdisk", False) # ☁️
255
+ }
256
 
257
  @router.post("/api/wallet/tip")
258
+ @limiter.limit("20/minute") # 🔒 P0安全优化:打赏每分钟最多20次
259
+ async def tip_user(request: Request, req: TipRequest, db: Session = Depends(get_db)):
260
  if req.amount <= 0:
261
  raise HTTPException(status_code=400, detail="打赏金额必须大于0")
262
  if req.sender_account == req.target_account:
 
340
  return {"status": "success", "balance": sender_wallet.balance}
341
 
342
  @router.post("/api/wallet/withdraw")
343
+ @limiter.limit("3/minute") # 🔒 P0安全优化:提现每分钟最多3次
344
+ async def withdraw(request: Request, req: WithdrawRequest, db: Session = Depends(get_db)):
345
  key = f"{req.account}_withdraw"
346
  code_data = VERIFY_CODES.get(key)
347
  # 🔒 P0安全修复:统一使用 expires_at 字段,兼容旧版 expires
 
355
 
356
  # 🚀 核心新增:阶梯手续费计算 (与前端逻辑统一)
357
  # 查询历史累计提现总额 (WITHDRAW 类型的 amount 是负数,需要取绝对值)
358
+ # 🚀 P1性能优化:使用聚合函数代替 .all() + sum
359
+ from sqlalchemy import func as sql_func
360
+ withdrawals_sum = db.query(sql_func.coalesce(sql_func.sum(Transaction.amount), 0)).filter(
361
  Transaction.account == req.account,
362
  Transaction.tx_type == 'WITHDRAW'
363
+ ).scalar() or 0
364
+ total_withdrawn = abs(withdrawals_sum)
365
 
366
  # 手续费规则:100元 = 10000积分 免手续费额度,超出部分收取 10%
367
  free_quota = max(0, 10000 - total_withdrawn) # 剩余免责额度
 
418
  "fee_amount": fee_amount,
419
  "net_amount": net_amount,
420
  "free_quota_used": min(req.amount, free_quota + total_withdrawn) - total_withdrawn # 本次消耗的免责额度
421
+ }
422
+
423
+ # ==========================================
424
+ # 💳 P6支付增强:交易明细查询API
425
+ # ==========================================
426
+
427
+ @router.get("/api/wallet/{account}/transactions")
428
+ async def get_transactions(
429
+ account: str,
430
+ page: int = 1,
431
+ limit: int = 20,
432
+ tx_type: str = None,
433
+ db: Session = Depends(get_db)
434
+ ):
435
+ """
436
+ 获取用户交易明细(分页)
437
+ - tx_type: 可选筛选(RECHARGE/PURCHASE/TIP_OUT/TIP_IN/WITHDRAW/TASK_FREEZE/TASK_DEPOSIT/TASK_PAYMENT/TASK_INCOME/TASK_REFUND)
438
+ """
439
+ query = db.query(Transaction).filter(Transaction.account == account)
440
+
441
+ if tx_type:
442
+ query = query.filter(Transaction.tx_type == tx_type)
443
+
444
+ total = query.count()
445
+ transactions = query.order_by(Transaction.created_at.desc()).offset((page - 1) * limit).limit(limit).all()
446
+
447
+ # 格式化输出
448
+ tx_list = []
449
+ for tx in transactions:
450
+ tx_list.append({
451
+ "tx_id": tx.tx_id,
452
+ "tx_type": tx.tx_type,
453
+ "amount": tx.amount,
454
+ "related_account": tx.related_account,
455
+ "item_id": tx.item_id,
456
+ "created_at": tx.created_at.isoformat() if tx.created_at else None
457
+ })
458
+
459
+ return {
460
+ "status": "success",
461
+ "data": tx_list,
462
+ "total": total,
463
+ "page": page,
464
+ "limit": limit
465
+ }
466
+
467
+ @router.get("/api/wallet/{account}/task-stats")
468
+ async def get_task_stats(account: str, db: Session = Depends(get_db)):
469
+ """
470
+ 📊 获取用户任务收益统计
471
+ 🚀 P1性能优化:使用单次查询+分组聚合替代多次查询
472
+ """
473
+ from sqlalchemy import func as sql_func, case
474
+
475
+ # 🚀 P1性能优化:使用分组聚合一次查询多种类型的统计
476
+ stats = db.query(
477
+ Transaction.tx_type,
478
+ sql_func.count(Transaction.tx_id).label('count'),
479
+ sql_func.coalesce(sql_func.sum(Transaction.amount), 0).label('total')
480
+ ).filter(
481
+ Transaction.account == account,
482
+ Transaction.tx_type.in_(["TASK_INCOME", "TASK_FREEZE", "TASK_DEPOSIT", "TASK_PAYMENT", "TASK_REFUND"])
483
+ ).group_by(Transaction.tx_type).all()
484
+
485
+ # 解析统计结果
486
+ stats_map = {s.tx_type: {'count': s.count, 'total': s.total} for s in stats}
487
+
488
+ total_income = stats_map.get('TASK_INCOME', {}).get('total', 0) or 0
489
+ income_count = stats_map.get('TASK_INCOME', {}).get('count', 0) or 0
490
+
491
+ # 任务支出(发布任务的支付)
492
+ total_payment = abs(
493
+ (stats_map.get('TASK_FREEZE', {}).get('total', 0) or 0) +
494
+ (stats_map.get('TASK_DEPOSIT', {}).get('total', 0) or 0) +
495
+ (stats_map.get('TASK_PAYMENT', {}).get('total', 0) or 0)
496
+ )
497
+ payment_count = (
498
+ (stats_map.get('TASK_FREEZE', {}).get('count', 0) or 0) +
499
+ (stats_map.get('TASK_DEPOSIT', {}).get('count', 0) or 0) +
500
+ (stats_map.get('TASK_PAYMENT', {}).get('count', 0) or 0)
501
+ )
502
+
503
+ total_refund = stats_map.get('TASK_REFUND', {}).get('total', 0) or 0
504
+
505
+ # 最近交易(任务相关)
506
+ recent_txs = db.query(Transaction).filter(
507
+ Transaction.account == account,
508
+ Transaction.tx_type.in_(["TASK_INCOME", "TASK_PAYMENT", "TASK_DEPOSIT", "TASK_FREEZE", "TASK_REFUND"])
509
+ ).order_by(Transaction.created_at.desc()).limit(10).all()
510
+
511
+ recent_list = [{
512
+ "tx_id": tx.tx_id,
513
+ "tx_type": tx.tx_type,
514
+ "amount": tx.amount,
515
+ "item_id": tx.item_id,
516
+ "created_at": tx.created_at.isoformat() if tx.created_at else None
517
+ } for tx in recent_txs]
518
+
519
+ return {
520
+ "status": "success",
521
+ "data": {
522
+ "total_income": total_income,
523
+ "income_count": income_count,
524
+ "total_payment": total_payment,
525
+ "payment_count": payment_count,
526
+ "total_refund": total_refund,
527
+ "net_earnings": total_income - total_payment + total_refund,
528
+ "recent_transactions": recent_list
529
+ }
530
+ }
531
+
532
+ # ==========================================
533
+ # 🔄 P7后悔模式:退款API
534
+ # ==========================================
535
+
536
+ @router.get("/api/wallet/{account}/purchase/{item_id}")
537
+ async def get_purchase_status(account: str, item_id: str, db: Session = Depends(get_db)):
538
+ """
539
+ 获取购买状态(用于判断是否可退款)
540
+ """
541
+ ownership = db.query(Ownership).filter(
542
+ Ownership.account == account,
543
+ Ownership.item_id == item_id,
544
+ Ownership.is_refunded == False
545
+ ).first()
546
+
547
+ if not ownership:
548
+ return {"status": "success", "owned": False}
549
+
550
+ # 计算是否在退款窗口内
551
+ purchased_at = ownership.purchased_at
552
+ now = datetime.datetime.utcnow()
553
+ refund_deadline = purchased_at + datetime.timedelta(hours=REFUND_WINDOW_HOURS)
554
+ can_refund = now < refund_deadline
555
+ hours_left = max(0, (refund_deadline - now).total_seconds() / 3600) if can_refund else 0
556
+
557
+ return {
558
+ "status": "success",
559
+ "owned": True,
560
+ "purchased_at": purchased_at.isoformat(),
561
+ "price_paid": ownership.price_paid,
562
+ "can_refund": can_refund,
563
+ "refund_hours_left": round(hours_left, 1)
564
+ }
565
+
566
+ @router.post("/api/wallet/refund")
567
+ @limiter.limit("3/minute") # 🔒 P0安全优化:退款每分钟最多3次
568
+ async def refund_purchase(request: Request, account: str, item_id: str, db: Session = Depends(get_db)):
569
+ """
570
+ 🔄 P7后悔模式:申请退款
571
+ - 24小时内可退款
572
+ - 退款后30天内禁止再次购买
573
+ - 退款后权限回收
574
+ """
575
+ items_db = json_db.load_data("items.json", default_data=[])
576
+ item = next((i for i in items_db if i["id"] == item_id), None)
577
+
578
+ if not item:
579
+ raise HTTPException(status_code=404, detail="商品不存在")
580
+
581
+ # 查找购买记录
582
+ ownership = db.query(Ownership).filter(
583
+ Ownership.account == account,
584
+ Ownership.item_id == item_id,
585
+ Ownership.is_refunded == False
586
+ ).first()
587
+
588
+ if not ownership:
589
+ raise HTTPException(status_code=404, detail="未找到购买记录")
590
+
591
+ # 检查是否在退款窗口内
592
+ purchased_at = ownership.purchased_at
593
+ now = datetime.datetime.utcnow()
594
+ refund_deadline = purchased_at + datetime.timedelta(hours=REFUND_WINDOW_HOURS)
595
+
596
+ if now >= refund_deadline:
597
+ hours_passed = (now - purchased_at).total_seconds() / 3600
598
+ raise HTTPException(status_code=400, detail=f"已超过24小时退款窗口(已购买{hours_passed:.1f}小时)")
599
+
600
+ refund_amount = ownership.price_paid or 0
601
+ seller_account = item.get("author")
602
+
603
+ if refund_amount <= 0:
604
+ raise HTTPException(status_code=400, detail="该商品为免费资源,无需退款")
605
+
606
+ # 执行退款
607
+ buyer_wallet = db.query(Wallet).filter(Wallet.account == account).with_for_update().first()
608
+ seller_wallet = db.query(Wallet).filter(Wallet.account == seller_account).with_for_update().first()
609
+
610
+ if seller_wallet:
611
+ # 从卖家收益中扣除(如果不足则从余额扣除)
612
+ if seller_wallet.earn_balance >= refund_amount:
613
+ seller_wallet.earn_balance -= refund_amount
614
+ else:
615
+ remaining = refund_amount - seller_wallet.earn_balance
616
+ seller_wallet.earn_balance = 0
617
+ seller_wallet.balance = max(0, seller_wallet.balance - remaining)
618
+
619
+ if buyer_wallet:
620
+ buyer_wallet.balance += refund_amount
621
+ else:
622
+ buyer_wallet = Wallet(account=account, balance=refund_amount)
623
+ db.add(buyer_wallet)
624
+
625
+ # 标记所有权为已退款
626
+ ownership.is_refunded = True
627
+ ownership.refunded_at = now
628
+
629
+ # 创建退款记录(30天禁购)
630
+ ban_until = now + datetime.timedelta(days=REFUND_BAN_DAYS)
631
+ new_refund = Refund(
632
+ account=account,
633
+ item_id=item_id,
634
+ amount=refund_amount,
635
+ ban_until=ban_until
636
+ )
637
+ db.add(new_refund)
638
+
639
+ # 记录退款交易
640
+ tx_id = f"REFUND_{int(time.time())}_{uuid.uuid4().hex[:6]}"
641
+ last_tx = db.query(Transaction).filter(Transaction.account == account).order_by(Transaction.created_at.desc()).first()
642
+ prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
643
+ tx_hash = calculate_tx_hash(tx_id, account, "REFUND", refund_amount, prev_hash)
644
+
645
+ new_tx = Transaction(
646
+ tx_id=tx_id, account=account, tx_type="REFUND", amount=refund_amount,
647
+ related_account=seller_account, item_id=item_id, prev_hash=prev_hash, tx_hash=tx_hash
648
+ )
649
+ db.add(new_tx)
650
+ db.commit()
651
+
652
+ logger.info(f"REFUND | buyer={account} | seller={seller_account} | item={item_id} | amount={refund_amount} | ban_until={ban_until.isoformat()}")
653
+
654
+ return {
655
+ "status": "success",
656
+ "message": f"退款成功,{refund_amount}积分已退还",
657
+ "refund_amount": refund_amount,
658
+ "ban_days": REFUND_BAN_DAYS
659
  }
数据库连接.py CHANGED
@@ -23,9 +23,14 @@ import threading
23
  import time
24
  import shutil
25
  import tempfile
 
26
  from typing import Any, Dict, List, Optional, Union
 
27
  from huggingface_hub import HfApi, hf_hub_download
28
 
 
 
 
29
 
30
  # ==========================================
31
  # 🔧 配置常量
@@ -46,6 +51,9 @@ BACKUP_DIR = os.path.join(LOCAL_DB_DIR, "_backups")
46
  # HuggingFace API 客户端
47
  api = HfApi() if HF_TOKEN else None
48
 
 
 
 
49
  # 确保目录存在
50
  os.makedirs(LOCAL_DB_DIR, exist_ok=True)
51
  os.makedirs(BACKUP_DIR, exist_ok=True)
@@ -97,8 +105,8 @@ if sys.platform == "win32":
97
  try:
98
  file_obj.seek(0)
99
  msvcrt.locking(file_obj.fileno(), msvcrt.LK_UNLCK, 1)
100
- except:
101
- pass
102
 
103
  else:
104
  # Linux/Mac 文件锁
@@ -278,12 +286,12 @@ def save_data(file_name: str, data: Union[Dict, List]) -> bool:
278
  raise
279
 
280
  # ========== 第五步:异步同步到云端 ==========
 
281
  if HF_TOKEN:
282
- threading.Thread(
283
- target=_background_upload_to_hf,
284
- args=(local_path, file_name),
285
- daemon=True
286
- ).start()
287
 
288
 
289
  # ==========================================
@@ -332,8 +340,8 @@ def _save_to_failed_queue(file_name: str):
332
  try:
333
  with open(failed_queue_path, "a", encoding="utf-8") as f:
334
  f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} | {file_name}\n")
335
- except:
336
- pass
337
 
338
 
339
  # ==========================================
@@ -357,7 +365,7 @@ def verify_data_integrity(file_name: str) -> bool:
357
  with open(local_path, "r", encoding="utf-8") as f:
358
  json.load(f)
359
  return True
360
- except:
361
  return False
362
 
363
 
 
23
  import time
24
  import shutil
25
  import tempfile
26
+ import logging
27
  from typing import Any, Dict, List, Optional, Union
28
+ from concurrent.futures import ThreadPoolExecutor
29
  from huggingface_hub import HfApi, hf_hub_download
30
 
31
+ # 📝 日志配置
32
+ logger = logging.getLogger("ComfyUI-Ranking.DB")
33
+
34
 
35
  # ==========================================
36
  # 🔧 配置常量
 
51
  # HuggingFace API 客户端
52
  api = HfApi() if HF_TOKEN else None
53
 
54
+ # 🔧 P3优化:线程池管理上传任务(限制并发数)
55
+ _upload_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="hf_upload")
56
+
57
  # 确保目录存在
58
  os.makedirs(LOCAL_DB_DIR, exist_ok=True)
59
  os.makedirs(BACKUP_DIR, exist_ok=True)
 
105
  try:
106
  file_obj.seek(0)
107
  msvcrt.locking(file_obj.fileno(), msvcrt.LK_UNLCK, 1)
108
+ except Exception:
109
+ pass # Windows 文件锁释放失败可忽略
110
 
111
  else:
112
  # Linux/Mac 文件锁
 
286
  raise
287
 
288
  # ========== 第五步:异步同步到云端 ==========
289
+ # 🔧 P3优化:使用线程池替代直接创建线程
290
  if HF_TOKEN:
291
+ try:
292
+ _upload_executor.submit(_background_upload_to_hf, local_path, file_name)
293
+ except Exception as e:
294
+ logger.warning(f"提交上传任务失败: {e}")
 
295
 
296
 
297
  # ==========================================
 
340
  try:
341
  with open(failed_queue_path, "a", encoding="utf-8") as f:
342
  f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} | {file_name}\n")
343
+ except Exception as e:
344
+ print(f"⚠️ 记录上传失败队列异常: {e}")
345
 
346
 
347
  # ==========================================
 
365
  with open(local_path, "r", encoding="utf-8") as f:
366
  json.load(f)
367
  return True
368
+ except Exception:
369
  return False
370
 
371