Spaces:
Running
Running
版本更新
Browse files- app.py +154 -2
- database_sql.py +43 -0
- image_moderation.py +457 -0
- models.py +24 -1
- models_sql.py +37 -4
- rate_limiter.py +164 -0
- router_items.py +42 -4
- router_posts.py +277 -187
- router_tasks.py +858 -1241
- router_users_auth.py +5 -3
- router_wallet.py +326 -19
- 数据库连接.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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
# 🔴 【绝对核心防线】:在下发给前端前,强行在内存中抹除
|
| 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,
|
|
|
|
|
|
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 17 |
-
from
|
|
|
|
| 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(
|
| 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 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
# 最新:按创建时间倒序
|
| 46 |
-
posts_db.sort(key=lambda x: x.get("createdAt", 0), reverse=True)
|
| 47 |
|
| 48 |
# 分页
|
| 49 |
-
start = (page - 1) *
|
| 50 |
-
end = start +
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
return {
|
| 54 |
"status": "success",
|
| 55 |
-
"data":
|
| 56 |
"total": len(posts_db),
|
| 57 |
"page": page,
|
| 58 |
-
"
|
| 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 |
-
|
| 73 |
-
if not post:
|
| 74 |
-
raise HTTPException(status_code=404, detail="帖子不存在")
|
| 75 |
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
-
|
| 81 |
-
|
| 82 |
|
| 83 |
-
# ==========================================
|
| 84 |
-
# ✏️ 发布新帖子
|
| 85 |
-
# ==========================================
|
| 86 |
@router.post("/api/posts")
|
| 87 |
-
async def create_post(
|
| 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 |
-
|
| 96 |
|
| 97 |
-
# 构建新帖子对象
|
| 98 |
new_post = {
|
| 99 |
-
"id": f"post_{uuid.uuid4().hex[:
|
| 100 |
-
"
|
| 101 |
-
"
|
| 102 |
-
"
|
| 103 |
-
"
|
| 104 |
-
"
|
| 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 |
-
"
|
| 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,
|
| 141 |
"""
|
| 142 |
-
更新帖子
|
| 143 |
"""
|
| 144 |
posts_db = db.load_data("posts.json", default_data=[])
|
| 145 |
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
|
| 150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
|
| 161 |
-
post
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
|
| 164 |
-
|
| 165 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
|
| 167 |
# ==========================================
|
| 168 |
-
#
|
| 169 |
# ==========================================
|
| 170 |
-
|
| 171 |
-
|
|
|
|
| 172 |
"""
|
| 173 |
-
|
| 174 |
"""
|
|
|
|
|
|
|
|
|
|
| 175 |
posts_db = db.load_data("posts.json", default_data=[])
|
|
|
|
| 176 |
|
| 177 |
-
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
raise HTTPException(status_code=404, detail="帖子不存在")
|
| 180 |
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
|
| 183 |
-
#
|
| 184 |
-
|
| 185 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 197 |
-
|
|
|
|
| 198 |
"""
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
请求参数:
|
| 202 |
-
- post_id: 帖子ID
|
| 203 |
-
- user_id: 用户账号
|
| 204 |
-
- action_type: like / favorite
|
| 205 |
-
- is_active: True=添加, False=取消
|
| 206 |
"""
|
| 207 |
-
|
| 208 |
-
users_db = db.load_data("users.json", default_data=
|
| 209 |
|
| 210 |
-
|
| 211 |
-
if post_idx is None:
|
| 212 |
-
raise HTTPException(status_code=404, detail="帖子不存在")
|
| 213 |
|
| 214 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 276 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
|
| 278 |
-
return {"status": "success", "data":
|
|
|
|
| 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 |
-
#
|
| 8 |
-
# -
|
| 9 |
-
# -
|
| 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 |
-
|
| 28 |
-
from models import TaskCreate, TaskUpdate, TaskApply, TaskAssign, TaskSubmit, TaskAccept
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
import time
|
| 30 |
import uuid
|
| 31 |
-
|
|
|
|
| 32 |
|
| 33 |
-
# 创建子路由实例
|
| 34 |
router = APIRouter()
|
| 35 |
|
|
|
|
|
|
|
| 36 |
|
| 37 |
# ==========================================
|
| 38 |
-
#
|
| 39 |
# ==========================================
|
| 40 |
-
def
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
|
|
|
|
|
|
| 70 |
|
| 71 |
-
|
|
|
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
"""
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
| 77 |
"""
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
messages_db[account] = []
|
| 82 |
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
"
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
-
return
|
| 97 |
|
| 98 |
-
|
| 99 |
-
# ==========================================
|
| 100 |
-
# 📖 获取任务榜列表(仅显示未过期+招募中的任务)
|
| 101 |
-
# ==========================================
|
| 102 |
@router.get("/api/tasks")
|
| 103 |
-
async def get_tasks(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
"""
|
| 105 |
-
获取任务列表
|
| 106 |
-
|
| 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 |
-
# 过
|
| 118 |
-
|
| 119 |
-
|
| 120 |
|
| 121 |
-
for
|
| 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 |
-
|
| 137 |
-
if
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
| 139 |
|
| 140 |
# 排序
|
| 141 |
if sort == "price":
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
|
|
|
|
|
|
| 145 |
|
| 146 |
# 分页
|
| 147 |
-
start = (page - 1) *
|
| 148 |
-
end = start +
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
return {
|
| 152 |
"status": "success",
|
| 153 |
-
"data":
|
| 154 |
-
"total": len(
|
| 155 |
"page": page,
|
| 156 |
-
"
|
| 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 |
-
|
| 262 |
-
if not task:
|
| 263 |
-
raise HTTPException(status_code=404, detail="任务不存在")
|
| 264 |
|
| 265 |
-
|
| 266 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
|
| 268 |
-
# ==========================================
|
| 269 |
-
# ✏️ 发布新任务(P2增强:余额预检+冻结)
|
| 270 |
-
# ==========================================
|
| 271 |
@router.post("/api/tasks")
|
| 272 |
-
async def create_task(
|
| 273 |
"""
|
| 274 |
发布新任务
|
| 275 |
-
|
| 276 |
"""
|
| 277 |
tasks_db = db.load_data("tasks.json", default_data=[])
|
| 278 |
-
users_db = db.load_data("users.json", default_data={})
|
| 279 |
|
| 280 |
-
#
|
| 281 |
-
|
|
|
|
| 282 |
|
| 283 |
-
#
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
available_balance = current_balance - frozen_balance
|
| 287 |
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
)
|
|
|
|
| 293 |
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
final_payment = task_data.totalPrice - deposit_amount
|
| 297 |
|
| 298 |
-
#
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
db.save_data("users.json", users_db)
|
| 302 |
|
| 303 |
-
#
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 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 |
-
"
|
| 316 |
-
"
|
| 317 |
-
"
|
| 318 |
-
"
|
| 319 |
-
"
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
"
|
| 323 |
-
"
|
| 324 |
-
"
|
| 325 |
-
"
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
#
|
| 329 |
-
"
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
#
|
| 333 |
-
"
|
| 334 |
-
|
| 335 |
-
"
|
| 336 |
-
"
|
| 337 |
-
|
| 338 |
-
"
|
| 339 |
-
|
| 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 |
-
|
| 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 |
-
|
| 404 |
|
| 405 |
-
return {"status": "success", "
|
| 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 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
|
| 455 |
-
|
| 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 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 539 |
|
| 540 |
-
|
| 541 |
-
|
| 542 |
|
| 543 |
# ==========================================
|
| 544 |
-
#
|
| 545 |
# ==========================================
|
| 546 |
-
|
| 547 |
-
|
|
|
|
| 548 |
"""
|
| 549 |
-
接单
|
| 550 |
"""
|
| 551 |
tasks_db = db.load_data("tasks.json", default_data=[])
|
| 552 |
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 582 |
|
| 583 |
-
|
| 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 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 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 |
-
|
| 688 |
-
|
| 689 |
|
| 690 |
# ==========================================
|
| 691 |
-
#
|
| 692 |
# ==========================================
|
| 693 |
-
|
| 694 |
-
|
|
|
|
| 695 |
"""
|
| 696 |
-
发布者
|
| 697 |
-
|
| 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 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 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 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 788 |
)
|
| 789 |
|
| 790 |
-
#
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 805 |
|
| 806 |
-
|
| 807 |
-
|
| 808 |
|
| 809 |
# ==========================================
|
| 810 |
-
#
|
| 811 |
# ==========================================
|
| 812 |
-
|
| 813 |
-
|
|
|
|
| 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 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
|
| 835 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 836 |
})
|
|
|
|
|
|
|
| 837 |
|
| 838 |
-
|
| 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 |
-
#
|
| 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 |
-
|
| 952 |
-
|
| 953 |
-
|
| 954 |
-
|
| 955 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 956 |
|
| 957 |
-
|
| 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 |
-
# ⚖️
|
| 985 |
# ==========================================
|
| 986 |
-
|
| 987 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1040 |
-
|
| 1041 |
-
|
| 1042 |
-
|
| 1043 |
-
|
| 1044 |
-
|
| 1045 |
-
|
| 1046 |
-
|
| 1047 |
-
|
| 1048 |
-
|
| 1049 |
-
|
| 1050 |
-
|
| 1051 |
-
|
| 1052 |
-
|
| 1053 |
-
|
| 1054 |
-
|
| 1055 |
-
|
| 1056 |
-
|
| 1057 |
-
|
| 1058 |
-
|
| 1059 |
-
|
| 1060 |
-
|
| 1061 |
-
|
| 1062 |
-
|
| 1063 |
-
|
| 1064 |
-
|
| 1065 |
-
|
| 1066 |
-
|
| 1067 |
-
|
| 1068 |
-
|
| 1069 |
-
|
| 1070 |
-
|
| 1071 |
-
|
| 1072 |
-
|
| 1073 |
-
|
| 1074 |
-
|
| 1075 |
-
|
| 1076 |
-
|
| 1077 |
-
|
| 1078 |
-
|
| 1079 |
-
|
| 1080 |
-
|
| 1081 |
-
|
| 1082 |
-
|
| 1083 |
-
|
| 1084 |
-
|
| 1085 |
-
|
| 1086 |
-
|
| 1087 |
-
|
| 1088 |
-
|
| 1089 |
-
|
| 1090 |
-
|
| 1091 |
-
|
| 1092 |
-
|
| 1093 |
-
|
| 1094 |
-
|
| 1095 |
-
|
| 1096 |
-
|
| 1097 |
-
|
| 1098 |
-
|
| 1099 |
-
target_account=respondent,
|
| 1100 |
-
note=f"发起申诉: {task.get('title', '')[:20]}"
|
| 1101 |
-
)
|
| 1102 |
|
| 1103 |
-
|
| 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 |
-
|
| 1125 |
-
|
| 1126 |
-
|
| 1127 |
-
|
| 1128 |
-
|
| 1129 |
-
|
| 1130 |
-
|
| 1131 |
-
|
| 1132 |
-
|
| 1133 |
-
|
| 1134 |
-
|
| 1135 |
-
|
| 1136 |
-
|
| 1137 |
-
|
| 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 |
-
|
| 1149 |
-
|
| 1150 |
|
| 1151 |
-
|
| 1152 |
-
|
| 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 |
-
|
| 1189 |
-
|
| 1190 |
-
|
| 1191 |
-
|
| 1192 |
-
|
| 1193 |
-
|
| 1194 |
-
|
| 1195 |
-
|
| 1196 |
-
|
| 1197 |
-
|
| 1198 |
-
|
| 1199 |
-
|
| 1200 |
-
|
| 1201 |
-
|
| 1202 |
-
|
| 1203 |
-
|
| 1204 |
-
|
| 1205 |
-
|
| 1206 |
-
|
| 1207 |
-
|
| 1208 |
-
|
| 1209 |
-
|
| 1210 |
-
|
| 1211 |
-
|
| 1212 |
-
|
| 1213 |
-
|
| 1214 |
-
|
| 1215 |
-
|
| 1216 |
-
|
| 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 |
-
|
| 1287 |
-
|
| 1288 |
|
| 1289 |
-
|
|
|
|
| 1290 |
"""
|
| 1291 |
-
|
|
|
|
| 1292 |
"""
|
| 1293 |
-
|
| 1294 |
-
|
| 1295 |
|
| 1296 |
-
|
| 1297 |
-
|
| 1298 |
-
|
| 1299 |
-
|
| 1300 |
-
|
| 1301 |
-
|
| 1302 |
-
|
| 1303 |
-
|
| 1304 |
-
|
| 1305 |
-
|
| 1306 |
-
|
| 1307 |
-
|
| 1308 |
-
|
| 1309 |
-
|
| 1310 |
-
|
| 1311 |
-
|
| 1312 |
-
|
| 1313 |
-
|
| 1314 |
-
|
| 1315 |
-
|
| 1316 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1317 |
|
| 1318 |
-
|
| 1319 |
-
|
| 1320 |
"""
|
| 1321 |
-
|
|
|
|
|
|
|
|
|
|
| 1322 |
"""
|
| 1323 |
-
|
| 1324 |
-
|
| 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 |
-
|
| 1336 |
-
|
| 1337 |
|
| 1338 |
-
|
| 1339 |
-
|
| 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 |
-
|
| 1357 |
-
|
| 1358 |
-
|
| 1359 |
-
|
| 1360 |
-
|
| 1361 |
-
|
| 1362 |
-
|
| 1363 |
-
|
| 1364 |
-
|
| 1365 |
-
|
| 1366 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1367 |
|
| 1368 |
-
|
| 1369 |
-
"status": "success",
|
| 1370 |
-
"data": paginated,
|
| 1371 |
-
"total": total,
|
| 1372 |
-
"page": page,
|
| 1373 |
-
"page_size": page_size
|
| 1374 |
-
}
|
| 1375 |
-
|
| 1376 |
|
| 1377 |
# ==========================================
|
| 1378 |
-
#
|
| 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 |
-
|
| 1403 |
|
| 1404 |
-
|
| 1405 |
-
|
| 1406 |
-
|
| 1407 |
-
|
| 1408 |
|
| 1409 |
-
|
|
|
|
| 1410 |
|
| 1411 |
-
return {"status": "success", "data":
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
# 🚀
|
| 126 |
-
|
| 127 |
-
total_withdrawn = sum(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
seller_account = item.get("author")
|
| 151 |
|
| 152 |
if price <= 0 or req.account == seller_account:
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
if owned:
|
| 157 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
|
| 192 |
@router.post("/api/wallet/tip")
|
| 193 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 292 |
Transaction.account == req.account,
|
| 293 |
Transaction.tx_type == 'WITHDRAW'
|
| 294 |
-
).
|
| 295 |
-
total_withdrawn = abs(
|
| 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 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 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 |
-
|
| 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 |
|