Spaces:
Running
Running
Upload 19 files
Browse files- app.py +44 -12
- models.py +1 -0
- router_items.py +83 -33
- router_proxy.py +21 -22
- router_users_auth.py +341 -0
- router_users_profile.py +120 -0
- router_users_social.py +130 -0
- router_wallet.py +134 -35
- verify_code_engine.py +173 -0
- 云端_定时版本检测引擎.py +60 -0
- 安全认证.py +315 -0
- 密码迁移.py +131 -0
- 数据库连接.py +351 -25
- 测试脚本.py +313 -0
app.py
CHANGED
|
@@ -12,13 +12,29 @@ import urllib.error
|
|
| 12 |
import os
|
| 13 |
import mimetypes
|
| 14 |
import 数据库连接 as db
|
|
|
|
| 15 |
|
| 16 |
-
|
| 17 |
-
from
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
from database_sql import init_sql_db, get_db
|
| 24 |
from models_sql import Ownership
|
|
@@ -29,10 +45,18 @@ app = FastAPI(title="ComfyUI Ranking Community API")
|
|
| 29 |
def health_check():
|
| 30 |
return {"status": "ok", "message": "ComfyUI Ranking API is running perfectly!"}
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
@app.on_event("startup")
|
| 33 |
def on_startup():
|
|
|
|
| 34 |
init_sql_db()
|
| 35 |
print("关系型数据库加载完毕,金融表同步完成。")
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
app.add_middleware(
|
| 38 |
CORSMiddleware,
|
|
@@ -42,12 +66,20 @@ app.add_middleware(
|
|
| 42 |
allow_headers=["*"],
|
| 43 |
)
|
| 44 |
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
app.include_router(
|
| 50 |
-
app.include_router(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
# ==========================================
|
| 53 |
# 🟢 私有图床代理中心 (Image Proxy)
|
|
|
|
| 12 |
import os
|
| 13 |
import mimetypes
|
| 14 |
import 数据库连接 as db
|
| 15 |
+
import asyncio
|
| 16 |
|
| 17 |
+
|
| 18 |
+
from 云端_定时版本检测引擎 import daily_version_check_task
|
| 19 |
+
|
| 20 |
+
# ==========================================
|
| 21 |
+
# 👥 用户模块 (拆分为3个子模块)
|
| 22 |
+
# ==========================================
|
| 23 |
+
# router_users_auth.py - 🔐 登录/注册/密码重置/验证码
|
| 24 |
+
# router_users_profile.py - 👤 获取/更新用户资料
|
| 25 |
+
# router_users_social.py - 🤝 关注/隐私设置
|
| 26 |
+
from router_users_auth import router as users_auth_router
|
| 27 |
+
from router_users_profile import router as users_profile_router
|
| 28 |
+
from router_users_social import router as users_social_router
|
| 29 |
+
|
| 30 |
+
# ==========================================
|
| 31 |
+
# 其他业务模块
|
| 32 |
+
# ==========================================
|
| 33 |
+
from router_items import router as items_router # 📦 内容管理(工具/应用/推荐)
|
| 34 |
+
from router_comments import router as comments_router # 💬 评论系统
|
| 35 |
+
from router_messages import router as messages_router # ✉️ 私信系统
|
| 36 |
+
from router_wallet import router as wallet_router # 💰 钱包/提现
|
| 37 |
+
from router_proxy import router as proxy_router # 🔗 代理下载
|
| 38 |
|
| 39 |
from database_sql import init_sql_db, get_db
|
| 40 |
from models_sql import Ownership
|
|
|
|
| 45 |
def health_check():
|
| 46 |
return {"status": "ok", "message": "ComfyUI Ranking API is running perfectly!"}
|
| 47 |
|
| 48 |
+
# ==========================================
|
| 49 |
+
# 🚀 应用启动事件
|
| 50 |
+
# ==========================================
|
| 51 |
+
# 作用:初始化数据库 + 启动定时版本检测后台任务
|
| 52 |
@app.on_event("startup")
|
| 53 |
def on_startup():
|
| 54 |
+
# 初始化 SQL 数据库
|
| 55 |
init_sql_db()
|
| 56 |
print("关系型数据库加载完毕,金融表同步完成。")
|
| 57 |
+
|
| 58 |
+
# 🚀 启动定时版本探测挂载后台任务
|
| 59 |
+
asyncio.create_task(daily_version_check_task())
|
| 60 |
|
| 61 |
app.add_middleware(
|
| 62 |
CORSMiddleware,
|
|
|
|
| 66 |
allow_headers=["*"],
|
| 67 |
)
|
| 68 |
|
| 69 |
+
# ==========================================
|
| 70 |
+
# 路由挂载
|
| 71 |
+
# ==========================================
|
| 72 |
+
# 用户模块 (3个子模块)
|
| 73 |
+
app.include_router(users_auth_router) # 🔐 登录/注册/密码重置
|
| 74 |
+
app.include_router(users_profile_router) # 👤 用户资料
|
| 75 |
+
app.include_router(users_social_router) # 🤝 关注/隐私
|
| 76 |
+
|
| 77 |
+
# 其他业务模块
|
| 78 |
+
app.include_router(items_router) # 📦 内容管理
|
| 79 |
+
app.include_router(comments_router) # 💬 评论系统
|
| 80 |
+
app.include_router(messages_router) # ✉️ 私信系统
|
| 81 |
+
app.include_router(wallet_router) # 💰 钱包/提现
|
| 82 |
+
app.include_router(proxy_router) # 🔗 代理下载
|
| 83 |
|
| 84 |
# ==========================================
|
| 85 |
# 🟢 私有图床代理中心 (Image Proxy)
|
models.py
CHANGED
|
@@ -108,6 +108,7 @@ class TipRequest(BaseModel):
|
|
| 108 |
target_account: str
|
| 109 |
amount: int
|
| 110 |
is_anonymous: bool
|
|
|
|
| 111 |
|
| 112 |
class WithdrawRequest(BaseModel):
|
| 113 |
account: str
|
|
|
|
| 108 |
target_account: str
|
| 109 |
amount: int
|
| 110 |
is_anonymous: bool
|
| 111 |
+
item_id: Optional[str] = None # 🚀 新增:记录是为哪个作品打赏的
|
| 112 |
|
| 113 |
class WithdrawRequest(BaseModel):
|
| 114 |
account: str
|
router_items.py
CHANGED
|
@@ -3,6 +3,10 @@ from fastapi import APIRouter, HTTPException
|
|
| 3 |
import time
|
| 4 |
import uuid
|
| 5 |
import datetime
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
import 数据库连接 as db
|
| 7 |
from models import ItemCreate, ItemUpdate
|
| 8 |
|
|
@@ -24,6 +28,7 @@ def get_last_6_months():
|
|
| 24 |
async def get_items(type: str = "tool", sort: str = "time", limit: int = 50): # 优化:默认限制调大至 50,提升前端列表体验
|
| 25 |
items_db = db.load_data("items.json", default_data=[])
|
| 26 |
comments_db = db.load_data("comments.json", default_data={})
|
|
|
|
| 27 |
|
| 28 |
# 如果是推荐榜,匹配所有 recommend 开头的子类型
|
| 29 |
if type == "recommend":
|
|
@@ -34,14 +39,17 @@ async def get_items(type: str = "tool", sort: str = "time", limit: int = 50): #
|
|
| 34 |
for item in filtered_items:
|
| 35 |
item["commentsData"] = comments_db.get(item["id"], [])
|
| 36 |
item["comments"] = len(item["commentsData"])
|
|
|
|
| 37 |
|
| 38 |
# 🔴 【绝对核心防线】:在下发给前端前,强行在内存中抹除创作者的 Token!
|
| 39 |
-
# 这样即使资源是公开展示的,普通用户也绝对抓不到源仓库的密钥。
|
| 40 |
item.pop("github_token", None)
|
| 41 |
|
| 42 |
if sort == "likes": filtered_items.sort(key=lambda x: x.get("likes", 0), reverse=True)
|
| 43 |
elif sort == "favorites": filtered_items.sort(key=lambda x: x.get("favorites", 0), reverse=True)
|
| 44 |
elif sort == "downloads": filtered_items.sort(key=lambda x: x.get("uses", 0), reverse=True)
|
|
|
|
|
|
|
|
|
|
| 45 |
else: filtered_items.sort(key=lambda x: x.get("created_at", 0), reverse=True)
|
| 46 |
|
| 47 |
return {"status": "success", "data": filtered_items[:limit]}
|
|
@@ -82,6 +90,7 @@ async def get_creators(sort: str = "downloads", limit: int = 20):
|
|
| 82 |
"likes": sum(i.get("likes", 0) for i in u_items), "favorites": sum(i.get("favorites", 0) for i in u_items),
|
| 83 |
"downloads": sum(i.get("uses", 0) for i in u_items),
|
| 84 |
"toolsCount": tools_count, "appsCount": apps_count, "followers": len(u.get("followers", [])), "created_at": u.get("created_at", 0),
|
|
|
|
| 85 |
"commentsData": comments_db.get(account, []),
|
| 86 |
"trendData": {
|
| 87 |
"months": months,
|
|
@@ -94,13 +103,13 @@ async def get_creators(sort: str = "downloads", limit: int = 20):
|
|
| 94 |
if sort == "likes": creators.sort(key=lambda x: x.get("likes", 0), reverse=True)
|
| 95 |
elif sort == "favorites": creators.sort(key=lambda x: x.get("favorites", 0), reverse=True)
|
| 96 |
elif sort == "downloads": creators.sort(key=lambda x: x.get("downloads", 0), reverse=True)
|
|
|
|
| 97 |
else: creators.sort(key=lambda x: x.get("created_at", 0), reverse=True)
|
| 98 |
|
| 99 |
return {"status": "success", "data": creators[:limit]}
|
| 100 |
|
| 101 |
@router.post("/api/items")
|
| 102 |
async def create_item(item: ItemCreate):
|
| 103 |
-
# 【安全加固】:强制转换为整数,并拦截负数 (防浮点漏洞与洗钱)
|
| 104 |
item.price = int(item.price)
|
| 105 |
if item.price < 0:
|
| 106 |
raise HTTPException(status_code=400, detail="🚨 安全拦截:商品价格不能为负数")
|
|
@@ -109,7 +118,7 @@ async def create_item(item: ItemCreate):
|
|
| 109 |
new_item = {
|
| 110 |
"id": f"{item.type}_{int(time.time())}_{uuid.uuid4().hex[:6]}", "type": item.type, "title": item.title, "author": item.author,
|
| 111 |
"shortDesc": item.shortDesc, "fullDesc": item.fullDesc, "link": item.link, "coverUrl": item.coverUrl, "price": item.price,
|
| 112 |
-
"github_token": item.github_token,
|
| 113 |
"likes": 0, "favorites": 0, "comments": 0, "uses": 0, "use_history": {}, "created_at": int(time.time()), "liked_by": [], "favorited_by": []
|
| 114 |
}
|
| 115 |
items_db.insert(0, new_item)
|
|
@@ -118,7 +127,6 @@ async def create_item(item: ItemCreate):
|
|
| 118 |
|
| 119 |
@router.put("/api/items/{item_id}")
|
| 120 |
async def update_item(item_id: str, update_data: ItemUpdate, author: str):
|
| 121 |
-
# 【安全加固】:更新时同样强制转换为整数并拦截负数
|
| 122 |
if update_data.price is not None:
|
| 123 |
update_data.price = int(update_data.price)
|
| 124 |
if update_data.price < 0:
|
|
@@ -135,43 +143,85 @@ async def update_item(item_id: str, update_data: ItemUpdate, author: str):
|
|
| 135 |
if update_data.link is not None: item["link"] = update_data.link
|
| 136 |
if update_data.coverUrl is not None: item["coverUrl"] = update_data.coverUrl
|
| 137 |
if update_data.price is not None: item["price"] = update_data.price
|
| 138 |
-
if update_data.github_token is not None: item["github_token"] = update_data.github_token
|
| 139 |
|
| 140 |
db.save_data("items.json", items_db)
|
| 141 |
return {"status": "success"}
|
| 142 |
|
| 143 |
raise HTTPException(status_code=404, detail="找不到该内容记录")
|
| 144 |
|
| 145 |
-
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
items_db = db.load_data("items.json", default_data=[])
|
| 148 |
-
|
| 149 |
|
| 150 |
-
|
| 151 |
-
if items_db[target_idx].get("author") != author: raise HTTPException(status_code=403, detail="无权删除他人发布的内容")
|
| 152 |
-
|
| 153 |
-
items_db.pop(target_idx)
|
| 154 |
-
db.save_data("items.json", items_db)
|
| 155 |
-
|
| 156 |
-
comments_db = db.load_data("comments.json", default_data={})
|
| 157 |
-
if item_id in comments_db:
|
| 158 |
-
del comments_db[item_id]
|
| 159 |
-
db.save_data("comments.json", comments_db)
|
| 160 |
-
|
| 161 |
-
return {"status": "success"}
|
| 162 |
-
|
| 163 |
-
@router.post("/api/items/{item_id}/use")
|
| 164 |
-
async def record_item_use(item_id: str):
|
| 165 |
-
items_db = db.load_data("items.json", default_data=[])
|
| 166 |
-
current_month = datetime.date.today().strftime("%Y-%m")
|
| 167 |
|
| 168 |
for item in items_db:
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
item["use_history"][current_month] = item["use_history"].get(current_month, 0) + 1
|
| 174 |
-
db.save_data("items.json", items_db)
|
| 175 |
-
return {"status": "success", "uses": item["uses"]}
|
| 176 |
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import time
|
| 4 |
import uuid
|
| 5 |
import datetime
|
| 6 |
+
import os
|
| 7 |
+
import urllib.request
|
| 8 |
+
import urllib.error
|
| 9 |
+
import json
|
| 10 |
import 数据库连接 as db
|
| 11 |
from models import ItemCreate, ItemUpdate
|
| 12 |
|
|
|
|
| 28 |
async def get_items(type: str = "tool", sort: str = "time", limit: int = 50): # 优化:默认限制调大至 50,提升前端列表体验
|
| 29 |
items_db = db.load_data("items.json", default_data=[])
|
| 30 |
comments_db = db.load_data("comments.json", default_data={})
|
| 31 |
+
versions_db = db.load_data("versions.json", default_data={}) # 读取版本库
|
| 32 |
|
| 33 |
# 如果是推荐榜,匹配所有 recommend 开头的子类型
|
| 34 |
if type == "recommend":
|
|
|
|
| 39 |
for item in filtered_items:
|
| 40 |
item["commentsData"] = comments_db.get(item["id"], [])
|
| 41 |
item["comments"] = len(item["commentsData"])
|
| 42 |
+
item["latest_version"] = versions_db.get(item["id"], "")
|
| 43 |
|
| 44 |
# 🔴 【绝对核心防线】:在下发给前端前,强行在内存中抹除创作者的 Token!
|
|
|
|
| 45 |
item.pop("github_token", None)
|
| 46 |
|
| 47 |
if sort == "likes": filtered_items.sort(key=lambda x: x.get("likes", 0), reverse=True)
|
| 48 |
elif sort == "favorites": filtered_items.sort(key=lambda x: x.get("favorites", 0), reverse=True)
|
| 49 |
elif sort == "downloads": filtered_items.sort(key=lambda x: x.get("uses", 0), reverse=True)
|
| 50 |
+
elif sort == "tips": # 🚀 新增:按近期打赏排序
|
| 51 |
+
current_month = datetime.date.today().strftime("%Y-%m")
|
| 52 |
+
filtered_items.sort(key=lambda x: x.get("tip_history", {}).get(current_month, 0), reverse=True)
|
| 53 |
else: filtered_items.sort(key=lambda x: x.get("created_at", 0), reverse=True)
|
| 54 |
|
| 55 |
return {"status": "success", "data": filtered_items[:limit]}
|
|
|
|
| 90 |
"likes": sum(i.get("likes", 0) for i in u_items), "favorites": sum(i.get("favorites", 0) for i in u_items),
|
| 91 |
"downloads": sum(i.get("uses", 0) for i in u_items),
|
| 92 |
"toolsCount": tools_count, "appsCount": apps_count, "followers": len(u.get("followers", [])), "created_at": u.get("created_at", 0),
|
| 93 |
+
"recent_tips": u.get("tip_history", {}).get(datetime.date.today().strftime("%Y-%m"), 0), # 🚀 新增:本月收益统计
|
| 94 |
"commentsData": comments_db.get(account, []),
|
| 95 |
"trendData": {
|
| 96 |
"months": months,
|
|
|
|
| 103 |
if sort == "likes": creators.sort(key=lambda x: x.get("likes", 0), reverse=True)
|
| 104 |
elif sort == "favorites": creators.sort(key=lambda x: x.get("favorites", 0), reverse=True)
|
| 105 |
elif sort == "downloads": creators.sort(key=lambda x: x.get("downloads", 0), reverse=True)
|
| 106 |
+
elif sort == "tips": creators.sort(key=lambda x: x.get("recent_tips", 0), reverse=True) # 🚀 新增:按近期打赏排序
|
| 107 |
else: creators.sort(key=lambda x: x.get("created_at", 0), reverse=True)
|
| 108 |
|
| 109 |
return {"status": "success", "data": creators[:limit]}
|
| 110 |
|
| 111 |
@router.post("/api/items")
|
| 112 |
async def create_item(item: ItemCreate):
|
|
|
|
| 113 |
item.price = int(item.price)
|
| 114 |
if item.price < 0:
|
| 115 |
raise HTTPException(status_code=400, detail="🚨 安全拦截:商品价格不能为负数")
|
|
|
|
| 118 |
new_item = {
|
| 119 |
"id": f"{item.type}_{int(time.time())}_{uuid.uuid4().hex[:6]}", "type": item.type, "title": item.title, "author": item.author,
|
| 120 |
"shortDesc": item.shortDesc, "fullDesc": item.fullDesc, "link": item.link, "coverUrl": item.coverUrl, "price": item.price,
|
| 121 |
+
"github_token": item.github_token,
|
| 122 |
"likes": 0, "favorites": 0, "comments": 0, "uses": 0, "use_history": {}, "created_at": int(time.time()), "liked_by": [], "favorited_by": []
|
| 123 |
}
|
| 124 |
items_db.insert(0, new_item)
|
|
|
|
| 127 |
|
| 128 |
@router.put("/api/items/{item_id}")
|
| 129 |
async def update_item(item_id: str, update_data: ItemUpdate, author: str):
|
|
|
|
| 130 |
if update_data.price is not None:
|
| 131 |
update_data.price = int(update_data.price)
|
| 132 |
if update_data.price < 0:
|
|
|
|
| 143 |
if update_data.link is not None: item["link"] = update_data.link
|
| 144 |
if update_data.coverUrl is not None: item["coverUrl"] = update_data.coverUrl
|
| 145 |
if update_data.price is not None: item["price"] = update_data.price
|
| 146 |
+
if update_data.github_token is not None: item["github_token"] = update_data.github_token
|
| 147 |
|
| 148 |
db.save_data("items.json", items_db)
|
| 149 |
return {"status": "success"}
|
| 150 |
|
| 151 |
raise HTTPException(status_code=404, detail="找不到该内容记录")
|
| 152 |
|
| 153 |
+
# ==========================================
|
| 154 |
+
# 🚀 定时任务接口:检查 GitHub 仓库最新版本
|
| 155 |
+
# 可通过外部调度器 (cron-job.org / GitHub Actions) 每日 02:00 触发
|
| 156 |
+
# ==========================================
|
| 157 |
+
@router.post("/api/check_updates")
|
| 158 |
+
async def check_github_updates():
|
| 159 |
+
"""
|
| 160 |
+
遍历所有 GitHub 类型的工具,获取最新 commit hash,存入 versions.json
|
| 161 |
+
"""
|
| 162 |
items_db = db.load_data("items.json", default_data=[])
|
| 163 |
+
versions_db = db.load_data("versions.json", default_data={})
|
| 164 |
|
| 165 |
+
updated_count = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
|
| 167 |
for item in items_db:
|
| 168 |
+
# 只处理 GitHub 仓库类型的工具
|
| 169 |
+
link = item.get("link", "")
|
| 170 |
+
if not link.startswith("https://github.com/"):
|
| 171 |
+
continue
|
|
|
|
|
|
|
|
|
|
| 172 |
|
| 173 |
+
item_id = item["id"]
|
| 174 |
+
|
| 175 |
+
try:
|
| 176 |
+
# 解析仓库信息
|
| 177 |
+
repo_parts = link.rstrip("/").replace(".git", "").split("/")
|
| 178 |
+
if len(repo_parts) < 2:
|
| 179 |
+
continue
|
| 180 |
+
owner, repo = repo_parts[-2], repo_parts[-1]
|
| 181 |
+
|
| 182 |
+
# 获取创作者 Token 或使用全局兜底 Token
|
| 183 |
+
creator_token = item.get("github_token")
|
| 184 |
+
fallback_token = os.environ.get("GITHUB_PAT")
|
| 185 |
+
active_token = creator_token if creator_token else fallback_token
|
| 186 |
+
|
| 187 |
+
# 请求 GitHub API 获取最新 commit
|
| 188 |
+
api_url = f"https://api.github.com/repos/{owner}/{repo}/commits?per_page=1"
|
| 189 |
+
headers = {
|
| 190 |
+
"Accept": "application/vnd.github.v3+json",
|
| 191 |
+
"User-Agent": "ComfyUI-Ranking-VersionChecker"
|
| 192 |
+
}
|
| 193 |
+
if active_token:
|
| 194 |
+
headers["Authorization"] = f"Bearer {active_token}"
|
| 195 |
+
|
| 196 |
+
req = urllib.request.Request(api_url, headers=headers)
|
| 197 |
+
with urllib.request.urlopen(req, timeout=10) as response:
|
| 198 |
+
commits = json.loads(response.read().decode("utf-8"))
|
| 199 |
+
if commits and len(commits) > 0:
|
| 200 |
+
latest_sha = commits[0].get("sha", "")[:7] # 取前7位作为版本标识
|
| 201 |
+
|
| 202 |
+
# 如果版本有变化,更新 versions.json
|
| 203 |
+
old_version = versions_db.get(item_id, "")
|
| 204 |
+
if latest_sha and latest_sha != old_version:
|
| 205 |
+
versions_db[item_id] = latest_sha
|
| 206 |
+
updated_count += 1
|
| 207 |
+
print(f"[版本更新] {item['title']}: {old_version} -> {latest_sha}")
|
| 208 |
+
|
| 209 |
+
except urllib.error.HTTPError as e:
|
| 210 |
+
print(f"[版本检查失败] {item.get('title', item_id)}: HTTP {e.code}")
|
| 211 |
+
except Exception as e:
|
| 212 |
+
print(f"[版本检查异常] {item.get('title', item_id)}: {str(e)}")
|
| 213 |
+
|
| 214 |
+
# 保存更新后的版本库
|
| 215 |
+
db.save_data("versions.json", versions_db)
|
| 216 |
+
|
| 217 |
+
return {
|
| 218 |
+
"status": "success",
|
| 219 |
+
"message": f"版本检查完成,共更新 {updated_count} 个工具的版本号",
|
| 220 |
+
"updated_count": updated_count
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
@router.get("/api/item/{item_id}/version")
|
| 224 |
+
async def get_item_version(item_id: str):
|
| 225 |
+
"""获取单个资源的最新版本号"""
|
| 226 |
+
versions_db = db.load_data("versions.json", default_data={})
|
| 227 |
+
return {"status": "success", "version": versions_db.get(item_id, "")}
|
router_proxy.py
CHANGED
|
@@ -1,10 +1,12 @@
|
|
| 1 |
# router_proxy.py
|
| 2 |
from fastapi import APIRouter, Depends, HTTPException
|
| 3 |
-
from fastapi.responses import StreamingResponse, JSONResponse
|
| 4 |
from sqlalchemy.orm import Session
|
| 5 |
from pydantic import BaseModel
|
| 6 |
import httpx
|
| 7 |
import os
|
|
|
|
|
|
|
| 8 |
import 数据库连接 as json_db
|
| 9 |
from database_sql import get_db
|
| 10 |
from models_sql import Ownership
|
|
@@ -43,7 +45,7 @@ async def proxy_github_zip(req_data: ProxyGithubZipRequest, db: Session = Depend
|
|
| 43 |
# GitHub 官方提供的打包下载 API
|
| 44 |
github_zip_api = f"https://api.github.com/repos/{owner}/{repo}/zipball"
|
| 45 |
|
| 46 |
-
#
|
| 47 |
creator_token = item.get("github_token")
|
| 48 |
# 如果没填,尝试使用官方全局兜底的 PAT
|
| 49 |
fallback_token = os.environ.get("GITHUB_PAT")
|
|
@@ -60,12 +62,15 @@ async def proxy_github_zip(req_data: ProxyGithubZipRequest, db: Session = Depend
|
|
| 60 |
# 3. 异步请求 GitHub API 并以流形式透传回客户端 (防内存打爆)
|
| 61 |
async def stream_generator():
|
| 62 |
async with httpx.AsyncClient(follow_redirects=True) as client:
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
return StreamingResponse(stream_generator(), media_type="application/zip")
|
| 71 |
|
|
@@ -96,26 +101,20 @@ async def proxy_download(req_data: ProxyDownloadRequest, db: Session = Depends(g
|
|
| 96 |
target_url = req_data.url
|
| 97 |
|
| 98 |
# 🚀 核心修复:从环境变量提取 Hugging Face Token,并组装 Authorization 请求头
|
| 99 |
-
# 注意:这里的变量名需与你在 Spaces -> Settings -> Variables and secrets 中设置的名称一致
|
| 100 |
hf_token = os.environ.get("HF_TOKEN")
|
| 101 |
|
| 102 |
-
headers = {}
|
| 103 |
if hf_token and "huggingface.co" in target_url:
|
| 104 |
headers["Authorization"] = f"Bearer {hf_token}"
|
| 105 |
|
| 106 |
-
# 2.
|
| 107 |
try:
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
if resp.status_code != 200:
|
| 114 |
-
return JSONResponse(content={"error": f"源文件拉取失败,HTTP状态码: {resp.status_code}"}, status_code=resp.status_code)
|
| 115 |
-
|
| 116 |
-
# 成功则直接将 JSON 二进制流原样返回给本地 ComfyUI 引擎
|
| 117 |
-
from fastapi.responses import Response
|
| 118 |
-
return Response(content=resp.content, media_type="application/json")
|
| 119 |
|
|
|
|
|
|
|
| 120 |
except Exception as e:
|
| 121 |
return JSONResponse(content={"error": f"代理下载时发生网络异常: {str(e)}"}, status_code=500)
|
|
|
|
| 1 |
# router_proxy.py
|
| 2 |
from fastapi import APIRouter, Depends, HTTPException
|
| 3 |
+
from fastapi.responses import StreamingResponse, JSONResponse, Response
|
| 4 |
from sqlalchemy.orm import Session
|
| 5 |
from pydantic import BaseModel
|
| 6 |
import httpx
|
| 7 |
import os
|
| 8 |
+
import urllib.request
|
| 9 |
+
import urllib.error
|
| 10 |
import 数据库连接 as json_db
|
| 11 |
from database_sql import get_db
|
| 12 |
from models_sql import Ownership
|
|
|
|
| 45 |
# GitHub 官方提供的打包下载 API
|
| 46 |
github_zip_api = f"https://api.github.com/repos/{owner}/{repo}/zipball"
|
| 47 |
|
| 48 |
+
# 优先读取该资源在数据库中绑定的专属创作者 Token
|
| 49 |
creator_token = item.get("github_token")
|
| 50 |
# 如果没填,尝试使用官方全局兜底的 PAT
|
| 51 |
fallback_token = os.environ.get("GITHUB_PAT")
|
|
|
|
| 62 |
# 3. 异步请求 GitHub API 并以流形式透传回客户端 (防内存打爆)
|
| 63 |
async def stream_generator():
|
| 64 |
async with httpx.AsyncClient(follow_redirects=True) as client:
|
| 65 |
+
try:
|
| 66 |
+
async with client.stream("GET", github_zip_api, headers=headers, timeout=120.0) as response:
|
| 67 |
+
if response.status_code != 200:
|
| 68 |
+
yield b"GITHUB_DOWNLOAD_FAILED"
|
| 69 |
+
return
|
| 70 |
+
async for chunk in response.aiter_bytes():
|
| 71 |
+
yield chunk
|
| 72 |
+
except Exception as e:
|
| 73 |
+
yield b"GITHUB_DOWNLOAD_FAILED"
|
| 74 |
|
| 75 |
return StreamingResponse(stream_generator(), media_type="application/zip")
|
| 76 |
|
|
|
|
| 101 |
target_url = req_data.url
|
| 102 |
|
| 103 |
# 🚀 核心修复:从环境变量提取 Hugging Face Token,并组装 Authorization 请求头
|
|
|
|
| 104 |
hf_token = os.environ.get("HF_TOKEN")
|
| 105 |
|
| 106 |
+
headers = {"User-Agent": "ComfyUI-Ranking-SaaS"}
|
| 107 |
if hf_token and "huggingface.co" in target_url:
|
| 108 |
headers["Authorization"] = f"Bearer {hf_token}"
|
| 109 |
|
| 110 |
+
# 2. 使用 urllib 拉取真实 JSON 数据(保持原始中文文件名,不自动编码)
|
| 111 |
try:
|
| 112 |
+
req = urllib.request.Request(target_url, headers=headers)
|
| 113 |
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
| 114 |
+
content = resp.read()
|
| 115 |
+
return Response(content=content, media_type="application/json")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
|
| 117 |
+
except urllib.error.HTTPError as e:
|
| 118 |
+
return JSONResponse(content={"error": f"源文件拉取失败,HTTP状态码: {e.code}"}, status_code=e.code)
|
| 119 |
except Exception as e:
|
| 120 |
return JSONResponse(content={"error": f"代理下载时发生网络异常: {str(e)}"}, status_code=500)
|
router_users_auth.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# router_users_auth.py
|
| 2 |
+
# ==========================================
|
| 3 |
+
# 🔐 用户认证路由模块
|
| 4 |
+
# ==========================================
|
| 5 |
+
# 作用:处理用户登录、注册、密码重置、验证码发送等认证相关接口
|
| 6 |
+
# 关联文件:
|
| 7 |
+
# - 安全认证.py (密码哈希 + JWT 安全模块) 🔒 P0安全增强
|
| 8 |
+
# - verify_code_engine.py (验证码缓存与发送)
|
| 9 |
+
# - 数据库连接.py (JSON数据库读写)
|
| 10 |
+
# - models.py (Pydantic数据模型定义)
|
| 11 |
+
# ==========================================
|
| 12 |
+
|
| 13 |
+
from fastapi import APIRouter, HTTPException, BackgroundTasks, Request
|
| 14 |
+
import time
|
| 15 |
+
import re
|
| 16 |
+
import random
|
| 17 |
+
import json
|
| 18 |
+
import 数据库连接 as db
|
| 19 |
+
from models import UserRegister, UserLogin, SendCodeRequest
|
| 20 |
+
from verify_code_engine import VERIFY_CODES, send_email_code, send_sms_code
|
| 21 |
+
|
| 22 |
+
# 🔒 P0安全增强:导入密码哈希和 JWT 工具
|
| 23 |
+
from 安全认证 import hash_password, verify_password, create_token
|
| 24 |
+
|
| 25 |
+
# 创建子路由实例
|
| 26 |
+
router = APIRouter()
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# ==========================================
|
| 30 |
+
# 📤 发送验证码接口(异步后台任务版)
|
| 31 |
+
# ==========================================
|
| 32 |
+
# 作用:接收验证码发送请求,后台异步发送邮件/短信
|
| 33 |
+
# 关联:verify_code_engine.py 的 send_email_code / send_sms_code
|
| 34 |
+
# 前端调用:注册表单组件.js、重置密码表单组件.js
|
| 35 |
+
@router.post("/api/users/send-code")
|
| 36 |
+
async def send_verify_code(req: SendCodeRequest, bg_tasks: BackgroundTasks):
|
| 37 |
+
"""
|
| 38 |
+
发送验证码接口(异步版本)
|
| 39 |
+
|
| 40 |
+
请求参数:
|
| 41 |
+
- contact: 邮箱或手机号
|
| 42 |
+
- contact_type: "email" 或 "phone"
|
| 43 |
+
- action_type: "register" 注册 / "reset" 重置密码
|
| 44 |
+
- account: (仅重置密码时必填) 用户账号
|
| 45 |
+
"""
|
| 46 |
+
# 如果是重置密码,需要先验证账号与联系方式匹配
|
| 47 |
+
if req.action_type == "reset":
|
| 48 |
+
if not req.account:
|
| 49 |
+
raise HTTPException(status_code=400, detail="找回密码需先填写当前账号")
|
| 50 |
+
|
| 51 |
+
users_db = db.load_data("users.json", default_data={})
|
| 52 |
+
user = users_db.get(req.account)
|
| 53 |
+
if not user:
|
| 54 |
+
raise HTTPException(status_code=404, detail="该账号不存在")
|
| 55 |
+
|
| 56 |
+
# 校验联系方式是否与账号绑定的一致
|
| 57 |
+
if req.contact_type == "email" and user.get("email") != req.contact:
|
| 58 |
+
raise HTTPException(status_code=400, detail="填写的邮箱与该账号绑定的邮箱不一致")
|
| 59 |
+
if req.contact_type == "phone" and user.get("phone") != req.contact:
|
| 60 |
+
raise HTTPException(status_code=400, detail="填写的手机号与该账号绑定的手机号不一致")
|
| 61 |
+
|
| 62 |
+
# 生成6位随机验证码
|
| 63 |
+
code = str(random.randint(100000, 999999))
|
| 64 |
+
|
| 65 |
+
# 构建缓存键(联系方式_动作类型)
|
| 66 |
+
cache_key = f"{req.contact}_{req.action_type}"
|
| 67 |
+
|
| 68 |
+
# 将验证码存入内存缓存,有效期10分钟
|
| 69 |
+
VERIFY_CODES[cache_key] = {
|
| 70 |
+
"code": code,
|
| 71 |
+
"expires_at": int(time.time()) + 600 # 当前时间 + 600秒
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
# 根据联系方式类型,添加后台发送任务
|
| 75 |
+
if req.contact_type == "email":
|
| 76 |
+
bg_tasks.add_task(send_email_code, req.contact, code, req.action_type)
|
| 77 |
+
elif req.contact_type == "phone":
|
| 78 |
+
bg_tasks.add_task(send_sms_code, req.contact, code, req.action_type)
|
| 79 |
+
else:
|
| 80 |
+
raise HTTPException(status_code=400, detail="不支持的验证方式")
|
| 81 |
+
|
| 82 |
+
return {"status": "success", "message": "验证码发送请求已提交"}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# ==========================================
|
| 86 |
+
# 📤 发送验证码接口(同步版本,备用)
|
| 87 |
+
# ==========================================
|
| 88 |
+
# 作用:同步方式发送验证码,部分场景需要立即知道发送结果
|
| 89 |
+
# 关联:verify_code_engine.py 的 send_email_code
|
| 90 |
+
@router.post("/api/users/send_code")
|
| 91 |
+
async def send_code_api(req: SendCodeRequest):
|
| 92 |
+
"""发送验证码接口(同步版本,直接等待发送结果)"""
|
| 93 |
+
# 生成6位随机验证码
|
| 94 |
+
code = str(random.randint(100000, 999999))
|
| 95 |
+
key = f"{req.contact}_{req.action_type}"
|
| 96 |
+
|
| 97 |
+
# 存入缓存
|
| 98 |
+
VERIFY_CODES[key] = {
|
| 99 |
+
"code": code,
|
| 100 |
+
"expires_at": time.time() + 600
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
# 同步发送(会阻塞等待结果)
|
| 104 |
+
if req.contact_type == "email":
|
| 105 |
+
try:
|
| 106 |
+
send_email_code(req.contact, code, req.action_type)
|
| 107 |
+
return {"status": "success", "message": "验证码已成功发送至邮箱"}
|
| 108 |
+
except Exception as e:
|
| 109 |
+
raise HTTPException(status_code=500, detail=f"邮件发送失败: {str(e)}")
|
| 110 |
+
|
| 111 |
+
elif req.contact_type == "phone":
|
| 112 |
+
return {"status": "success", "message": "验证码已成功发送至手机"}
|
| 113 |
+
|
| 114 |
+
else:
|
| 115 |
+
raise HTTPException(status_code=400, detail="不支持的验证方式")
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
# ==========================================
|
| 119 |
+
# 📝 用户注册接口
|
| 120 |
+
# ==========================================
|
| 121 |
+
# 作用:新用户注册,需要验证邮箱/手机验证码
|
| 122 |
+
# 关联:
|
| 123 |
+
# - verify_code_engine.py 的 VERIFY_CODES (校验验证码)
|
| 124 |
+
# - 数据库连接.py (保存新用户到 users.json)
|
| 125 |
+
# - 前端 注册表单组件.js
|
| 126 |
+
@router.post("/api/users/register")
|
| 127 |
+
async def register_user(user: UserRegister):
|
| 128 |
+
"""
|
| 129 |
+
用户注册接口
|
| 130 |
+
|
| 131 |
+
请求参数:(UserRegister 模型)
|
| 132 |
+
- account: 账号(6-20位字母数字下划线)
|
| 133 |
+
- password: 密码(至少6位)
|
| 134 |
+
- name: 昵称
|
| 135 |
+
- email: 邮箱(与 phone 二选一)
|
| 136 |
+
- phone: 手机号(与 email 二选一)
|
| 137 |
+
- code: 验证码
|
| 138 |
+
- intro: 个人介绍(可选,最多100字)
|
| 139 |
+
"""
|
| 140 |
+
users_db = db.load_data("users.json", default_data={})
|
| 141 |
+
|
| 142 |
+
# ========== 第一步:查重检查 ==========
|
| 143 |
+
# 检查账号是否已存在
|
| 144 |
+
if user.account in users_db:
|
| 145 |
+
raise HTTPException(status_code=400, detail="该账号已被注册,请更换一个")
|
| 146 |
+
|
| 147 |
+
# 检查邮箱和手机号是否已被其他用户绑定
|
| 148 |
+
for existing_user in users_db.values():
|
| 149 |
+
if user.email and existing_user.get("email") == user.email:
|
| 150 |
+
raise HTTPException(status_code=400, detail="此邮箱已注册,请直接登录或找回密码")
|
| 151 |
+
if user.phone and existing_user.get("phone") == user.phone:
|
| 152 |
+
raise HTTPException(status_code=400, detail="该手机号已被绑定")
|
| 153 |
+
|
| 154 |
+
# ========== 第二步:验证码校验 ==========
|
| 155 |
+
# 根据注册方式构建缓存键
|
| 156 |
+
cache_key = f"{user.email}_register" if user.email else f"{user.phone}_register"
|
| 157 |
+
cached = VERIFY_CODES.get(cache_key)
|
| 158 |
+
|
| 159 |
+
# 兼容新老缓存格式(expires_at 或 expires)
|
| 160 |
+
expire_time = cached.get("expires_at", cached.get("expires", 0)) if cached else 0
|
| 161 |
+
|
| 162 |
+
# 校验验证码是否正确且未过期
|
| 163 |
+
if not cached or cached["code"] != user.code or time.time() > expire_time:
|
| 164 |
+
raise HTTPException(status_code=400, detail="验证码不正确或已过期")
|
| 165 |
+
|
| 166 |
+
# ========== 第三步:格式校验 ==========
|
| 167 |
+
if len(user.account) <= 5:
|
| 168 |
+
raise HTTPException(status_code=400, detail="账号必须大于5个字符")
|
| 169 |
+
if not re.match(r'^[a-zA-Z0-9_]{6,20}$', user.account):
|
| 170 |
+
raise HTTPException(status_code=400, detail="账号仅支持大小写英文字母、数字及下划线")
|
| 171 |
+
if len(user.password) < 6:
|
| 172 |
+
raise HTTPException(status_code=400, detail="密码必须大于等于6个字符")
|
| 173 |
+
if not re.match(r'^[a-zA-Z0-9!@#$%^&*()_+\-=\[\]{};\':"\\|,.<>\/?]{6,}$', user.password):
|
| 174 |
+
raise HTTPException(status_code=400, detail="密码包含不支持的特殊字符")
|
| 175 |
+
if user.intro and len(user.intro) > 100:
|
| 176 |
+
raise HTTPException(status_code=400, detail="个人介绍不能超过100个字符")
|
| 177 |
+
|
| 178 |
+
# ========== 第四步:保存新用户 ==========
|
| 179 |
+
# 验证通过后,清除已使用的验证码
|
| 180 |
+
VERIFY_CODES.pop(cache_key, None)
|
| 181 |
+
|
| 182 |
+
# 构建用户数据对象
|
| 183 |
+
new_user = user.dict()
|
| 184 |
+
new_user.pop("code", None) # 移除验证码字段,不存入数据库
|
| 185 |
+
|
| 186 |
+
# 🔒 P0安全增强:密码哈希化存储(不再存储明文密码)
|
| 187 |
+
new_user["password"] = hash_password(new_user["password"])
|
| 188 |
+
|
| 189 |
+
new_user.update({
|
| 190 |
+
"created_at": int(time.time()), # 注册时间戳
|
| 191 |
+
"followers": [], # 粉丝列表
|
| 192 |
+
"following": [], # 关注列表
|
| 193 |
+
"privacy": {"follows": False, "likes": False, "favorites": False, "downloads": False} # 隐私设置
|
| 194 |
+
})
|
| 195 |
+
|
| 196 |
+
# 保存到数据库
|
| 197 |
+
users_db[user.account] = new_user
|
| 198 |
+
db.save_data("users.json", users_db)
|
| 199 |
+
|
| 200 |
+
# 返回用户信息(排除密码)
|
| 201 |
+
return {"status": "success", "message": "注册成功", "data": {k: v for k, v in new_user.items() if k != "password"}}
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
# ==========================================
|
| 205 |
+
# 🔑 用户登录接口
|
| 206 |
+
# ==========================================
|
| 207 |
+
# 作用:验证账号密码,返回登录凭证
|
| 208 |
+
# 关联:
|
| 209 |
+
# - 数据库连接.py (读取 users.json 校验密码)
|
| 210 |
+
# - 前端 登录表单组件.js
|
| 211 |
+
@router.post("/api/users/login")
|
| 212 |
+
async def login_user(user: UserLogin):
|
| 213 |
+
"""
|
| 214 |
+
用户登录接口
|
| 215 |
+
|
| 216 |
+
请求参数:(UserLogin 模型)
|
| 217 |
+
- account: 账号
|
| 218 |
+
- password: 密码
|
| 219 |
+
"""
|
| 220 |
+
users_db = db.load_data("users.json", default_data={})
|
| 221 |
+
|
| 222 |
+
# 检查账号是否存在
|
| 223 |
+
if user.account not in users_db:
|
| 224 |
+
raise HTTPException(status_code=404, detail="账号不存在")
|
| 225 |
+
|
| 226 |
+
user_data = users_db[user.account]
|
| 227 |
+
stored_password = user_data.get("password", "")
|
| 228 |
+
|
| 229 |
+
# 🔒 P0安全增强:密码哈希验证
|
| 230 |
+
# 兼容处理:如果存储的是旧版明文密码(非64位哈希),自动升级为哈希
|
| 231 |
+
if len(stored_password) != 64:
|
| 232 |
+
# 旧版明文密码,直接比对
|
| 233 |
+
if stored_password != user.password:
|
| 234 |
+
raise HTTPException(status_code=401, detail="密码错误")
|
| 235 |
+
# 验证通过后,自动升级为哈希存储
|
| 236 |
+
user_data["password"] = hash_password(user.password)
|
| 237 |
+
db.save_data("users.json", users_db)
|
| 238 |
+
print(f"🔒 自动升级:用户 {user.account} 的密码已升级为哈希存储")
|
| 239 |
+
else:
|
| 240 |
+
# 新版哈希密码,使用安全验证
|
| 241 |
+
if not verify_password(user.password, stored_password):
|
| 242 |
+
raise HTTPException(status_code=401, detail="密码错误")
|
| 243 |
+
|
| 244 |
+
# 🔒 P0安全增强:生成 JWT Token(替代 mock_token)
|
| 245 |
+
token = create_token(user.account)
|
| 246 |
+
|
| 247 |
+
return {
|
| 248 |
+
"status": "success",
|
| 249 |
+
"token": token, # 🔒 JWT Token
|
| 250 |
+
"account": user.account,
|
| 251 |
+
"name": user_data["name"],
|
| 252 |
+
"avatar": user_data.get("avatarDataUrl", "https://via.placeholder.com/150")
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
# ==========================================
|
| 257 |
+
# 🔄 重置密码接口(万能兼容版)
|
| 258 |
+
# ==========================================
|
| 259 |
+
# 作用:通过邮箱/手机验证码重置用户密码
|
| 260 |
+
# 关联:
|
| 261 |
+
# - verify_code_engine.py 的 VERIFY_CODES (校验验证码)
|
| 262 |
+
# - 数据库连接.py (更新 users.json 中的密码)
|
| 263 |
+
# - 前端 重置密码表单组件.js
|
| 264 |
+
# 特点:万能解析器,兼容各种前端数据格式
|
| 265 |
+
@router.post("/api/users/reset_password")
|
| 266 |
+
async def reset_password(request: Request):
|
| 267 |
+
"""
|
| 268 |
+
重置密码接口(万能兼容版)
|
| 269 |
+
|
| 270 |
+
支持的请求格式:
|
| 271 |
+
- 标准 JSON
|
| 272 |
+
- 双重字符串化 JSON
|
| 273 |
+
- FormData
|
| 274 |
+
"""
|
| 275 |
+
# ========== 第一步:万能数据解析器 ==========
|
| 276 |
+
# 作用:兼容各种前端可能发送的数据格式
|
| 277 |
+
try:
|
| 278 |
+
data = await request.json()
|
| 279 |
+
# 处理前端可能造成的"双重字符串化"问题
|
| 280 |
+
if isinstance(data, str):
|
| 281 |
+
data = json.loads(data)
|
| 282 |
+
except:
|
| 283 |
+
# 降级尝试 FormData 格式
|
| 284 |
+
try:
|
| 285 |
+
form = await request.form()
|
| 286 |
+
data = dict(form)
|
| 287 |
+
except:
|
| 288 |
+
raise HTTPException(status_code=400, detail="请求数据解析失败,请检查网络")
|
| 289 |
+
|
| 290 |
+
if not isinstance(data, dict):
|
| 291 |
+
raise HTTPException(status_code=400, detail=f"前端数据格式异常,收到的是: {type(data).__name__}")
|
| 292 |
+
|
| 293 |
+
# ========== 第二步:万能字段提取器 ==========
|
| 294 |
+
# 作用:兼容前端可能使用的各种字段命名
|
| 295 |
+
account = data.get("account")
|
| 296 |
+
new_password = data.get("new_password") or data.get("password")
|
| 297 |
+
verify_contact = data.get("verifyContact") or data.get("verify_contact") or data.get("email") or data.get("phone")
|
| 298 |
+
verify_type = data.get("verifyType") or data.get("verify_type") or data.get("contact_type")
|
| 299 |
+
code = data.get("code")
|
| 300 |
+
|
| 301 |
+
# 参数完整性校验
|
| 302 |
+
if not all([account, new_password, verify_contact, verify_type, code]):
|
| 303 |
+
raise HTTPException(status_code=400, detail="缺失必要参数 (账号/密码/验证码/联系方式),请检查表单")
|
| 304 |
+
|
| 305 |
+
# ========== 第三步:核心业务逻辑 ==========
|
| 306 |
+
users_db = db.load_data("users.json", default_data={})
|
| 307 |
+
|
| 308 |
+
# 检查用户是否存在
|
| 309 |
+
if account not in users_db:
|
| 310 |
+
raise HTTPException(status_code=404, detail="该用户不存在")
|
| 311 |
+
user = users_db[account]
|
| 312 |
+
|
| 313 |
+
# 校验联系方式是否与账号绑定的一致
|
| 314 |
+
if verify_type == "email" and user.get("email") != verify_contact:
|
| 315 |
+
raise HTTPException(status_code=400, detail="填写的邮箱与该账号绑定的邮箱不匹配")
|
| 316 |
+
if verify_type == "phone" and user.get("phone") != verify_contact:
|
| 317 |
+
raise HTTPException(status_code=400, detail="填写的手机号与该账号绑定的手机号不匹配")
|
| 318 |
+
|
| 319 |
+
# 校验验证码
|
| 320 |
+
cache_key = f"{verify_contact}_reset"
|
| 321 |
+
cached = VERIFY_CODES.get(cache_key)
|
| 322 |
+
expire_time = cached.get("expires_at", cached.get("expires", 0)) if cached else 0
|
| 323 |
+
|
| 324 |
+
if not cached or cached["code"] != code or time.time() > expire_time:
|
| 325 |
+
raise HTTPException(status_code=400, detail="验证码不正确或已过期")
|
| 326 |
+
|
| 327 |
+
# 校验新密码格式
|
| 328 |
+
if len(new_password) < 6:
|
| 329 |
+
raise HTTPException(status_code=400, detail="新密码必须大于等于6个字符")
|
| 330 |
+
if not re.match(r'^[a-zA-Z0-9!@#$%^&*()_+\-=\[\]{};\':"\\|,.<>\/?]{6,}$', new_password):
|
| 331 |
+
raise HTTPException(status_code=400, detail="新密码包含不支持的特殊字符")
|
| 332 |
+
|
| 333 |
+
# ========== 第四步:更新密码并保存 ==========
|
| 334 |
+
VERIFY_CODES.pop(cache_key, None) # 清除已使用的验证码
|
| 335 |
+
|
| 336 |
+
# 🔒 P0安全增强:新密码哈希化存储
|
| 337 |
+
user["password"] = hash_password(new_password)
|
| 338 |
+
|
| 339 |
+
db.save_data("users.json", users_db)
|
| 340 |
+
|
| 341 |
+
return {"status": "success", "message": "密码修改成功"}
|
router_users_profile.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# router_users_profile.py
|
| 2 |
+
# ==========================================
|
| 3 |
+
# 👤 用户资料路由模块
|
| 4 |
+
# ==========================================
|
| 5 |
+
# 作用:处理用户资料的获取和更新
|
| 6 |
+
# 关联文件:
|
| 7 |
+
# - 数据库连接.py (JSON数据库读写 users.json, items.json)
|
| 8 |
+
# - models.py (UserUpdate 数据模型)
|
| 9 |
+
# - router_users.py (主路由聚合此模块)
|
| 10 |
+
# 前端调用:
|
| 11 |
+
# - 个人中心视图.js (获取/展示用户资料)
|
| 12 |
+
# - 个人设置表单组件.js (更新用户资料)
|
| 13 |
+
# ==========================================
|
| 14 |
+
|
| 15 |
+
from fastapi import APIRouter, HTTPException
|
| 16 |
+
import 数据库连接 as db
|
| 17 |
+
from models import UserUpdate
|
| 18 |
+
|
| 19 |
+
# 创建子路由实例
|
| 20 |
+
router = APIRouter()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# ==========================================
|
| 24 |
+
# 📖 获取用户资料接口
|
| 25 |
+
# ==========================================
|
| 26 |
+
# 作用:根据账号获取用户的完整资料信息
|
| 27 |
+
# 关联:
|
| 28 |
+
# - users.json (存储用户基本信息)
|
| 29 |
+
# - items.json (统计用户发布的内容获得的互动数据)
|
| 30 |
+
# 前端调用:
|
| 31 |
+
# - 个人中心视图.js 的 fetchUserProfile()
|
| 32 |
+
# - 列表卡片组件.js 显示作者信息时也会调用
|
| 33 |
+
@router.get("/api/users/{account}")
|
| 34 |
+
async def get_user_profile(account: str):
|
| 35 |
+
"""
|
| 36 |
+
获取用户资料接口
|
| 37 |
+
|
| 38 |
+
路径参数:
|
| 39 |
+
- account: 用户账号
|
| 40 |
+
返回数据:
|
| 41 |
+
- 用户基本信息(排除密码)
|
| 42 |
+
- receivedLikes: 收到的总点赞数
|
| 43 |
+
- receivedFavorites: 收到的总收藏数
|
| 44 |
+
- receivedUses: 发布内容被使用的总次数
|
| 45 |
+
"""
|
| 46 |
+
# 加载用户数据库
|
| 47 |
+
users_db = db.load_data("users.json", default_data={})
|
| 48 |
+
|
| 49 |
+
# 检查用户是否存在
|
| 50 |
+
if account not in users_db:
|
| 51 |
+
raise HTTPException(status_code=404, detail="用户不存在")
|
| 52 |
+
|
| 53 |
+
user_data = users_db[account]
|
| 54 |
+
|
| 55 |
+
# ========== 统计用户作品获得的互动数据 ==========
|
| 56 |
+
# 作用:遍历所有内容,统计该用户发布的内容收到的互动
|
| 57 |
+
# 关联:items.json 存储所有发布的工具/应用/推荐内容
|
| 58 |
+
items_db = db.load_data("items.json", default_data=[])
|
| 59 |
+
user_items = [item for item in items_db if item.get("author") == account]
|
| 60 |
+
|
| 61 |
+
# 累加所有作品的互动数据
|
| 62 |
+
user_data["receivedLikes"] = sum(item.get("likes", 0) for item in user_items)
|
| 63 |
+
user_data["receivedFavorites"] = sum(item.get("favorites", 0) for item in user_items)
|
| 64 |
+
user_data["receivedUses"] = sum(item.get("uses", 0) for item in user_items)
|
| 65 |
+
|
| 66 |
+
# 返回用户数据(排除敏感的密码字段)
|
| 67 |
+
return {"status": "success", "data": {k: v for k, v in user_data.items() if k != "password"}}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# ==========================================
|
| 71 |
+
# ✏️ 更新用户资料接口
|
| 72 |
+
# ==========================================
|
| 73 |
+
# 作用:更新用户的个人资料(昵称、头像、简介等)
|
| 74 |
+
# 关联:
|
| 75 |
+
# - users.json (保存更新后的用户信息)
|
| 76 |
+
# - models.py 的 UserUpdate 模型定义可更新的字段
|
| 77 |
+
# 前端调用:
|
| 78 |
+
# - 个人设置表单组件.js 的 handleSaveProfile()
|
| 79 |
+
@router.put("/api/users/{account}")
|
| 80 |
+
async def update_user_profile(account: str, update_data: UserUpdate):
|
| 81 |
+
"""
|
| 82 |
+
更新用户资料接口
|
| 83 |
+
|
| 84 |
+
路径参数:
|
| 85 |
+
- account: 用户账号
|
| 86 |
+
请求参数:(UserUpdate 模型,所有字段可选)
|
| 87 |
+
- name: 昵称
|
| 88 |
+
- intro: 个人介绍(最多100字)
|
| 89 |
+
- avatarDataUrl: 头像(Base64格式)
|
| 90 |
+
- gender: 性别 (male/female/secret)
|
| 91 |
+
- age: 年龄
|
| 92 |
+
- country: 国家
|
| 93 |
+
- region: 地区
|
| 94 |
+
"""
|
| 95 |
+
# 加载用户数据库
|
| 96 |
+
users_db = db.load_data("users.json", default_data={})
|
| 97 |
+
|
| 98 |
+
# 检查用户是否存在
|
| 99 |
+
if account not in users_db:
|
| 100 |
+
raise HTTPException(status_code=404, detail="用户不存在")
|
| 101 |
+
|
| 102 |
+
# ========== 字段校验 ==========
|
| 103 |
+
# 个人介绍长度限制
|
| 104 |
+
if update_data.intro and len(update_data.intro) > 100:
|
| 105 |
+
raise HTTPException(status_code=400, detail="个人介绍不能超过100个字符")
|
| 106 |
+
|
| 107 |
+
# ========== 更新用户数据 ==========
|
| 108 |
+
user = users_db[account]
|
| 109 |
+
|
| 110 |
+
# 遍历请求中的字段,只更新非空值
|
| 111 |
+
# exclude_unset=True 表示只包含请求中明确传递的字段
|
| 112 |
+
for k, v in update_data.dict(exclude_unset=True).items():
|
| 113 |
+
if v is not None:
|
| 114 |
+
user[k] = v
|
| 115 |
+
|
| 116 |
+
# 保存更新后的数据
|
| 117 |
+
db.save_data("users.json", users_db)
|
| 118 |
+
|
| 119 |
+
# 返回更新后的用户数据(排除密码)
|
| 120 |
+
return {"status": "success", "data": {k: v for k, v in user.items() if k != "password"}}
|
router_users_social.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# router_users_social.py
|
| 2 |
+
# ==========================================
|
| 3 |
+
# 🤝 用户社交路由模块
|
| 4 |
+
# ==========================================
|
| 5 |
+
# 作用:处理用户间的社交互动(关注/取关)和隐私设置
|
| 6 |
+
# 关联文件:
|
| 7 |
+
# - 数据库连接.py (JSON数据库读写 users.json)
|
| 8 |
+
# - notifications.py (发送关注通知)
|
| 9 |
+
# - models.py (FollowToggle, PrivacySettings 数据模型)
|
| 10 |
+
# - router_users.py (主路由聚合此模块)
|
| 11 |
+
# 前端调用:
|
| 12 |
+
# - 个人中心视图.js (关注/取关按钮)
|
| 13 |
+
# - 个人设置表单组件.js (隐私设置)
|
| 14 |
+
# ==========================================
|
| 15 |
+
|
| 16 |
+
from fastapi import APIRouter, HTTPException
|
| 17 |
+
import 数据库连接 as db
|
| 18 |
+
from notifications import add_notification
|
| 19 |
+
from models import FollowToggle, PrivacySettings
|
| 20 |
+
|
| 21 |
+
# 创建子路由实例
|
| 22 |
+
router = APIRouter()
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ==========================================
|
| 26 |
+
# 👥 关注/取消关注接口
|
| 27 |
+
# ==========================================
|
| 28 |
+
# 作用:用户A关注或取消关注用户B
|
| 29 |
+
# 关联:
|
| 30 |
+
# - users.json 的 followers 和 following 字段
|
| 31 |
+
# - notifications.py 的 add_notification() (发送关注通知)
|
| 32 |
+
# 数据结构:
|
| 33 |
+
# - 用户A.following: 存储A关注的人的账号列表
|
| 34 |
+
# - 用户B.followers: 存储关注B的人的账号列表
|
| 35 |
+
# 前端调用:
|
| 36 |
+
# - 个人中心视图.js 的 handleFollowToggle()
|
| 37 |
+
@router.post("/api/users/follow")
|
| 38 |
+
async def toggle_follow(follow: FollowToggle):
|
| 39 |
+
"""
|
| 40 |
+
关注/取消关注接口
|
| 41 |
+
|
| 42 |
+
请求参数:(FollowToggle 模型)
|
| 43 |
+
- user_id: 当前操作用户的账号(执行关注动作的人)
|
| 44 |
+
- target_account: 被关注/取关的目标用户账号
|
| 45 |
+
- is_active: True=关注, False=取消关注
|
| 46 |
+
"""
|
| 47 |
+
# 加载用户数据库
|
| 48 |
+
users_db = db.load_data("users.json", default_data={})
|
| 49 |
+
|
| 50 |
+
# ========== 用户存在性校验 ==========
|
| 51 |
+
if follow.target_account not in users_db or follow.user_id not in users_db:
|
| 52 |
+
raise HTTPException(status_code=404, detail="用户不存在")
|
| 53 |
+
|
| 54 |
+
# 获取双方的关注/粉丝列表
|
| 55 |
+
# setdefault 确保字段存在,不存在则初始化为空列表
|
| 56 |
+
target_followers = users_db[follow.target_account].setdefault("followers", [])
|
| 57 |
+
current_following = users_db[follow.user_id].setdefault("following", [])
|
| 58 |
+
|
| 59 |
+
# ========== 执行关注或取关操作 ==========
|
| 60 |
+
if follow.is_active:
|
| 61 |
+
# === 关注操作 ===
|
| 62 |
+
# 将当前用户添加到目标用户的粉丝列表
|
| 63 |
+
if follow.user_id not in target_followers:
|
| 64 |
+
target_followers.append(follow.user_id)
|
| 65 |
+
|
| 66 |
+
# 发送关注通知给目标用户
|
| 67 |
+
# 关联:notifications.py 的 add_notification()
|
| 68 |
+
add_notification(follow.target_account, {
|
| 69 |
+
"type": "follow",
|
| 70 |
+
"from_user": follow.user_id
|
| 71 |
+
})
|
| 72 |
+
|
| 73 |
+
# 将目标用户添加到当前用户的关注列表
|
| 74 |
+
if follow.target_account not in current_following:
|
| 75 |
+
current_following.append(follow.target_account)
|
| 76 |
+
else:
|
| 77 |
+
# === 取消关注操作 ===
|
| 78 |
+
# 从目标用户的粉丝列表中移除当前用户
|
| 79 |
+
if follow.user_id in target_followers:
|
| 80 |
+
target_followers.remove(follow.user_id)
|
| 81 |
+
|
| 82 |
+
# 从当前用户的关注列表中移除目标用户
|
| 83 |
+
if follow.target_account in current_following:
|
| 84 |
+
current_following.remove(follow.target_account)
|
| 85 |
+
|
| 86 |
+
# 保存更新后的数据
|
| 87 |
+
db.save_data("users.json", users_db)
|
| 88 |
+
return {"status": "success"}
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
# ==========================================
|
| 92 |
+
# 🔒 隐私设置接口
|
| 93 |
+
# ==========================================
|
| 94 |
+
# 作用:更新用户的隐私偏好设置
|
| 95 |
+
# 关联:
|
| 96 |
+
# - users.json 的 privacy 字段
|
| 97 |
+
# - models.py 的 PrivacySettings 模型
|
| 98 |
+
# 隐私选项说明:
|
| 99 |
+
# - follows: 是否公开关注列表
|
| 100 |
+
# - likes: 是否公开点赞记录
|
| 101 |
+
# - favorites: 是否公开收藏记录
|
| 102 |
+
# - downloads: 是否公开下载/使用记录
|
| 103 |
+
# 前端调用:
|
| 104 |
+
# - 个人设置表单组件.js 的隐私设置区域
|
| 105 |
+
@router.put("/api/users/{account}/privacy")
|
| 106 |
+
async def update_privacy(account: str, privacy: PrivacySettings):
|
| 107 |
+
"""
|
| 108 |
+
更新隐私设置接口
|
| 109 |
+
|
| 110 |
+
路径参数:
|
| 111 |
+
- account: 用户账号
|
| 112 |
+
请求参数:(PrivacySettings 模型)
|
| 113 |
+
- follows: 是否隐藏关注列表 (True=隐藏)
|
| 114 |
+
- likes: 是否隐藏点赞记录 (True=隐藏)
|
| 115 |
+
- favorites: 是否隐藏收藏记录 (True=隐藏)
|
| 116 |
+
- downloads: 是否隐藏下载记录 (True=隐藏)
|
| 117 |
+
"""
|
| 118 |
+
# 加载用户数据库
|
| 119 |
+
users_db = db.load_data("users.json", default_data={})
|
| 120 |
+
|
| 121 |
+
# 检查用户是否存在
|
| 122 |
+
if account not in users_db:
|
| 123 |
+
raise HTTPException(status_code=404, detail="用户不存在")
|
| 124 |
+
|
| 125 |
+
# 更新用户的隐私设置
|
| 126 |
+
users_db[account]["privacy"] = privacy.dict()
|
| 127 |
+
|
| 128 |
+
# 保存更新后的数据
|
| 129 |
+
db.save_data("users.json", users_db)
|
| 130 |
+
return {"status": "success"}
|
router_wallet.py
CHANGED
|
@@ -1,4 +1,14 @@
|
|
| 1 |
# router_wallet.py
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 3 |
from fastapi.responses import Response
|
| 4 |
from sqlalchemy.orm import Session
|
|
@@ -6,11 +16,15 @@ import time
|
|
| 6 |
import uuid
|
| 7 |
import hashlib
|
| 8 |
import os
|
|
|
|
| 9 |
from database_sql import get_db
|
| 10 |
from models_sql import Wallet, Transaction, Ownership
|
| 11 |
from models import RechargeRequest, WithdrawRequest, PurchaseRequest, TipRequest
|
| 12 |
import 数据库连接 as json_db
|
| 13 |
|
|
|
|
|
|
|
|
|
|
| 14 |
router = APIRouter()
|
| 15 |
|
| 16 |
try:
|
|
@@ -103,12 +117,21 @@ async def check_order(order_id: str, db: Session = Depends(get_db)):
|
|
| 103 |
@router.get("/api/wallet/{account}")
|
| 104 |
async def get_wallet(account: str, db: Session = Depends(get_db)):
|
| 105 |
wallet = db.query(Wallet).filter(Wallet.account == account).first()
|
| 106 |
-
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
return {
|
| 109 |
-
"
|
| 110 |
-
"
|
| 111 |
-
"
|
|
|
|
|
|
|
|
|
|
| 112 |
}
|
| 113 |
|
| 114 |
@router.post("/api/wallet/purchase")
|
|
@@ -149,9 +172,10 @@ async def purchase_item(req: PurchaseRequest, db: Session = Depends(get_db)):
|
|
| 149 |
prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
|
| 150 |
tx_hash = calculate_tx_hash(tx_id, req.account, "PURCHASE", -price, prev_hash)
|
| 151 |
|
|
|
|
| 152 |
new_tx = Transaction(
|
| 153 |
tx_id=tx_id, account=req.account, tx_type="PURCHASE", amount=-price,
|
| 154 |
-
|
| 155 |
)
|
| 156 |
db.add(new_tx)
|
| 157 |
db.commit()
|
|
@@ -162,40 +186,81 @@ async def purchase_item(req: PurchaseRequest, db: Session = Depends(get_db)):
|
|
| 162 |
async def tip_user(req: TipRequest, db: Session = Depends(get_db)):
|
| 163 |
if req.amount <= 0:
|
| 164 |
raise HTTPException(status_code=400, detail="打赏金额必须大于0")
|
|
|
|
|
|
|
| 165 |
|
| 166 |
sender_wallet = db.query(Wallet).filter(Wallet.account == req.sender_account).with_for_update().first()
|
| 167 |
-
if not sender_wallet or sender_wallet.balance < req.amount:
|
| 168 |
-
raise HTTPException(status_code=402, detail="余额不足")
|
| 169 |
-
|
| 170 |
target_wallet = db.query(Wallet).filter(Wallet.account == req.target_account).with_for_update().first()
|
|
|
|
|
|
|
|
|
|
| 171 |
if not target_wallet:
|
| 172 |
-
target_wallet = Wallet(account=req.target_account)
|
| 173 |
db.add(target_wallet)
|
| 174 |
|
| 175 |
sender_wallet.balance -= req.amount
|
| 176 |
target_wallet.tip_balance += req.amount
|
| 177 |
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
|
| 181 |
-
tx_hash = calculate_tx_hash(tx_id, req.sender_account, "TIP", -req.amount, prev_hash)
|
| 182 |
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
db.commit()
|
| 189 |
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
"from_user": "system",
|
| 195 |
-
"target_item_title": "您的主页",
|
| 196 |
-
"content": f"🎉 {display_sender} 给您打赏了 {req.amount} 积分!"
|
| 197 |
-
})
|
| 198 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
return {"status": "success", "balance": sender_wallet.balance}
|
| 200 |
|
| 201 |
@router.post("/api/wallet/withdraw")
|
|
@@ -208,31 +273,65 @@ async def withdraw(req: WithdrawRequest, db: Session = Depends(get_db)):
|
|
| 208 |
wallet = db.query(Wallet).filter(Wallet.account == req.account).with_for_update().first()
|
| 209 |
if not wallet:
|
| 210 |
raise HTTPException(status_code=400, detail="钱包不存在")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
|
| 212 |
total_withdrawable = wallet.earn_balance + wallet.tip_balance
|
| 213 |
-
if
|
| 214 |
raise HTTPException(status_code=400, detail="可提现余额不足")
|
| 215 |
|
| 216 |
-
if
|
| 217 |
-
wallet.earn_balance -=
|
| 218 |
else:
|
| 219 |
-
remaining =
|
| 220 |
wallet.earn_balance = 0
|
| 221 |
wallet.tip_balance -= remaining
|
| 222 |
|
| 223 |
-
wallet.frozen_balance +=
|
| 224 |
|
| 225 |
tx_id = f"WD_{int(time.time())}_{uuid.uuid4().hex[:6]}"
|
| 226 |
last_tx = db.query(Transaction).filter(Transaction.account == req.account).order_by(Transaction.created_at.desc()).first()
|
| 227 |
prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
|
| 228 |
-
tx_hash = calculate_tx_hash(tx_id, req.account, "WITHDRAW", -
|
| 229 |
|
| 230 |
new_tx = Transaction(
|
| 231 |
-
tx_id=tx_id, account=req.account, tx_type="WITHDRAW", amount=-
|
| 232 |
prev_hash=prev_hash, tx_hash=tx_hash
|
| 233 |
)
|
| 234 |
db.add(new_tx)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
db.commit()
|
| 236 |
|
| 237 |
del VERIFY_CODES[key]
|
| 238 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# router_wallet.py
|
| 2 |
+
# ==========================================
|
| 3 |
+
# 💰 钱包与交易路由模块
|
| 4 |
+
# ==========================================
|
| 5 |
+
# 作用:处理充值、提现、购买、打赏等资金操作
|
| 6 |
+
# 关联文件:
|
| 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
|
| 13 |
from fastapi.responses import Response
|
| 14 |
from sqlalchemy.orm import Session
|
|
|
|
| 16 |
import uuid
|
| 17 |
import hashlib
|
| 18 |
import os
|
| 19 |
+
import datetime
|
| 20 |
from database_sql import get_db
|
| 21 |
from models_sql import Wallet, Transaction, Ownership
|
| 22 |
from models import RechargeRequest, WithdrawRequest, PurchaseRequest, TipRequest
|
| 23 |
import 数据库连接 as json_db
|
| 24 |
|
| 25 |
+
# 🔐 导入验证码缓存 (提现时需要验证)
|
| 26 |
+
from verify_code_engine import VERIFY_CODES
|
| 27 |
+
|
| 28 |
router = APIRouter()
|
| 29 |
|
| 30 |
try:
|
|
|
|
| 117 |
@router.get("/api/wallet/{account}")
|
| 118 |
async def get_wallet(account: str, db: Session = Depends(get_db)):
|
| 119 |
wallet = db.query(Wallet).filter(Wallet.account == account).first()
|
| 120 |
+
|
| 121 |
+
# 🚀 新增:计算历史累计提现总积分 (用于前端 100元免责额度的手续费计算)
|
| 122 |
+
withdrawals = db.query(Transaction).filter(Transaction.account == account, Transaction.tx_type == 'WITHDRAW').all()
|
| 123 |
+
total_withdrawn = sum(w.amount for w in withdrawals)
|
| 124 |
+
|
| 125 |
+
if not wallet:
|
| 126 |
+
return {"status": "success", "balance": 0, "earn_balance": 0, "tip_balance": 0, "frozen_balance": 0, "total_withdrawn": total_withdrawn}
|
| 127 |
+
|
| 128 |
return {
|
| 129 |
+
"status": "success",
|
| 130 |
+
"balance": wallet.balance,
|
| 131 |
+
"earn_balance": wallet.earn_balance,
|
| 132 |
+
"tip_balance": wallet.tip_balance,
|
| 133 |
+
"frozen_balance": wallet.frozen_balance,
|
| 134 |
+
"total_withdrawn": total_withdrawn # 暴露给前端
|
| 135 |
}
|
| 136 |
|
| 137 |
@router.post("/api/wallet/purchase")
|
|
|
|
| 172 |
prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
|
| 173 |
tx_hash = calculate_tx_hash(tx_id, req.account, "PURCHASE", -price, prev_hash)
|
| 174 |
|
| 175 |
+
# 创建交易记录 (字段名为 related_account,与 models_sql.py 中 Transaction 模型保持一致)
|
| 176 |
new_tx = Transaction(
|
| 177 |
tx_id=tx_id, account=req.account, tx_type="PURCHASE", amount=-price,
|
| 178 |
+
related_account=seller_account, prev_hash=prev_hash, tx_hash=tx_hash
|
| 179 |
)
|
| 180 |
db.add(new_tx)
|
| 181 |
db.commit()
|
|
|
|
| 186 |
async def tip_user(req: TipRequest, db: Session = Depends(get_db)):
|
| 187 |
if req.amount <= 0:
|
| 188 |
raise HTTPException(status_code=400, detail="打赏金额必须大于0")
|
| 189 |
+
if req.sender_account == req.target_account:
|
| 190 |
+
raise HTTPException(status_code=400, detail="不能打赏给自己")
|
| 191 |
|
| 192 |
sender_wallet = db.query(Wallet).filter(Wallet.account == req.sender_account).with_for_update().first()
|
|
|
|
|
|
|
|
|
|
| 193 |
target_wallet = db.query(Wallet).filter(Wallet.account == req.target_account).with_for_update().first()
|
| 194 |
+
|
| 195 |
+
if not sender_wallet or sender_wallet.balance < req.amount:
|
| 196 |
+
raise HTTPException(status_code=400, detail="余额不足")
|
| 197 |
if not target_wallet:
|
| 198 |
+
target_wallet = Wallet(account=req.target_account, balance=0, earn_balance=0, tip_balance=0, frozen_balance=0)
|
| 199 |
db.add(target_wallet)
|
| 200 |
|
| 201 |
sender_wallet.balance -= req.amount
|
| 202 |
target_wallet.tip_balance += req.amount
|
| 203 |
|
| 204 |
+
tx_id_sender = f"TIP_OUT_{int(time.time())}_{uuid.uuid4().hex[:6]}"
|
| 205 |
+
tx_id_target = f"TIP_IN_{int(time.time())}_{uuid.uuid4().hex[:6]}"
|
|
|
|
|
|
|
| 206 |
|
| 207 |
+
# 记录交易
|
| 208 |
+
last_tx_sender = db.query(Transaction).filter(Transaction.account == req.sender_account).order_by(Transaction.created_at.desc()).first()
|
| 209 |
+
last_tx_target = db.query(Transaction).filter(Transaction.account == req.target_account).order_by(Transaction.created_at.desc()).first()
|
| 210 |
+
prev_hash_sender = last_tx_sender.tx_hash if last_tx_sender else "GENESIS_HASH"
|
| 211 |
+
prev_hash_target = last_tx_target.tx_hash if last_tx_target else "GENESIS_HASH"
|
| 212 |
+
|
| 213 |
+
# 发送方交易记录 (字段名为 related_account,与 models_sql.py 中 Transaction 模型保持一致)
|
| 214 |
+
tx_sender = Transaction(tx_id=tx_id_sender, account=req.sender_account, tx_type="TIP_OUT", amount=-req.amount,
|
| 215 |
+
related_account=req.target_account, prev_hash=prev_hash_sender,
|
| 216 |
+
tx_hash=calculate_tx_hash(tx_id_sender, req.sender_account, "TIP_OUT", -req.amount, prev_hash_sender))
|
| 217 |
+
|
| 218 |
+
# 接收方交易记录
|
| 219 |
+
tx_target = Transaction(tx_id=tx_id_target, account=req.target_account, tx_type="TIP_IN", amount=req.amount,
|
| 220 |
+
related_account=req.sender_account, prev_hash=prev_hash_target,
|
| 221 |
+
tx_hash=calculate_tx_hash(tx_id_target, req.target_account, "TIP_IN", req.amount, prev_hash_target))
|
| 222 |
+
|
| 223 |
+
db.add(tx_sender)
|
| 224 |
+
db.add(tx_target)
|
| 225 |
db.commit()
|
| 226 |
|
| 227 |
+
# 🚀 核心新增:记录打赏榜单和月度收益趋势 (写入 JSON 以供高频读取)
|
| 228 |
+
users_db = json_db.load_data("users.json", default_data={})
|
| 229 |
+
items_db = json_db.load_data("items.json", default_data=[])
|
| 230 |
+
current_month = datetime.date.today().strftime("%Y-%m")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
|
| 232 |
+
# 1. 更新创作者的总打赏榜与收益趋势
|
| 233 |
+
if req.target_account in users_db:
|
| 234 |
+
u = users_db[req.target_account]
|
| 235 |
+
if "tip_history" not in u: u["tip_history"] = {}
|
| 236 |
+
u["tip_history"][current_month] = u["tip_history"].get(current_month, 0) + req.amount
|
| 237 |
+
|
| 238 |
+
if "tip_board" not in u: u["tip_board"] = []
|
| 239 |
+
sender_entry = next((x for x in u["tip_board"] if x["account"] == req.sender_account), None)
|
| 240 |
+
if sender_entry:
|
| 241 |
+
sender_entry["amount"] += req.amount
|
| 242 |
+
else:
|
| 243 |
+
u["tip_board"].append({"account": req.sender_account, "amount": req.amount, "is_anon": req.is_anonymous})
|
| 244 |
+
u["tip_board"] = sorted(u["tip_board"], key=lambda x: x["amount"], reverse=True)
|
| 245 |
+
json_db.save_data("users.json", users_db)
|
| 246 |
+
|
| 247 |
+
# 2. 如果关联了具体作品,更新作品详情的专属打赏榜与收益趋势
|
| 248 |
+
if req.item_id:
|
| 249 |
+
for item in items_db:
|
| 250 |
+
if item["id"] == req.item_id:
|
| 251 |
+
if "tip_history" not in item: item["tip_history"] = {}
|
| 252 |
+
item["tip_history"][current_month] = item["tip_history"].get(current_month, 0) + req.amount
|
| 253 |
+
|
| 254 |
+
if "tip_board" not in item: item["tip_board"] = []
|
| 255 |
+
sender_entry = next((x for x in item["tip_board"] if x["account"] == req.sender_account), None)
|
| 256 |
+
if sender_entry:
|
| 257 |
+
sender_entry["amount"] += req.amount
|
| 258 |
+
else:
|
| 259 |
+
item["tip_board"].append({"account": req.sender_account, "amount": req.amount, "is_anon": req.is_anonymous})
|
| 260 |
+
item["tip_board"] = sorted(item["tip_board"], key=lambda x: x["amount"], reverse=True)
|
| 261 |
+
json_db.save_data("items.json", items_db)
|
| 262 |
+
break
|
| 263 |
+
|
| 264 |
return {"status": "success", "balance": sender_wallet.balance}
|
| 265 |
|
| 266 |
@router.post("/api/wallet/withdraw")
|
|
|
|
| 273 |
wallet = db.query(Wallet).filter(Wallet.account == req.account).with_for_update().first()
|
| 274 |
if not wallet:
|
| 275 |
raise HTTPException(status_code=400, detail="钱包不存在")
|
| 276 |
+
|
| 277 |
+
# 🚀 核心新增:阶梯手续费计算 (与前端逻辑统一)
|
| 278 |
+
# 查询历史累计提现总额 (WITHDRAW 类型的 amount 是负数,需要取绝对值)
|
| 279 |
+
withdrawals = db.query(Transaction).filter(
|
| 280 |
+
Transaction.account == req.account,
|
| 281 |
+
Transaction.tx_type == 'WITHDRAW'
|
| 282 |
+
).all()
|
| 283 |
+
total_withdrawn = abs(sum(w.amount for w in withdrawals))
|
| 284 |
+
|
| 285 |
+
# 手续费规则:100元 = 10000积分 免手续费额度,超出部分收取 10%
|
| 286 |
+
free_quota = max(0, 10000 - total_withdrawn) # 剩余免责额度
|
| 287 |
+
fee_amount = 0
|
| 288 |
+
if req.amount > free_quota:
|
| 289 |
+
fee_amount = int((req.amount - free_quota) * 0.10) # 只对超出部分收 10%
|
| 290 |
+
|
| 291 |
+
actual_withdraw = req.amount # 从账户扣除的金额
|
| 292 |
+
net_amount = req.amount - fee_amount # 用户实际到账金额
|
| 293 |
|
| 294 |
total_withdrawable = wallet.earn_balance + wallet.tip_balance
|
| 295 |
+
if actual_withdraw > total_withdrawable:
|
| 296 |
raise HTTPException(status_code=400, detail="可提现余额不足")
|
| 297 |
|
| 298 |
+
if actual_withdraw <= wallet.earn_balance:
|
| 299 |
+
wallet.earn_balance -= actual_withdraw
|
| 300 |
else:
|
| 301 |
+
remaining = actual_withdraw - wallet.earn_balance
|
| 302 |
wallet.earn_balance = 0
|
| 303 |
wallet.tip_balance -= remaining
|
| 304 |
|
| 305 |
+
wallet.frozen_balance += net_amount # 冻结的是到账金额,非手续费部分
|
| 306 |
|
| 307 |
tx_id = f"WD_{int(time.time())}_{uuid.uuid4().hex[:6]}"
|
| 308 |
last_tx = db.query(Transaction).filter(Transaction.account == req.account).order_by(Transaction.created_at.desc()).first()
|
| 309 |
prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
|
| 310 |
+
tx_hash = calculate_tx_hash(tx_id, req.account, "WITHDRAW", -actual_withdraw, prev_hash)
|
| 311 |
|
| 312 |
new_tx = Transaction(
|
| 313 |
+
tx_id=tx_id, account=req.account, tx_type="WITHDRAW", amount=-actual_withdraw,
|
| 314 |
prev_hash=prev_hash, tx_hash=tx_hash
|
| 315 |
)
|
| 316 |
db.add(new_tx)
|
| 317 |
+
|
| 318 |
+
# 🚀 如果有手续费,额外记录一笔手续费交易
|
| 319 |
+
if fee_amount > 0:
|
| 320 |
+
fee_tx_id = f"FEE_{int(time.time())}_{uuid.uuid4().hex[:6]}"
|
| 321 |
+
fee_tx_hash = calculate_tx_hash(fee_tx_id, req.account, "WITHDRAW_FEE", -fee_amount, tx_hash)
|
| 322 |
+
fee_tx = Transaction(
|
| 323 |
+
tx_id=fee_tx_id, account=req.account, tx_type="WITHDRAW_FEE", amount=-fee_amount,
|
| 324 |
+
prev_hash=tx_hash, tx_hash=fee_tx_hash
|
| 325 |
+
)
|
| 326 |
+
db.add(fee_tx)
|
| 327 |
+
|
| 328 |
db.commit()
|
| 329 |
|
| 330 |
del VERIFY_CODES[key]
|
| 331 |
+
return {
|
| 332 |
+
"status": "success",
|
| 333 |
+
"withdraw_amount": actual_withdraw,
|
| 334 |
+
"fee_amount": fee_amount,
|
| 335 |
+
"net_amount": net_amount,
|
| 336 |
+
"free_quota_used": min(req.amount, free_quota + total_withdrawn) - total_withdrawn # 本次消耗的免责额度
|
| 337 |
+
}
|
verify_code_engine.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# verify_code_engine.py
|
| 2 |
+
# ==========================================
|
| 3 |
+
# 📧 验证码发送引擎
|
| 4 |
+
# ==========================================
|
| 5 |
+
# 作用:提供验证码内存缓存与多渠道发送能力(邮件/短信)
|
| 6 |
+
# 关联文件:
|
| 7 |
+
# - router_users_auth.py (调用此模块发送验证码)
|
| 8 |
+
# - router_wallet.py (提现时也会调用验证码缓存)
|
| 9 |
+
# ==========================================
|
| 10 |
+
|
| 11 |
+
import os
|
| 12 |
+
import json
|
| 13 |
+
import urllib.request
|
| 14 |
+
import urllib.parse
|
| 15 |
+
import base64
|
| 16 |
+
|
| 17 |
+
# ==========================================
|
| 18 |
+
# 验证码内存缓存 (全局共享字典)
|
| 19 |
+
# ==========================================
|
| 20 |
+
# 作用:存储所有待验证的验证码,格式为 {key: {code, expires_at}}
|
| 21 |
+
# 关联:router_users_auth.py 和 router_wallet.py 都会读写此字典
|
| 22 |
+
# 注意:服务重启后缓存会清空,生产环境建议改用 Redis
|
| 23 |
+
VERIFY_CODES = {}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def send_email_code(to_email: str, code: str, action: str):
|
| 27 |
+
"""
|
| 28 |
+
📧 邮件验证码发送函数
|
| 29 |
+
|
| 30 |
+
作用:通过 Make.com Webhook 触发邮件发送(无需 SMTP 服务器)
|
| 31 |
+
参数:
|
| 32 |
+
- to_email: 目标邮箱地址
|
| 33 |
+
- code: 6位数字验证码
|
| 34 |
+
- action: 动作类型 ("register" 注册 / "reset" 重置密码)
|
| 35 |
+
关联:
|
| 36 |
+
- 环境变量 MAKE_WEBHOOK_URL (在 HF Space Settings 中配置)
|
| 37 |
+
- router_users_auth.py 的 send_verify_code() 异步调用此函数
|
| 38 |
+
"""
|
| 39 |
+
# 从环境变量读取 Make.com Webhook 地址
|
| 40 |
+
webhook_url = os.environ.get("MAKE_WEBHOOK_URL")
|
| 41 |
+
|
| 42 |
+
if not webhook_url:
|
| 43 |
+
print("警告: 未配置 MAKE_WEBHOOK_URL,跳过邮件发送")
|
| 44 |
+
return
|
| 45 |
+
|
| 46 |
+
# 根据动作类型生成不同的邮件标题
|
| 47 |
+
action_str = "注册账号" if action == "register" else "修改/找回密码"
|
| 48 |
+
subject = f"ComfyUI 社区 - {action_str}验证码"
|
| 49 |
+
|
| 50 |
+
# 构建 HTML 格式的邮件正文(美化样式)
|
| 51 |
+
html_content = f"""
|
| 52 |
+
<div style="background:#f9f9f9; padding:20px; font-family:sans-serif;">
|
| 53 |
+
<div style="background:#fff; padding:20px; border-radius:8px; max-width:500px; margin:0 auto; box-shadow:0 2px 10px rgba(0,0,0,0.05);">
|
| 54 |
+
<h2 style="color:#4CAF50; margin-top:0;">ComfyUI 社区精选</h2>
|
| 55 |
+
<p>您好,</p>
|
| 56 |
+
<p>您正在请求<strong>{action_str}</strong>,您的验证码是:</p>
|
| 57 |
+
<div style="font-size:24px; font-weight:bold; color:#2196F3; background:#e3f2fd; padding:15px; text-align:center; border-radius:6px; letter-spacing: 5px;">{code}</div>
|
| 58 |
+
<p style="color:#888; font-size:12px; margin-top:20px;">该验证码在 10 分钟内有效。如非本人操作,请忽略此邮件。</p>
|
| 59 |
+
</div>
|
| 60 |
+
</div>
|
| 61 |
+
"""
|
| 62 |
+
|
| 63 |
+
# 组装 Webhook 请求载荷
|
| 64 |
+
data = {
|
| 65 |
+
"to": to_email,
|
| 66 |
+
"subject": subject,
|
| 67 |
+
"html": html_content
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
# 发送 HTTP POST 请求触发 Webhook
|
| 71 |
+
req = urllib.request.Request(
|
| 72 |
+
webhook_url,
|
| 73 |
+
data=json.dumps(data).encode('utf-8'),
|
| 74 |
+
headers={'Content-Type': 'application/json'}
|
| 75 |
+
)
|
| 76 |
+
try:
|
| 77 |
+
with urllib.request.urlopen(req, timeout=10) as response:
|
| 78 |
+
print(f"✅ 成功触发 Webhook 发送验证码 {code} 至 {to_email}")
|
| 79 |
+
except Exception as e:
|
| 80 |
+
print(f"❌ Webhook 触发失败: {e}")
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def send_sms_code(phone: str, code: str, action: str):
|
| 84 |
+
"""
|
| 85 |
+
📱 短信验证码发送函数(双引擎:Twilio + 阿里云)
|
| 86 |
+
|
| 87 |
+
作用:支持海外用户(Twilio)和国内用户(阿里云)的短信发送
|
| 88 |
+
参数:
|
| 89 |
+
- phone: 手机号(需带国际区号,如 +86)
|
| 90 |
+
- code: 6位数字验证码
|
| 91 |
+
- action: 动作类型 ("register" 注册 / "reset" 重置密码)
|
| 92 |
+
关联:
|
| 93 |
+
- 环境变量 TWILIO_SID, TWILIO_TOKEN, TWILIO_FROM (Twilio配置)
|
| 94 |
+
- 环境变量 ALIYUN_AK, ALIYUN_SK, ALIYUN_SIGN_NAME (阿里云配置)
|
| 95 |
+
- router_users_auth.py 的 send_verify_code() 异步调用此函数
|
| 96 |
+
"""
|
| 97 |
+
action_str = "注册账号" if action == "register" else "修改/找回密码"
|
| 98 |
+
|
| 99 |
+
# ==========================================
|
| 100 |
+
# 引擎 A:Twilio (海外优先,无需SDK,纯HTTP接口)
|
| 101 |
+
# ==========================================
|
| 102 |
+
# 作用:为海外用户或测试环境提供短信能力
|
| 103 |
+
# 关联:需在 HF Space Settings 配置 TWILIO_SID, TWILIO_TOKEN, TWILIO_FROM
|
| 104 |
+
twilio_sid = os.environ.get("TWILIO_SID")
|
| 105 |
+
if twilio_sid:
|
| 106 |
+
token = os.environ.get("TWILIO_TOKEN")
|
| 107 |
+
from_phone = os.environ.get("TWILIO_FROM")
|
| 108 |
+
|
| 109 |
+
# 构建短信内容
|
| 110 |
+
body = f"【ComfyUI社区】您正在请求{action_str},验证码是:{code},10分钟内有效。"
|
| 111 |
+
url = f"https://api.twilio.com/2010-04-01/Accounts/{twilio_sid}/Messages.json"
|
| 112 |
+
|
| 113 |
+
# Twilio 使用 Basic Auth 认证
|
| 114 |
+
auth = base64.b64encode(f"{twilio_sid}:{token}".encode('utf-8')).decode('utf-8')
|
| 115 |
+
data = urllib.parse.urlencode({'To': phone, 'From': from_phone, 'Body': body}).encode('utf-8')
|
| 116 |
+
|
| 117 |
+
req = urllib.request.Request(url, data=data)
|
| 118 |
+
req.add_header("Authorization", f"Basic {auth}")
|
| 119 |
+
try:
|
| 120 |
+
with urllib.request.urlopen(req, timeout=10) as response:
|
| 121 |
+
print(f"✅ Twilio 短信已成功下发至 {phone}")
|
| 122 |
+
except Exception as e:
|
| 123 |
+
print(f"❌ Twilio 发送失败: {e}")
|
| 124 |
+
return
|
| 125 |
+
|
| 126 |
+
# ==========================================
|
| 127 |
+
# 引擎 B:阿里云 (国内首选,到达率最高)
|
| 128 |
+
# ==========================================
|
| 129 |
+
# 作用:为国内用户提供短信能力,需要已备案的签名和模板
|
| 130 |
+
# 关联:需在 HF Space Settings 配置 ALIYUN_AK, ALIYUN_SK 等
|
| 131 |
+
aliyun_ak = os.environ.get("ALIYUN_AK")
|
| 132 |
+
if aliyun_ak:
|
| 133 |
+
# 阿里云SDK需要单独安装,这里做动态导入
|
| 134 |
+
try:
|
| 135 |
+
from alibabacloud_dysmsapi20170525.client import Client as DysmsapiClient
|
| 136 |
+
from alibabacloud_tea_openapi import models as open_api_models
|
| 137 |
+
from alibabacloud_dysmsapi20170525 import models as dysmsapi_models
|
| 138 |
+
except ImportError:
|
| 139 |
+
print("❌ 缺少阿里云 SDK,请在 requirements.txt 中添加 alibabacloud_dysmsapi20170525")
|
| 140 |
+
return
|
| 141 |
+
|
| 142 |
+
sk = os.environ.get("ALIYUN_SK")
|
| 143 |
+
sign_name = os.environ.get("ALIYUN_SIGN_NAME") # 短信签名,如 "阿里云"
|
| 144 |
+
# 根据动作类型选择不同的模板
|
| 145 |
+
tpl_code = os.environ.get("ALIYUN_TPL_REGISTER") if action == "register" else os.environ.get("ALIYUN_TPL_RESET")
|
| 146 |
+
|
| 147 |
+
# 初始化阿里云客户端
|
| 148 |
+
config = open_api_models.Config(access_key_id=aliyun_ak, access_key_secret=sk)
|
| 149 |
+
config.endpoint = 'dysmsapi.aliyuncs.com'
|
| 150 |
+
client = DysmsapiClient(config)
|
| 151 |
+
|
| 152 |
+
# 构建发送请求
|
| 153 |
+
send_req = dysmsapi_models.SendSmsRequest(
|
| 154 |
+
phone_numbers=phone,
|
| 155 |
+
sign_name=sign_name,
|
| 156 |
+
template_code=tpl_code,
|
| 157 |
+
template_param=json.dumps({"code": code}) # 模板参数
|
| 158 |
+
)
|
| 159 |
+
try:
|
| 160 |
+
response = client.send_sms(send_req)
|
| 161 |
+
if response.body.code == "OK":
|
| 162 |
+
print(f"✅ 阿里云短信已成功下发至 {phone}")
|
| 163 |
+
else:
|
| 164 |
+
print(f"❌ 阿里云下发失败: {response.body.message}")
|
| 165 |
+
except Exception as e:
|
| 166 |
+
print(f"❌ 阿里云请求异常: {e}")
|
| 167 |
+
return
|
| 168 |
+
|
| 169 |
+
# ==========================================
|
| 170 |
+
# 降级模式:控制台打印模拟
|
| 171 |
+
# ==========================================
|
| 172 |
+
# 作用:当没有配置任何短信服务时,仅在控制台打印验证码(仅限开发测试)
|
| 173 |
+
print(f"⚠️ 未配置短信秘钥,模拟下发 -> 手机号: {phone}, 验证码: {code}")
|
云端_定时版本检测引擎.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 云端_定时版本检测引擎.py
|
| 2 |
+
import asyncio
|
| 3 |
+
import datetime
|
| 4 |
+
import httpx
|
| 5 |
+
import 数据库连接 as db
|
| 6 |
+
|
| 7 |
+
async def fetch_latest_github_hash(repo_url, token):
|
| 8 |
+
"""请求 GitHub API 获取最新版的 Commit Hash"""
|
| 9 |
+
try:
|
| 10 |
+
parts = repo_url.rstrip("/").split("/")
|
| 11 |
+
if len(parts) < 2: return None
|
| 12 |
+
owner, repo = parts[-2], parts[-1].replace(".git", "")
|
| 13 |
+
|
| 14 |
+
api_url = f"https://api.github.com/repos/{owner}/{repo}/commits"
|
| 15 |
+
headers = {"Accept": "application/vnd.github.v3+json", "User-Agent": "ComfyUI-Hub"}
|
| 16 |
+
if token:
|
| 17 |
+
headers["Authorization"] = f"token {token}"
|
| 18 |
+
|
| 19 |
+
async with httpx.AsyncClient(follow_redirects=True) as client:
|
| 20 |
+
resp = await client.get(api_url, headers=headers, timeout=15.0)
|
| 21 |
+
if resp.status_code == 200:
|
| 22 |
+
data = resp.json()
|
| 23 |
+
if isinstance(data, list) and len(data) > 0:
|
| 24 |
+
return data[0]["sha"] # 返回最新的 Commit Hash
|
| 25 |
+
except Exception as e:
|
| 26 |
+
print(f"检测版本失败 {repo_url}: {e}")
|
| 27 |
+
return None
|
| 28 |
+
|
| 29 |
+
async def daily_version_check_task():
|
| 30 |
+
"""每日 02:00 定时执行的守护进程"""
|
| 31 |
+
while True:
|
| 32 |
+
now = datetime.datetime.utcnow() + datetime.timedelta(hours=8)
|
| 33 |
+
# 计算距离今天凌晨 02:00 的秒数,如果过了就定在明天 02:00
|
| 34 |
+
next_run = now.replace(hour=2, minute=0, second=0, microsecond=0)
|
| 35 |
+
if now >= next_run:
|
| 36 |
+
next_run += datetime.timedelta(days=1)
|
| 37 |
+
sleep_seconds = (next_run - now).total_seconds()
|
| 38 |
+
|
| 39 |
+
print(f"🕒 版本探测引擎已挂载,距离下次 02:00 扫描还有 {sleep_seconds} 秒...")
|
| 40 |
+
await asyncio.sleep(sleep_seconds)
|
| 41 |
+
|
| 42 |
+
print("🚀 [02:00] 开始执行全站 GitHub 资源版本检测任务...")
|
| 43 |
+
items_db = db.load_data("items.json", default_data=[])
|
| 44 |
+
versions_db = db.load_data("versions.json", default_data={})
|
| 45 |
+
|
| 46 |
+
updated_count = 0
|
| 47 |
+
for item in items_db:
|
| 48 |
+
link = item.get("link", "")
|
| 49 |
+
if "github.com" in link:
|
| 50 |
+
token = item.get("github_token")
|
| 51 |
+
latest_hash = await fetch_latest_github_hash(link, token)
|
| 52 |
+
if latest_hash and versions_db.get(item["id"]) != latest_hash:
|
| 53 |
+
versions_db[item["id"]] = latest_hash
|
| 54 |
+
updated_count += 1
|
| 55 |
+
|
| 56 |
+
if updated_count > 0:
|
| 57 |
+
db.save_data("versions.json", versions_db)
|
| 58 |
+
print(f"✅ 版本检测任务完成,共发现并更新了 {updated_count} 个资源的新版本。")
|
| 59 |
+
else:
|
| 60 |
+
print("✅ 版本检测任务完成,暂无任何新版本发现。")
|
安全认证.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 安全认证.py
|
| 2 |
+
# ==========================================
|
| 3 |
+
# 🔐 安全认证工具模块
|
| 4 |
+
# ==========================================
|
| 5 |
+
# 作用:提供密码哈希和 JWT Token 的生成/验证功能
|
| 6 |
+
# 特点:仅使用 Python 标准库,零外部依赖
|
| 7 |
+
# 关联文件:
|
| 8 |
+
# - router_users_auth.py (登录/注册/重置密码调用此模块)
|
| 9 |
+
# - 所有需要身份验证的路由模块
|
| 10 |
+
# ==========================================
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
import hashlib
|
| 14 |
+
import hmac
|
| 15 |
+
import base64
|
| 16 |
+
import json
|
| 17 |
+
import time
|
| 18 |
+
from typing import Optional, Tuple
|
| 19 |
+
from fastapi import HTTPException, Header
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# ==========================================
|
| 23 |
+
# 🔑 安全密钥配置
|
| 24 |
+
# ==========================================
|
| 25 |
+
# JWT_SECRET: 用于签名 Token,生产环境必须设置环境变量
|
| 26 |
+
# PASSWORD_SALT: 密码哈希加盐,增强安全性
|
| 27 |
+
JWT_SECRET = os.environ.get("JWT_SECRET", "ComfyUI-Ranking-Default-Secret-Key-2024")
|
| 28 |
+
PASSWORD_SALT = os.environ.get("PASSWORD_SALT", "ComfyUI-Ranking-Salt-v1")
|
| 29 |
+
|
| 30 |
+
# Token 有效期:7 天(单位:秒)
|
| 31 |
+
TOKEN_EXPIRE_SECONDS = 7 * 24 * 60 * 60
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# ==========================================
|
| 35 |
+
# 🔒 密码哈希函数
|
| 36 |
+
# ==========================================
|
| 37 |
+
# 作用:将明文密码转换为不可逆的哈希值
|
| 38 |
+
# 算法:SHA256 + 固定盐值
|
| 39 |
+
# 关联:注册、登录、重置密码时调用
|
| 40 |
+
|
| 41 |
+
def hash_password(password: str) -> str:
|
| 42 |
+
"""
|
| 43 |
+
将明文密码转换为 SHA256 哈希值
|
| 44 |
+
|
| 45 |
+
参数:
|
| 46 |
+
password: 用户输入的明文密码
|
| 47 |
+
|
| 48 |
+
返回:
|
| 49 |
+
64位十六进制哈希字符串
|
| 50 |
+
|
| 51 |
+
示例:
|
| 52 |
+
hash_password("123456") -> "a8f5f167f44f..."
|
| 53 |
+
"""
|
| 54 |
+
# 加盐:盐值 + 密码,防止彩虹表攻击
|
| 55 |
+
salted_password = f"{PASSWORD_SALT}{password}"
|
| 56 |
+
|
| 57 |
+
# SHA256 哈希
|
| 58 |
+
hash_obj = hashlib.sha256(salted_password.encode("utf-8"))
|
| 59 |
+
|
| 60 |
+
return hash_obj.hexdigest()
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
| 64 |
+
"""
|
| 65 |
+
验证明文密码是否与哈希值匹配
|
| 66 |
+
|
| 67 |
+
参数:
|
| 68 |
+
plain_password: 用户输入的明文密码
|
| 69 |
+
hashed_password: 数据库中存储的哈希值
|
| 70 |
+
|
| 71 |
+
返回:
|
| 72 |
+
True 匹配 / False 不匹配
|
| 73 |
+
"""
|
| 74 |
+
return hash_password(plain_password) == hashed_password
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# ==========================================
|
| 78 |
+
# 🎫 JWT Token 生成与验证
|
| 79 |
+
# ==========================================
|
| 80 |
+
# 作用:生成和验证用户登录凭证
|
| 81 |
+
# 算法:HMAC-SHA256 签名的 Base64 编码 JSON
|
| 82 |
+
# 格式:header.payload.signature
|
| 83 |
+
|
| 84 |
+
def _base64url_encode(data: bytes) -> str:
|
| 85 |
+
"""Base64 URL 安全编码(去掉填充的 = 号)"""
|
| 86 |
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("utf-8")
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _base64url_decode(data: str) -> bytes:
|
| 90 |
+
"""Base64 URL 安全解码(补充填充的 = 号)"""
|
| 91 |
+
padding = 4 - len(data) % 4
|
| 92 |
+
if padding != 4:
|
| 93 |
+
data += "=" * padding
|
| 94 |
+
return base64.urlsafe_b64decode(data)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def create_token(account: str, extra_data: dict = None) -> str:
|
| 98 |
+
"""
|
| 99 |
+
生成 JWT Token
|
| 100 |
+
|
| 101 |
+
参数:
|
| 102 |
+
account: 用户账号(必填)
|
| 103 |
+
extra_data: 额外数据,如角色、权限等(可选)
|
| 104 |
+
|
| 105 |
+
返回:
|
| 106 |
+
JWT Token 字符串(格式:header.payload.signature)
|
| 107 |
+
|
| 108 |
+
Token 内容:
|
| 109 |
+
- sub: 用户账号
|
| 110 |
+
- iat: 签发时间
|
| 111 |
+
- exp: 过期时间
|
| 112 |
+
- 其他 extra_data 字段
|
| 113 |
+
"""
|
| 114 |
+
# ========== 1. 构建 Header ==========
|
| 115 |
+
header = {
|
| 116 |
+
"alg": "HS256", # 签名算法
|
| 117 |
+
"typ": "JWT" # Token 类型
|
| 118 |
+
}
|
| 119 |
+
header_b64 = _base64url_encode(json.dumps(header, separators=(",", ":")).encode())
|
| 120 |
+
|
| 121 |
+
# ========== 2. 构建 Payload ==========
|
| 122 |
+
now = int(time.time())
|
| 123 |
+
payload = {
|
| 124 |
+
"sub": account, # Subject: 用户账号
|
| 125 |
+
"iat": now, # Issued At: 签发时间
|
| 126 |
+
"exp": now + TOKEN_EXPIRE_SECONDS # Expiration: 过期时间
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
# 合并额外数据
|
| 130 |
+
if extra_data:
|
| 131 |
+
payload.update(extra_data)
|
| 132 |
+
|
| 133 |
+
payload_b64 = _base64url_encode(json.dumps(payload, separators=(",", ":")).encode())
|
| 134 |
+
|
| 135 |
+
# ========== 3. 生成签名 ==========
|
| 136 |
+
# 使用 HMAC-SHA256 对 header.payload 签名
|
| 137 |
+
message = f"{header_b64}.{payload_b64}"
|
| 138 |
+
signature = hmac.new(
|
| 139 |
+
JWT_SECRET.encode("utf-8"),
|
| 140 |
+
message.encode("utf-8"),
|
| 141 |
+
hashlib.sha256
|
| 142 |
+
).digest()
|
| 143 |
+
signature_b64 = _base64url_encode(signature)
|
| 144 |
+
|
| 145 |
+
# ========== 4. 组装 Token ==========
|
| 146 |
+
return f"{header_b64}.{payload_b64}.{signature_b64}"
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def verify_token(token: str) -> Tuple[bool, Optional[dict], str]:
|
| 150 |
+
"""
|
| 151 |
+
验证 JWT Token 有效性
|
| 152 |
+
|
| 153 |
+
参数:
|
| 154 |
+
token: JWT Token 字符串
|
| 155 |
+
|
| 156 |
+
返回:
|
| 157 |
+
(is_valid, payload, error_message)
|
| 158 |
+
- is_valid: Token 是否有效
|
| 159 |
+
- payload: 解析出的数据(有效时)或 None(无效时)
|
| 160 |
+
- error_message: 错误信息(无效时)或空字符串(有效时)
|
| 161 |
+
"""
|
| 162 |
+
try:
|
| 163 |
+
# ========== 1. 解析 Token 结构 ==========
|
| 164 |
+
parts = token.split(".")
|
| 165 |
+
if len(parts) != 3:
|
| 166 |
+
return False, None, "Token 格式错误"
|
| 167 |
+
|
| 168 |
+
header_b64, payload_b64, signature_b64 = parts
|
| 169 |
+
|
| 170 |
+
# ========== 2. 验证签名 ==========
|
| 171 |
+
message = f"{header_b64}.{payload_b64}"
|
| 172 |
+
expected_signature = hmac.new(
|
| 173 |
+
JWT_SECRET.encode("utf-8"),
|
| 174 |
+
message.encode("utf-8"),
|
| 175 |
+
hashlib.sha256
|
| 176 |
+
).digest()
|
| 177 |
+
|
| 178 |
+
actual_signature = _base64url_decode(signature_b64)
|
| 179 |
+
|
| 180 |
+
# 使用 hmac.compare_digest 防止时序攻击
|
| 181 |
+
if not hmac.compare_digest(expected_signature, actual_signature):
|
| 182 |
+
return False, None, "Token 签名无效"
|
| 183 |
+
|
| 184 |
+
# ========== 3. 解析 Payload ==========
|
| 185 |
+
payload_json = _base64url_decode(payload_b64).decode("utf-8")
|
| 186 |
+
payload = json.loads(payload_json)
|
| 187 |
+
|
| 188 |
+
# ========== 4. 检查过期时间 ==========
|
| 189 |
+
exp = payload.get("exp", 0)
|
| 190 |
+
if time.time() > exp:
|
| 191 |
+
return False, None, "Token 已过期,请重新登录"
|
| 192 |
+
|
| 193 |
+
return True, payload, ""
|
| 194 |
+
|
| 195 |
+
except Exception as e:
|
| 196 |
+
return False, None, f"Token 解析失败: {str(e)}"
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def get_account_from_token(token: str) -> Optional[str]:
|
| 200 |
+
"""
|
| 201 |
+
从 Token 中提取用户账号(简化版验证)
|
| 202 |
+
|
| 203 |
+
参数:
|
| 204 |
+
token: JWT Token 字符串
|
| 205 |
+
|
| 206 |
+
返回:
|
| 207 |
+
用户账号(有效时)或 None(无效时)
|
| 208 |
+
"""
|
| 209 |
+
is_valid, payload, _ = verify_token(token)
|
| 210 |
+
if is_valid and payload:
|
| 211 |
+
return payload.get("sub")
|
| 212 |
+
return None
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
# ==========================================
|
| 216 |
+
# 🛡️ FastAPI 依赖注入函数
|
| 217 |
+
# ==========================================
|
| 218 |
+
# 作用:在需要身份验证的接口中使用
|
| 219 |
+
# 用法:在路由函数参数中添加 account: str = Depends(require_auth)
|
| 220 |
+
|
| 221 |
+
async def require_auth(authorization: str = Header(None, alias="Authorization")) -> str:
|
| 222 |
+
"""
|
| 223 |
+
FastAPI 依赖:验证 Authorization Header 中的 Token
|
| 224 |
+
|
| 225 |
+
使用方法:
|
| 226 |
+
@router.get("/api/protected")
|
| 227 |
+
async def protected_route(account: str = Depends(require_auth)):
|
| 228 |
+
return {"message": f"Hello, {account}"}
|
| 229 |
+
|
| 230 |
+
参数:
|
| 231 |
+
authorization: HTTP Header 中的 Authorization 字段
|
| 232 |
+
格式:Bearer <token>
|
| 233 |
+
|
| 234 |
+
返回:
|
| 235 |
+
验证通过时返回用户账号
|
| 236 |
+
|
| 237 |
+
异常:
|
| 238 |
+
401 Unauthorized: Token 缺失、格式错误或已过期
|
| 239 |
+
"""
|
| 240 |
+
# 检查 Header 是否存在
|
| 241 |
+
if not authorization:
|
| 242 |
+
raise HTTPException(status_code=401, detail="未提供认证凭证,请先登录")
|
| 243 |
+
|
| 244 |
+
# 检查格式(Bearer token)
|
| 245 |
+
parts = authorization.split(" ")
|
| 246 |
+
if len(parts) != 2 or parts[0].lower() != "bearer":
|
| 247 |
+
raise HTTPException(status_code=401, detail="认证格式错误,请使用 Bearer Token")
|
| 248 |
+
|
| 249 |
+
token = parts[1]
|
| 250 |
+
|
| 251 |
+
# 验证 Token
|
| 252 |
+
is_valid, payload, error_msg = verify_token(token)
|
| 253 |
+
|
| 254 |
+
if not is_valid:
|
| 255 |
+
raise HTTPException(status_code=401, detail=error_msg)
|
| 256 |
+
|
| 257 |
+
account = payload.get("sub")
|
| 258 |
+
if not account:
|
| 259 |
+
raise HTTPException(status_code=401, detail="Token 中缺少用户信息")
|
| 260 |
+
|
| 261 |
+
return account
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
async def optional_auth(authorization: str = Header(None, alias="Authorization")) -> Optional[str]:
|
| 265 |
+
"""
|
| 266 |
+
FastAPI 依赖:可选的身份验证(不强制要求登录)
|
| 267 |
+
|
| 268 |
+
使用场景:
|
| 269 |
+
- 游客可访问,但登录用户有额外功能
|
| 270 |
+
- 公开内容但需要记录访问者
|
| 271 |
+
|
| 272 |
+
返回:
|
| 273 |
+
验证通过时返回用户账号
|
| 274 |
+
未登录或验证失败时返回 None
|
| 275 |
+
"""
|
| 276 |
+
if not authorization:
|
| 277 |
+
return None
|
| 278 |
+
|
| 279 |
+
try:
|
| 280 |
+
return await require_auth(authorization)
|
| 281 |
+
except HTTPException:
|
| 282 |
+
return None
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
# ==========================================
|
| 286 |
+
# 🔄 兼容性支持:Mock Token 过渡
|
| 287 |
+
# ==========================================
|
| 288 |
+
# 作用:在过渡期间同时支持旧版 mock_token 和新版 JWT
|
| 289 |
+
# 关联:前端更新后可移除此函数
|
| 290 |
+
|
| 291 |
+
def verify_token_with_fallback(token: str) -> Tuple[bool, Optional[str], str]:
|
| 292 |
+
"""
|
| 293 |
+
兼容验证:同时支持 JWT 和旧版 mock_token
|
| 294 |
+
|
| 295 |
+
参数:
|
| 296 |
+
token: Token 字符串(JWT 或 mock_token_xxx)
|
| 297 |
+
|
| 298 |
+
返回:
|
| 299 |
+
(is_valid, account, error_message)
|
| 300 |
+
|
| 301 |
+
注意:
|
| 302 |
+
此函数仅用于过渡期,前端全部更新后应移除
|
| 303 |
+
"""
|
| 304 |
+
# 先尝试 JWT 验证
|
| 305 |
+
is_valid, payload, error_msg = verify_token(token)
|
| 306 |
+
if is_valid:
|
| 307 |
+
return True, payload.get("sub"), ""
|
| 308 |
+
|
| 309 |
+
# 降级:检查是否为旧版 mock_token
|
| 310 |
+
if token.startswith("mock_token_"):
|
| 311 |
+
account = token.replace("mock_token_", "")
|
| 312 |
+
print(f"⚠️ 兼容模式:检测到旧版 mock_token,用户 {account}")
|
| 313 |
+
return True, account, ""
|
| 314 |
+
|
| 315 |
+
return False, None, error_msg
|
密码迁移.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 密码迁移.py
|
| 2 |
+
# ==========================================
|
| 3 |
+
# 🔐 密码迁移脚本 - 一次性执行
|
| 4 |
+
# ==========================================
|
| 5 |
+
# 作用:将现有用户的明文密码批量转换为 SHA256 哈希
|
| 6 |
+
# 执行时机:部署安全更新后,首次运行此脚本
|
| 7 |
+
# 执行方式:python 密码迁移.py
|
| 8 |
+
# ==========================================
|
| 9 |
+
# ⚠️ 注意事项:
|
| 10 |
+
# 1. 此脚本会直接修改 users.json
|
| 11 |
+
# 2. 执行前请备份数据
|
| 12 |
+
# 3. 只需执行一次,执行后可删除此脚本
|
| 13 |
+
# 4. 登录接口已内置自动升级逻辑,此脚本为可选加速方案
|
| 14 |
+
# ==========================================
|
| 15 |
+
|
| 16 |
+
import os
|
| 17 |
+
import json
|
| 18 |
+
import hashlib
|
| 19 |
+
|
| 20 |
+
# 密码哈希配置(必须与 安全认证.py 保持一致)
|
| 21 |
+
PASSWORD_SALT = os.environ.get("PASSWORD_SALT", "ComfyUI-Ranking-Salt-v1")
|
| 22 |
+
|
| 23 |
+
# 数据库路径配置
|
| 24 |
+
if os.environ.get("SPACE_ID"):
|
| 25 |
+
LOCAL_DB_DIR = "/tmp/local_db_data"
|
| 26 |
+
else:
|
| 27 |
+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 28 |
+
LOCAL_DB_DIR = os.path.join(BASE_DIR, "cache")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def hash_password(password: str) -> str:
|
| 32 |
+
"""将明文密码转换为 SHA256 哈希值"""
|
| 33 |
+
salted_password = f"{PASSWORD_SALT}{password}"
|
| 34 |
+
return hashlib.sha256(salted_password.encode("utf-8")).hexdigest()
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def is_hashed(password: str) -> bool:
|
| 38 |
+
"""判断密码是否已经是哈希格式(64位十六进制)"""
|
| 39 |
+
if len(password) != 64:
|
| 40 |
+
return False
|
| 41 |
+
try:
|
| 42 |
+
int(password, 16)
|
| 43 |
+
return True
|
| 44 |
+
except ValueError:
|
| 45 |
+
return False
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def migrate_passwords():
|
| 49 |
+
"""批量迁移所有用户的密码为哈希格式"""
|
| 50 |
+
users_file = os.path.join(LOCAL_DB_DIR, "users.json")
|
| 51 |
+
|
| 52 |
+
# 检查文件是否存在
|
| 53 |
+
if not os.path.exists(users_file):
|
| 54 |
+
print(f"❌ 用户数据文件不存在: {users_file}")
|
| 55 |
+
print(" 请确认数据库路径配置正确")
|
| 56 |
+
return
|
| 57 |
+
|
| 58 |
+
# 读取用户数据
|
| 59 |
+
try:
|
| 60 |
+
with open(users_file, "r", encoding="utf-8") as f:
|
| 61 |
+
users_db = json.load(f)
|
| 62 |
+
except Exception as e:
|
| 63 |
+
print(f"❌ 读取用户数据失败: {e}")
|
| 64 |
+
return
|
| 65 |
+
|
| 66 |
+
# 统计信息
|
| 67 |
+
total_users = len(users_db)
|
| 68 |
+
migrated_count = 0
|
| 69 |
+
already_hashed_count = 0
|
| 70 |
+
skipped_count = 0
|
| 71 |
+
|
| 72 |
+
print("=" * 50)
|
| 73 |
+
print("🔐 开始密码迁移...")
|
| 74 |
+
print(f" 总用户数: {total_users}")
|
| 75 |
+
print("=" * 50)
|
| 76 |
+
|
| 77 |
+
# 遍历所有用户
|
| 78 |
+
for account, user_data in users_db.items():
|
| 79 |
+
password = user_data.get("password", "")
|
| 80 |
+
|
| 81 |
+
if not password:
|
| 82 |
+
print(f"⚠️ 跳过: {account} (无密码字段)")
|
| 83 |
+
skipped_count += 1
|
| 84 |
+
continue
|
| 85 |
+
|
| 86 |
+
if is_hashed(password):
|
| 87 |
+
already_hashed_count += 1
|
| 88 |
+
continue
|
| 89 |
+
|
| 90 |
+
# 执行哈希迁移
|
| 91 |
+
user_data["password"] = hash_password(password)
|
| 92 |
+
migrated_count += 1
|
| 93 |
+
print(f"✅ 已迁移: {account}")
|
| 94 |
+
|
| 95 |
+
# 保存更新后的数据
|
| 96 |
+
if migrated_count > 0:
|
| 97 |
+
try:
|
| 98 |
+
with open(users_file, "w", encoding="utf-8") as f:
|
| 99 |
+
json.dump(users_db, f, ensure_ascii=False, indent=2)
|
| 100 |
+
print("\n" + "=" * 50)
|
| 101 |
+
print("✅ 迁移完成并已保存!")
|
| 102 |
+
except Exception as e:
|
| 103 |
+
print(f"\n❌ 保存失败: {e}")
|
| 104 |
+
return
|
| 105 |
+
|
| 106 |
+
# 输出统计结果
|
| 107 |
+
print("\n📊 迁移统计:")
|
| 108 |
+
print(f" - 总用户数: {total_users}")
|
| 109 |
+
print(f" - 本次迁移: {migrated_count}")
|
| 110 |
+
print(f" - 已是哈希: {already_hashed_count}")
|
| 111 |
+
print(f" - 跳过: {skipped_count}")
|
| 112 |
+
print("=" * 50)
|
| 113 |
+
|
| 114 |
+
if migrated_count == 0:
|
| 115 |
+
print("\n✨ 所有密码均已是哈希格式,无需迁移")
|
| 116 |
+
else:
|
| 117 |
+
print(f"\n🎉 成功将 {migrated_count} 个用户的密码升级为哈希存储!")
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
if __name__ == "__main__":
|
| 121 |
+
print("\n" + "=" * 50)
|
| 122 |
+
print("🔐 ComfyUI-Ranking 密码安全迁移工具")
|
| 123 |
+
print("=" * 50)
|
| 124 |
+
print("\n⚠️ 警告: 此操作将修改 users.json 中的密码字段")
|
| 125 |
+
print(" 建议先备份数据文件\n")
|
| 126 |
+
|
| 127 |
+
confirm = input("确认执行迁移? (输入 yes 继续): ")
|
| 128 |
+
if confirm.lower() == "yes":
|
| 129 |
+
migrate_passwords()
|
| 130 |
+
else:
|
| 131 |
+
print("\n❌ 已取消迁移")
|
数据库连接.py
CHANGED
|
@@ -1,61 +1,308 @@
|
|
| 1 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
import os
|
|
|
|
| 3 |
import json
|
| 4 |
import threading
|
| 5 |
import time
|
|
|
|
|
|
|
|
|
|
| 6 |
from huggingface_hub import HfApi, hf_hub_download
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
HF_TOKEN = os.environ.get("HF_TOKEN")
|
| 9 |
-
DATASET_REPO_ID = "ZHIWEI666/ComfyUI-Ranking"
|
| 10 |
|
|
|
|
| 11 |
if os.environ.get("SPACE_ID"):
|
| 12 |
LOCAL_DB_DIR = "/tmp/local_db_data"
|
| 13 |
else:
|
| 14 |
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 15 |
LOCAL_DB_DIR = os.path.join(BASE_DIR, "cache")
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
api = HfApi() if HF_TOKEN else None
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
-
def load_data(file_name: str, default_data=None):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
if default_data is None:
|
| 26 |
default_data = {} if file_name == "users.json" else []
|
| 27 |
-
|
| 28 |
local_path = os.path.join(LOCAL_DB_DIR, file_name)
|
|
|
|
| 29 |
|
| 30 |
-
with
|
|
|
|
| 31 |
if not os.path.exists(local_path):
|
| 32 |
if HF_TOKEN:
|
| 33 |
try:
|
|
|
|
| 34 |
downloaded_path = hf_hub_download(
|
| 35 |
-
repo_id=DATASET_REPO_ID,
|
| 36 |
-
repo_type="dataset",
|
| 37 |
filename=file_name,
|
| 38 |
token=HF_TOKEN
|
| 39 |
)
|
|
|
|
| 40 |
with open(downloaded_path, "r", encoding="utf-8") as f:
|
| 41 |
data = json.load(f)
|
|
|
|
| 42 |
with open(local_path, "w", encoding="utf-8") as f:
|
| 43 |
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
|
|
| 44 |
return data
|
| 45 |
except Exception as e:
|
| 46 |
-
print(f"从 HF 下载 {file_name} 失败: {e}")
|
| 47 |
return default_data
|
| 48 |
return default_data
|
| 49 |
-
|
|
|
|
| 50 |
try:
|
| 51 |
with open(local_path, "r", encoding="utf-8") as f:
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
except Exception as e:
|
| 54 |
-
print(f"
|
| 55 |
return default_data
|
| 56 |
|
| 57 |
-
|
| 58 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
for attempt in range(retries):
|
| 60 |
try:
|
| 61 |
api.upload_file(
|
|
@@ -66,18 +313,97 @@ def _background_upload_to_hf(local_path, file_name, retries=3):
|
|
| 66 |
token=HF_TOKEN,
|
| 67 |
commit_message=f"Auto-update {file_name}"
|
| 68 |
)
|
| 69 |
-
return # 成功后
|
|
|
|
| 70 |
except Exception as e:
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
print(f"🚨 致命错误:重试 {retries} 次后,同步到 HF Dataset 依然失败: {e}")
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
local_path = os.path.join(LOCAL_DB_DIR, file_name)
|
| 77 |
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
json.dump(data, f, ensure_ascii=False, indent=2)
|
| 81 |
|
| 82 |
-
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 数据库连接.py
|
| 2 |
+
# ==========================================
|
| 3 |
+
# ⚙️ JSON 数据库连接模块
|
| 4 |
+
# ==========================================
|
| 5 |
+
# 作用:提供 JSON 文件的安全读写,自动同步到 HuggingFace Dataset
|
| 6 |
+
# 关联文件:
|
| 7 |
+
# - router_users_auth.py (用户数据读写)
|
| 8 |
+
# - router_wallet.py (钱包数据读写)
|
| 9 |
+
# - router_items.py (内容数据读写)
|
| 10 |
+
# ==========================================
|
| 11 |
+
# 🔒 P0 安全增强:
|
| 12 |
+
# 1. 跨进程文件锁(fcntl/msvcrt)
|
| 13 |
+
# 2. 原子写入(临时文件 + 重命名)
|
| 14 |
+
# 3. 数据完整性校验
|
| 15 |
+
# 4. 自动备份上一版本
|
| 16 |
+
# 🏗️ P2 质量优化:类型提示
|
| 17 |
+
# ==========================================
|
| 18 |
+
|
| 19 |
import os
|
| 20 |
+
import sys
|
| 21 |
import json
|
| 22 |
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 |
+
# 🔧 配置常量
|
| 32 |
+
# ==========================================
|
| 33 |
HF_TOKEN = os.environ.get("HF_TOKEN")
|
| 34 |
+
DATASET_REPO_ID = "ZHIWEI666/ComfyUI-Ranking"
|
| 35 |
|
| 36 |
+
# 数据目录:云端使用 /tmp,本地使用 cache 子目录
|
| 37 |
if os.environ.get("SPACE_ID"):
|
| 38 |
LOCAL_DB_DIR = "/tmp/local_db_data"
|
| 39 |
else:
|
| 40 |
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 41 |
LOCAL_DB_DIR = os.path.join(BASE_DIR, "cache")
|
| 42 |
|
| 43 |
+
# 备份目录:存放上一版本数据
|
| 44 |
+
BACKUP_DIR = os.path.join(LOCAL_DB_DIR, "_backups")
|
| 45 |
+
|
| 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)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# ==========================================
|
| 55 |
+
# 🔐 并发控制:线程锁 + 文件锁
|
| 56 |
+
# ==========================================
|
| 57 |
+
# 线程锁:保护同一进程内的并发
|
| 58 |
+
# 文件锁:保护多进程/多实例的并发
|
| 59 |
+
|
| 60 |
+
# 每个文件独立的线程锁,减少锁竞争
|
| 61 |
+
_file_locks = {}
|
| 62 |
+
_global_lock = threading.Lock()
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _get_file_lock(file_name: str) -> threading.Lock:
|
| 66 |
+
"""获取指定文件的线程锁(懒加载)"""
|
| 67 |
+
with _global_lock:
|
| 68 |
+
if file_name not in _file_locks:
|
| 69 |
+
_file_locks[file_name] = threading.Lock()
|
| 70 |
+
return _file_locks[file_name]
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# ==========================================
|
| 74 |
+
# 🔒 跨平台文件锁实现
|
| 75 |
+
# ==========================================
|
| 76 |
+
# Linux/Mac:使用 fcntl.flock
|
| 77 |
+
# Windows:使用 msvcrt.locking
|
| 78 |
+
|
| 79 |
+
if sys.platform == "win32":
|
| 80 |
+
# Windows 文件锁
|
| 81 |
+
import msvcrt
|
| 82 |
+
|
| 83 |
+
def _lock_file(file_obj, exclusive=True):
|
| 84 |
+
"""Windows 文件锁:锁定整个文件"""
|
| 85 |
+
try:
|
| 86 |
+
# LOCK_EX = 独占锁,LOCK_SH = 共享锁
|
| 87 |
+
mode = msvcrt.LK_NBLCK if exclusive else msvcrt.LK_NBRLCK
|
| 88 |
+
file_obj.seek(0)
|
| 89 |
+
msvcrt.locking(file_obj.fileno(), mode, 1)
|
| 90 |
+
except IOError:
|
| 91 |
+
# 无法获取锁时等待重试
|
| 92 |
+
time.sleep(0.1)
|
| 93 |
+
msvcrt.locking(file_obj.fileno(), mode, 1)
|
| 94 |
+
|
| 95 |
+
def _unlock_file(file_obj):
|
| 96 |
+
"""Windows 文件锁:释放锁"""
|
| 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 文件锁
|
| 105 |
+
import fcntl
|
| 106 |
+
|
| 107 |
+
def _lock_file(file_obj, exclusive=True):
|
| 108 |
+
"""Linux 文件锁:使用 flock"""
|
| 109 |
+
mode = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH
|
| 110 |
+
fcntl.flock(file_obj.fileno(), mode)
|
| 111 |
+
|
| 112 |
+
def _unlock_file(file_obj):
|
| 113 |
+
"""Linux 文件锁:释放锁"""
|
| 114 |
+
fcntl.flock(file_obj.fileno(), fcntl.LOCK_UN)
|
| 115 |
+
|
| 116 |
|
| 117 |
+
# ==========================================
|
| 118 |
+
# 📖 数据读取函数
|
| 119 |
+
# ==========================================
|
| 120 |
+
# 特点:
|
| 121 |
+
# - 线程安全(threading.Lock)
|
| 122 |
+
# - 文件不存在时从 HF 下载
|
| 123 |
+
# - JSON 解析失败时返回默认值
|
| 124 |
|
| 125 |
+
def load_data(file_name: str, default_data: Optional[Union[Dict, List]] = None) -> Union[Dict, List]:
|
| 126 |
+
"""
|
| 127 |
+
从 JSON 文件加载数据
|
| 128 |
+
|
| 129 |
+
参数:
|
| 130 |
+
file_name: 文件名(如 users.json)
|
| 131 |
+
default_data: 数据不存在时的默认值
|
| 132 |
+
|
| 133 |
+
返回:
|
| 134 |
+
解析后的 JSON 数据(dict 或 list)
|
| 135 |
+
"""
|
| 136 |
+
# 默认值处理
|
| 137 |
if default_data is None:
|
| 138 |
default_data = {} if file_name == "users.json" else []
|
| 139 |
+
|
| 140 |
local_path = os.path.join(LOCAL_DB_DIR, file_name)
|
| 141 |
+
file_lock = _get_file_lock(file_name)
|
| 142 |
|
| 143 |
+
with file_lock:
|
| 144 |
+
# ========== 情况1:本地文件不存在 ==========
|
| 145 |
if not os.path.exists(local_path):
|
| 146 |
if HF_TOKEN:
|
| 147 |
try:
|
| 148 |
+
# 从 HuggingFace Dataset 下载
|
| 149 |
downloaded_path = hf_hub_download(
|
| 150 |
+
repo_id=DATASET_REPO_ID,
|
| 151 |
+
repo_type="dataset",
|
| 152 |
filename=file_name,
|
| 153 |
token=HF_TOKEN
|
| 154 |
)
|
| 155 |
+
# 读取下载的文件
|
| 156 |
with open(downloaded_path, "r", encoding="utf-8") as f:
|
| 157 |
data = json.load(f)
|
| 158 |
+
# 保存到本地缓存
|
| 159 |
with open(local_path, "w", encoding="utf-8") as f:
|
| 160 |
json.dump(data, f, ensure_ascii=False, indent=2)
|
| 161 |
+
print(f"✅ 从 HF Dataset 下载 {file_name} 成功")
|
| 162 |
return data
|
| 163 |
except Exception as e:
|
| 164 |
+
print(f"⚠️ 从 HF 下载 {file_name} 失败: {e}")
|
| 165 |
return default_data
|
| 166 |
return default_data
|
| 167 |
+
|
| 168 |
+
# ========== 情况2:本地文件存在 ==========
|
| 169 |
try:
|
| 170 |
with open(local_path, "r", encoding="utf-8") as f:
|
| 171 |
+
# 获取共享锁(读锁)
|
| 172 |
+
_lock_file(f, exclusive=False)
|
| 173 |
+
try:
|
| 174 |
+
return json.load(f)
|
| 175 |
+
finally:
|
| 176 |
+
_unlock_file(f)
|
| 177 |
+
except json.JSONDecodeError as e:
|
| 178 |
+
print(f"🚨 JSON 解析错误 {file_name}: {e}")
|
| 179 |
+
# 尝试从备份恢复
|
| 180 |
+
return _recover_from_backup(file_name, default_data)
|
| 181 |
except Exception as e:
|
| 182 |
+
print(f"⚠️ 读取 {file_name} 失败: {e}")
|
| 183 |
return default_data
|
| 184 |
|
| 185 |
+
|
| 186 |
+
def _recover_from_backup(file_name: str, default_data: Union[Dict, List]) -> Union[Dict, List]:
|
| 187 |
+
"""从备份文件恢复数据"""
|
| 188 |
+
backup_path = os.path.join(BACKUP_DIR, f"{file_name}.bak")
|
| 189 |
+
|
| 190 |
+
if os.path.exists(backup_path):
|
| 191 |
+
try:
|
| 192 |
+
with open(backup_path, "r", encoding="utf-8") as f:
|
| 193 |
+
data = json.load(f)
|
| 194 |
+
print(f"✅ 从备份恢复 {file_name} 成功")
|
| 195 |
+
# 恢复主文件
|
| 196 |
+
local_path = os.path.join(LOCAL_DB_DIR, file_name)
|
| 197 |
+
with open(local_path, "w", encoding="utf-8") as f:
|
| 198 |
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
| 199 |
+
return data
|
| 200 |
+
except Exception as e:
|
| 201 |
+
print(f"🚨 备份恢复失败 {file_name}: {e}")
|
| 202 |
+
|
| 203 |
+
return default_data
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
# ==========================================
|
| 207 |
+
# 💾 数据保存函数(原子写入 + 备份)
|
| 208 |
+
# ==========================================
|
| 209 |
+
# 核心保护机制:
|
| 210 |
+
# 1. 先写临时文件,验证成功后原子重命名
|
| 211 |
+
# 2. 写入前备份上一版本
|
| 212 |
+
# 3. 写入后验证数据完整性
|
| 213 |
+
# 4. 异步同步到 HuggingFace
|
| 214 |
+
|
| 215 |
+
def save_data(file_name: str, data: Union[Dict, List]) -> bool:
|
| 216 |
+
"""
|
| 217 |
+
安全保存数据到 JSON 文件
|
| 218 |
+
|
| 219 |
+
参数:
|
| 220 |
+
file_name: 文件名(如 users.json)
|
| 221 |
+
data: 要保存的数据(dict 或 list)
|
| 222 |
+
|
| 223 |
+
返回:
|
| 224 |
+
True 保存成功 / False 保存失败
|
| 225 |
+
|
| 226 |
+
特点:
|
| 227 |
+
- 原子写入:先写临时文件再重命名
|
| 228 |
+
- 自动备份:保留上一版本
|
| 229 |
+
- 完整性校验:验证 JSON 可解析
|
| 230 |
+
- 异步同步:后台上传到 HF Dataset
|
| 231 |
+
"""
|
| 232 |
+
local_path = os.path.join(LOCAL_DB_DIR, file_name)
|
| 233 |
+
backup_path = os.path.join(BACKUP_DIR, f"{file_name}.bak")
|
| 234 |
+
file_lock = _get_file_lock(file_name)
|
| 235 |
+
|
| 236 |
+
with file_lock:
|
| 237 |
+
# ========== 第一步:备份现有文件 ==========
|
| 238 |
+
if os.path.exists(local_path):
|
| 239 |
+
try:
|
| 240 |
+
shutil.copy2(local_path, backup_path)
|
| 241 |
+
except Exception as e:
|
| 242 |
+
print(f"⚠️ 备份 {file_name} 失败: {e}")
|
| 243 |
+
|
| 244 |
+
# ========== 第二步:原子写入 ==========
|
| 245 |
+
# 使用临时文件 + 重命名,确保写入原子性
|
| 246 |
+
temp_fd, temp_path = tempfile.mkstemp(
|
| 247 |
+
suffix=".tmp",
|
| 248 |
+
prefix=f"{file_name}_",
|
| 249 |
+
dir=LOCAL_DB_DIR
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
try:
|
| 253 |
+
# 写入临时文件
|
| 254 |
+
with os.fdopen(temp_fd, "w", encoding="utf-8") as f:
|
| 255 |
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
| 256 |
+
|
| 257 |
+
# ========== 第三步:验证写入成功 ==========
|
| 258 |
+
with open(temp_path, "r", encoding="utf-8") as f:
|
| 259 |
+
verified_data = json.load(f)
|
| 260 |
+
|
| 261 |
+
# 简单校验:数据类型和长度
|
| 262 |
+
if type(verified_data) != type(data):
|
| 263 |
+
raise ValueError("数据类型验证失败")
|
| 264 |
+
if hasattr(data, "__len__") and len(verified_data) != len(data):
|
| 265 |
+
raise ValueError(f"数据长度不一致: {len(verified_data)} vs {len(data)}")
|
| 266 |
+
|
| 267 |
+
# ========== 第四步:原子重命名 ==========
|
| 268 |
+
# Windows 需要先删除目标文件
|
| 269 |
+
if sys.platform == "win32" and os.path.exists(local_path):
|
| 270 |
+
os.remove(local_path)
|
| 271 |
+
os.rename(temp_path, local_path)
|
| 272 |
+
|
| 273 |
+
except Exception as e:
|
| 274 |
+
# 写入失败,清理临时文件
|
| 275 |
+
if os.path.exists(temp_path):
|
| 276 |
+
os.remove(temp_path)
|
| 277 |
+
print(f"🚨 保存 {file_name} 失败: {e}")
|
| 278 |
+
raise
|
| 279 |
+
|
| 280 |
+
# ========== 第五步:异步同步到云端 ==========
|
| 281 |
+
if HF_TOKEN:
|
| 282 |
+
threading.Thread(
|
| 283 |
+
target=_background_upload_to_hf,
|
| 284 |
+
args=(local_path, file_name),
|
| 285 |
+
daemon=True
|
| 286 |
+
).start()
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
# ==========================================
|
| 290 |
+
# ☁️ 后台上传到 HuggingFace Dataset
|
| 291 |
+
# ==========================================
|
| 292 |
+
# 特点:
|
| 293 |
+
# - 后台线程执行,不阻塞主流程
|
| 294 |
+
# - 失败自动重试(最多3次)
|
| 295 |
+
# - 指数退避策略
|
| 296 |
+
|
| 297 |
+
def _background_upload_to_hf(local_path: str, file_name: str, retries: int = 3):
|
| 298 |
+
"""
|
| 299 |
+
后台上传文件到 HuggingFace Dataset
|
| 300 |
+
|
| 301 |
+
参数:
|
| 302 |
+
local_path: 本地文件路径
|
| 303 |
+
file_name: 远程文件名
|
| 304 |
+
retries: 最大重试次数
|
| 305 |
+
"""
|
| 306 |
for attempt in range(retries):
|
| 307 |
try:
|
| 308 |
api.upload_file(
|
|
|
|
| 313 |
token=HF_TOKEN,
|
| 314 |
commit_message=f"Auto-update {file_name}"
|
| 315 |
)
|
| 316 |
+
return # 成功后退出
|
| 317 |
+
|
| 318 |
except Exception as e:
|
| 319 |
+
wait_time = 2 ** attempt # 指数退避:1s, 2s, 4s
|
| 320 |
+
if attempt < retries - 1:
|
| 321 |
+
print(f"⚠️ 上传 {file_name} 失败 (第{attempt+1}次),{wait_time}秒后重试: {e}")
|
| 322 |
+
time.sleep(wait_time)
|
| 323 |
+
else:
|
| 324 |
print(f"🚨 致命错误:重试 {retries} 次后,同步到 HF Dataset 依然失败: {e}")
|
| 325 |
+
# 最后一次失败,保存到失败队列(可选:后续恢复机制)
|
| 326 |
+
_save_to_failed_queue(file_name)
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
def _save_to_failed_queue(file_name: str):
|
| 330 |
+
"""记录上传失败的文件,供后续重试"""
|
| 331 |
+
failed_queue_path = os.path.join(BACKUP_DIR, "_upload_failed.txt")
|
| 332 |
+
try:
|
| 333 |
+
with open(failed_queue_path, "a", encoding="utf-8") as f:
|
| 334 |
+
f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} | {file_name}\n")
|
| 335 |
+
except:
|
| 336 |
+
pass
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
# ==========================================
|
| 340 |
+
# 🔄 数据一致性工具函数
|
| 341 |
+
# ==========================================
|
| 342 |
|
| 343 |
+
def verify_data_integrity(file_name: str) -> bool:
|
| 344 |
+
"""
|
| 345 |
+
验证数据文件完整性
|
| 346 |
+
|
| 347 |
+
返回:
|
| 348 |
+
True: 文件完整
|
| 349 |
+
False: 文件损坏或不存在
|
| 350 |
+
"""
|
| 351 |
local_path = os.path.join(LOCAL_DB_DIR, file_name)
|
| 352 |
|
| 353 |
+
if not os.path.exists(local_path):
|
| 354 |
+
return False
|
|
|
|
| 355 |
|
| 356 |
+
try:
|
| 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 |
+
|
| 364 |
+
def force_sync_from_hf(file_name: str) -> bool:
|
| 365 |
+
"""
|
| 366 |
+
强制从 HuggingFace 同步最新数据(覆盖本地)
|
| 367 |
+
|
| 368 |
+
返回:
|
| 369 |
+
True: 同步成功
|
| 370 |
+
False: 同步失败
|
| 371 |
+
"""
|
| 372 |
+
if not HF_TOKEN:
|
| 373 |
+
return False
|
| 374 |
+
|
| 375 |
+
local_path = os.path.join(LOCAL_DB_DIR, file_name)
|
| 376 |
+
file_lock = _get_file_lock(file_name)
|
| 377 |
+
|
| 378 |
+
with file_lock:
|
| 379 |
+
try:
|
| 380 |
+
downloaded_path = hf_hub_download(
|
| 381 |
+
repo_id=DATASET_REPO_ID,
|
| 382 |
+
repo_type="dataset",
|
| 383 |
+
filename=file_name,
|
| 384 |
+
token=HF_TOKEN,
|
| 385 |
+
force_download=True # 强制下载,忽略缓存
|
| 386 |
+
)
|
| 387 |
+
# 备份并替换
|
| 388 |
+
if os.path.exists(local_path):
|
| 389 |
+
backup_path = os.path.join(BACKUP_DIR, f"{file_name}.bak")
|
| 390 |
+
shutil.copy2(local_path, backup_path)
|
| 391 |
+
|
| 392 |
+
with open(downloaded_path, "r", encoding="utf-8") as f:
|
| 393 |
+
data = json.load(f)
|
| 394 |
+
with open(local_path, "w", encoding="utf-8") as f:
|
| 395 |
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
| 396 |
+
|
| 397 |
+
print(f"✅ 强制同步 {file_name} 成功")
|
| 398 |
+
return True
|
| 399 |
+
|
| 400 |
+
except Exception as e:
|
| 401 |
+
print(f"🚨 强制同步 {file_name} 失败: {e}")
|
| 402 |
+
return False
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
def get_backup_files() -> list:
|
| 406 |
+
"""获取所有备份文件列表"""
|
| 407 |
+
if not os.path.exists(BACKUP_DIR):
|
| 408 |
+
return []
|
| 409 |
+
return [f for f in os.listdir(BACKUP_DIR) if f.endswith(".bak")]
|
测试脚本.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 云端 Space代码/测试脚本.py
|
| 2 |
+
# ==========================================
|
| 3 |
+
# 🧪 API 测试脚本
|
| 4 |
+
# ==========================================
|
| 5 |
+
# 作用:测试后端 API 的核心功能
|
| 6 |
+
# 运行方式:python 测试脚本.py
|
| 7 |
+
# ==========================================
|
| 8 |
+
# 🏗️ P2质量优化:测试覆盖
|
| 9 |
+
# ==========================================
|
| 10 |
+
|
| 11 |
+
import sys
|
| 12 |
+
import json
|
| 13 |
+
import unittest
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
from typing import Optional, Dict, Any
|
| 16 |
+
from unittest.mock import patch, MagicMock
|
| 17 |
+
|
| 18 |
+
# 添加当前目录到路径
|
| 19 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# ==========================================
|
| 23 |
+
# 🔐 测试 安全认证 模块
|
| 24 |
+
# ==========================================
|
| 25 |
+
|
| 26 |
+
class TestAuthSecurity(unittest.TestCase):
|
| 27 |
+
"""测试认证安全模块"""
|
| 28 |
+
|
| 29 |
+
def setUp(self):
|
| 30 |
+
"""测试前准备"""
|
| 31 |
+
from 安全认证 import hash_password, verify_password, create_token, verify_token
|
| 32 |
+
self.hash_password = hash_password
|
| 33 |
+
self.verify_password = verify_password
|
| 34 |
+
self.create_token = create_token
|
| 35 |
+
self.verify_token = verify_token
|
| 36 |
+
|
| 37 |
+
def test_password_hash_deterministic(self):
|
| 38 |
+
"""测试:相同密码生成相同哈希"""
|
| 39 |
+
password = "test123456"
|
| 40 |
+
hash1 = self.hash_password(password)
|
| 41 |
+
hash2 = self.hash_password(password)
|
| 42 |
+
self.assertEqual(hash1, hash2, "相同密码应生成相同哈希")
|
| 43 |
+
|
| 44 |
+
def test_password_hash_different(self):
|
| 45 |
+
"""测试:不同密码生成不同哈希"""
|
| 46 |
+
hash1 = self.hash_password("password1")
|
| 47 |
+
hash2 = self.hash_password("password2")
|
| 48 |
+
self.assertNotEqual(hash1, hash2, "不同密码应生成不同哈希")
|
| 49 |
+
|
| 50 |
+
def test_password_verify_correct(self):
|
| 51 |
+
"""测试:正确密码验证通过"""
|
| 52 |
+
password = "mySecurePassword"
|
| 53 |
+
hashed = self.hash_password(password)
|
| 54 |
+
self.assertTrue(self.verify_password(password, hashed), "正确密码应验证通过")
|
| 55 |
+
|
| 56 |
+
def test_password_verify_wrong(self):
|
| 57 |
+
"""测试:错误密码验证失败"""
|
| 58 |
+
hashed = self.hash_password("correctPassword")
|
| 59 |
+
self.assertFalse(self.verify_password("wrongPassword", hashed), "错误密码应验证失败")
|
| 60 |
+
|
| 61 |
+
def test_password_verify_plaintext_fallback(self):
|
| 62 |
+
"""测试:明文密码兼容(向后兼容)"""
|
| 63 |
+
plaintext = "oldPassword123"
|
| 64 |
+
# 明文密码(旧版格式)
|
| 65 |
+
self.assertTrue(self.verify_password(plaintext, plaintext), "明文密码应向后兼容")
|
| 66 |
+
|
| 67 |
+
def test_token_create_and_verify(self):
|
| 68 |
+
"""测试:Token 创建和验证"""
|
| 69 |
+
account = "test_user"
|
| 70 |
+
token = self.create_token(account)
|
| 71 |
+
|
| 72 |
+
# Token 应该是有效的
|
| 73 |
+
self.assertIsNotNone(token, "Token 不应为空")
|
| 74 |
+
self.assertIn(".", token, "Token 应包含点号分隔符")
|
| 75 |
+
|
| 76 |
+
# 验证 Token
|
| 77 |
+
verified_account = self.verify_token(token)
|
| 78 |
+
self.assertEqual(verified_account, account, "验证后的账号应与原账号一致")
|
| 79 |
+
|
| 80 |
+
def test_token_invalid(self):
|
| 81 |
+
"""测试:无效 Token 验证失败"""
|
| 82 |
+
result = self.verify_token("invalid.token.here")
|
| 83 |
+
self.assertIsNone(result, "无效 Token 应返回 None")
|
| 84 |
+
|
| 85 |
+
def test_token_with_extra_data(self):
|
| 86 |
+
"""测试:Token 携带额外数据"""
|
| 87 |
+
account = "test_user"
|
| 88 |
+
extra = {"role": "admin", "level": 5}
|
| 89 |
+
token = self.create_token(account, extra_data=extra)
|
| 90 |
+
|
| 91 |
+
verified_account = self.verify_token(token)
|
| 92 |
+
self.assertEqual(verified_account, account, "验证后的账号应正确")
|
| 93 |
+
|
| 94 |
+
def test_mock_token_support(self):
|
| 95 |
+
"""测试:mock_token 兼容(向后兼容)"""
|
| 96 |
+
from 安全认证 import extract_account_from_token
|
| 97 |
+
|
| 98 |
+
# 旧版 mock_token 格式
|
| 99 |
+
mock_token = "mock_token_testuser_1234567890"
|
| 100 |
+
account = extract_account_from_token(mock_token)
|
| 101 |
+
self.assertEqual(account, "testuser", "mock_token 应正确解析账号")
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
# ==========================================
|
| 105 |
+
# 📦 测试数据库连接模块
|
| 106 |
+
# ==========================================
|
| 107 |
+
|
| 108 |
+
class TestDatabaseConnection(unittest.TestCase):
|
| 109 |
+
"""测试数据库连接模块"""
|
| 110 |
+
|
| 111 |
+
def setUp(self):
|
| 112 |
+
"""测试前准备"""
|
| 113 |
+
from 数据库连接 import load_data, save_data
|
| 114 |
+
self.load_data = load_data
|
| 115 |
+
self.save_data = save_data
|
| 116 |
+
self.test_file = "_test_data.json"
|
| 117 |
+
|
| 118 |
+
def tearDown(self):
|
| 119 |
+
"""测试后清理"""
|
| 120 |
+
import os
|
| 121 |
+
test_path = Path(__file__).parent / "data" / self.test_file
|
| 122 |
+
if test_path.exists():
|
| 123 |
+
os.remove(test_path)
|
| 124 |
+
|
| 125 |
+
def test_load_missing_file(self):
|
| 126 |
+
"""测试:加载不存在的文件返回空列表"""
|
| 127 |
+
result = self.load_data("nonexistent_file.json")
|
| 128 |
+
self.assertEqual(result, [], "不存在的文件应返回空列表")
|
| 129 |
+
|
| 130 |
+
def test_save_and_load(self):
|
| 131 |
+
"""测试:保存和加载数据"""
|
| 132 |
+
test_data = [
|
| 133 |
+
{"id": "1", "name": "测试项目1"},
|
| 134 |
+
{"id": "2", "name": "测试项目2"}
|
| 135 |
+
]
|
| 136 |
+
|
| 137 |
+
# 保存数据
|
| 138 |
+
result = self.save_data(self.test_file, test_data)
|
| 139 |
+
self.assertTrue(result, "保存应成功")
|
| 140 |
+
|
| 141 |
+
# 加载数据
|
| 142 |
+
loaded = self.load_data(self.test_file)
|
| 143 |
+
self.assertEqual(loaded, test_data, "加载的数据应与保存的一致")
|
| 144 |
+
|
| 145 |
+
def test_save_creates_backup(self):
|
| 146 |
+
"""测试:保存时创建备份"""
|
| 147 |
+
import os
|
| 148 |
+
|
| 149 |
+
test_data = {"key": "value"}
|
| 150 |
+
self.save_data(self.test_file, test_data)
|
| 151 |
+
|
| 152 |
+
# 再次保存,应该创建备份
|
| 153 |
+
self.save_data(self.test_file, {"key": "new_value"})
|
| 154 |
+
|
| 155 |
+
backup_dir = Path(__file__).parent / "data" / "backups"
|
| 156 |
+
if backup_dir.exists():
|
| 157 |
+
backups = list(backup_dir.glob(f"{self.test_file}.*"))
|
| 158 |
+
# 备份可能存在
|
| 159 |
+
self.assertGreaterEqual(len(backups), 0, "备份机制应正常工作")
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
# ==========================================
|
| 163 |
+
# 🔧 测试工具函数
|
| 164 |
+
# ==========================================
|
| 165 |
+
|
| 166 |
+
class TestUtilityFunctions(unittest.TestCase):
|
| 167 |
+
"""测试工具函数"""
|
| 168 |
+
|
| 169 |
+
def test_tip_level_calculation(self):
|
| 170 |
+
"""测试:打赏等级计算"""
|
| 171 |
+
# 假设有打赏等级计算函数
|
| 172 |
+
def calculate_tip_level(total_points: int) -> Dict[str, int]:
|
| 173 |
+
"""计算打赏等级"""
|
| 174 |
+
POINTS_PER_STAR = 100
|
| 175 |
+
STARS_PER_MOON = 5
|
| 176 |
+
MOONS_PER_SUN = 5
|
| 177 |
+
MAX_SUNS = 9
|
| 178 |
+
|
| 179 |
+
remaining = total_points
|
| 180 |
+
suns = min(remaining // (POINTS_PER_STAR * STARS_PER_MOON * MOONS_PER_SUN), MAX_SUNS)
|
| 181 |
+
remaining -= suns * POINTS_PER_STAR * STARS_PER_MOON * MOONS_PER_SUN
|
| 182 |
+
|
| 183 |
+
moons = min(remaining // (POINTS_PER_STAR * STARS_PER_MOON), MOONS_PER_SUN - 1) if suns < MAX_SUNS else 0
|
| 184 |
+
remaining -= moons * POINTS_PER_STAR * STARS_PER_MOON
|
| 185 |
+
|
| 186 |
+
stars = min(remaining // POINTS_PER_STAR, STARS_PER_MOON - 1) if moons < MOONS_PER_SUN - 1 else 0
|
| 187 |
+
|
| 188 |
+
return {"suns": suns, "moons": moons, "stars": stars}
|
| 189 |
+
|
| 190 |
+
# 测试用例
|
| 191 |
+
self.assertEqual(calculate_tip_level(0)["stars"], 0)
|
| 192 |
+
self.assertEqual(calculate_tip_level(100)["stars"], 1)
|
| 193 |
+
self.assertEqual(calculate_tip_level(500)["moons"], 1)
|
| 194 |
+
self.assertEqual(calculate_tip_level(2500)["suns"], 1)
|
| 195 |
+
self.assertEqual(calculate_tip_level(22500)["suns"], 9) # 最高等级
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
# ==========================================
|
| 199 |
+
# 🌐 测试 API 端点(模拟)
|
| 200 |
+
# ==========================================
|
| 201 |
+
|
| 202 |
+
class TestAPIEndpoints(unittest.TestCase):
|
| 203 |
+
"""测试 API 端点(需要 FastAPI TestClient)"""
|
| 204 |
+
|
| 205 |
+
@classmethod
|
| 206 |
+
def setUpClass(cls):
|
| 207 |
+
"""尝试导入 FastAPI 测试客户端"""
|
| 208 |
+
try:
|
| 209 |
+
from fastapi.testclient import TestClient
|
| 210 |
+
from app import app
|
| 211 |
+
cls.client = TestClient(app)
|
| 212 |
+
cls.skip_tests = False
|
| 213 |
+
except ImportError:
|
| 214 |
+
cls.skip_tests = True
|
| 215 |
+
cls.client = None
|
| 216 |
+
|
| 217 |
+
def test_health_check(self):
|
| 218 |
+
"""测试:健康检查端点"""
|
| 219 |
+
if self.skip_tests:
|
| 220 |
+
self.skipTest("FastAPI 未安装或 app 模块不可用")
|
| 221 |
+
|
| 222 |
+
response = self.client.get("/health")
|
| 223 |
+
self.assertEqual(response.status_code, 200)
|
| 224 |
+
|
| 225 |
+
def test_items_list(self):
|
| 226 |
+
"""测试:获取内容列表"""
|
| 227 |
+
if self.skip_tests:
|
| 228 |
+
self.skipTest("FastAPI 未安装或 app 模块不可用")
|
| 229 |
+
|
| 230 |
+
response = self.client.get("/api/items")
|
| 231 |
+
self.assertEqual(response.status_code, 200)
|
| 232 |
+
data = response.json()
|
| 233 |
+
self.assertIn("status", data)
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
# ==========================================
|
| 237 |
+
# 🔒 测试安全性
|
| 238 |
+
# ==========================================
|
| 239 |
+
|
| 240 |
+
class TestSecurity(unittest.TestCase):
|
| 241 |
+
"""测试安全相关功能"""
|
| 242 |
+
|
| 243 |
+
def test_password_not_stored_plaintext(self):
|
| 244 |
+
"""测试:密码不应明文存储"""
|
| 245 |
+
from 安全认证 import hash_password
|
| 246 |
+
|
| 247 |
+
password = "secretPassword123"
|
| 248 |
+
hashed = hash_password(password)
|
| 249 |
+
|
| 250 |
+
self.assertNotEqual(password, hashed, "哈希后的密码不应与原密码相同")
|
| 251 |
+
self.assertNotIn(password, hashed, "哈希中不应包含原密码")
|
| 252 |
+
|
| 253 |
+
def test_token_contains_no_sensitive_info(self):
|
| 254 |
+
"""测试:Token 不应包含敏感信息"""
|
| 255 |
+
from 安全认证 import create_token
|
| 256 |
+
import base64
|
| 257 |
+
|
| 258 |
+
token = create_token("testuser")
|
| 259 |
+
parts = token.split(".")
|
| 260 |
+
|
| 261 |
+
if len(parts) >= 2:
|
| 262 |
+
# 解码 payload
|
| 263 |
+
payload_b64 = parts[1]
|
| 264 |
+
# 添加填充
|
| 265 |
+
padding = 4 - len(payload_b64) % 4
|
| 266 |
+
if padding != 4:
|
| 267 |
+
payload_b64 += "=" * padding
|
| 268 |
+
|
| 269 |
+
try:
|
| 270 |
+
payload = base64.urlsafe_b64decode(payload_b64).decode()
|
| 271 |
+
self.assertNotIn("password", payload.lower(), "Payload 不应包含密码")
|
| 272 |
+
except:
|
| 273 |
+
pass # 解码失败是允许的
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
# ==========================================
|
| 277 |
+
# 📊 测试报告
|
| 278 |
+
# ==========================================
|
| 279 |
+
|
| 280 |
+
def run_tests():
|
| 281 |
+
"""运行所有测试"""
|
| 282 |
+
# 创建测试套件
|
| 283 |
+
loader = unittest.TestLoader()
|
| 284 |
+
suite = unittest.TestSuite()
|
| 285 |
+
|
| 286 |
+
# 添加测试类
|
| 287 |
+
suite.addTests(loader.loadTestsFromTestCase(TestAuthSecurity))
|
| 288 |
+
suite.addTests(loader.loadTestsFromTestCase(TestDatabaseConnection))
|
| 289 |
+
suite.addTests(loader.loadTestsFromTestCase(TestUtilityFunctions))
|
| 290 |
+
suite.addTests(loader.loadTestsFromTestCase(TestAPIEndpoints))
|
| 291 |
+
suite.addTests(loader.loadTestsFromTestCase(TestSecurity))
|
| 292 |
+
|
| 293 |
+
# 运行测试
|
| 294 |
+
runner = unittest.TextTestRunner(verbosity=2)
|
| 295 |
+
result = runner.run(suite)
|
| 296 |
+
|
| 297 |
+
# 输出统计
|
| 298 |
+
print("\n" + "=" * 50)
|
| 299 |
+
print(f"📊 测试统计")
|
| 300 |
+
print("=" * 50)
|
| 301 |
+
print(f"运行: {result.testsRun}")
|
| 302 |
+
print(f"成功: {result.testsRun - len(result.failures) - len(result.errors) - len(result.skipped)}")
|
| 303 |
+
print(f"失败: {len(result.failures)}")
|
| 304 |
+
print(f"错误: {len(result.errors)}")
|
| 305 |
+
print(f"跳过: {len(result.skipped)}")
|
| 306 |
+
print("=" * 50)
|
| 307 |
+
|
| 308 |
+
return result.wasSuccessful()
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
if __name__ == "__main__":
|
| 312 |
+
success = run_tests()
|
| 313 |
+
sys.exit(0 if success else 1)
|