ZHIWEI666 commited on
Commit
f68778c
·
verified ·
1 Parent(s): 5c607d7

Upload 19 files

Browse files
app.py CHANGED
@@ -12,13 +12,29 @@ import urllib.error
12
  import os
13
  import mimetypes
14
  import 数据库连接 as db
 
15
 
16
- from router_users import router as users_router
17
- from router_items import router as items_router
18
- from router_comments import router as comments_router
19
- from router_messages import router as messages_router
20
- from router_wallet import router as wallet_router
21
- from router_proxy import router as proxy_router
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- app.include_router(users_router)
46
- app.include_router(items_router)
47
- app.include_router(comments_router)
48
- app.include_router(messages_router)
49
- app.include_router(wallet_router)
50
- app.include_router(proxy_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, # 【新增】保存密钥到云端 JSON
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
- @router.delete("/api/items/{item_id}")
146
- async def delete_item(item_id: str, author: str):
 
 
 
 
 
 
 
147
  items_db = db.load_data("items.json", default_data=[])
148
- target_idx = next((i for i, item in enumerate(items_db) if item["id"] == item_id), None)
149
 
150
- if target_idx is None: raise HTTPException(status_code=404, detail="找不到该内容记录")
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
- if item["id"] == item_id:
170
- item["uses"] = item.get("uses", 0) + 1
171
- if "use_history" not in item:
172
- item["use_history"] = {}
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
- raise HTTPException(status_code=404, detail="找不到该内容记录")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # 【核心修改】:优先读取该资源在数据库中绑定的专属创作者 Token
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
- async with client.stream("GET", github_zip_api, headers=headers) as response:
64
- if response.status_code != 200:
65
- yield b"GITHUB_DOWNLOAD_FAILED"
66
- return
67
- async for chunk in response.aiter_bytes():
68
- yield chunk
 
 
 
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. 异步拉取真实 JSON 数据并透传回客户端
107
  try:
108
- # 开启 follow_redirects=True 防止源地址有 302 重定向跳转
109
- async with httpx.AsyncClient(follow_redirects=True) as client:
110
- # 🚀 将携带 Token 的 headers 传给 GET 请求,突破私有库 401 封锁
111
- resp = await client.get(target_url, headers=headers, timeout=30.0)
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
- if not wallet:
107
- return {"balance": 0, "earn_balance": 0, "tip_balance": 0}
 
 
 
 
 
 
108
  return {
109
- "balance": wallet.balance,
110
- "earn_balance": wallet.earn_balance,
111
- "tip_balance": wallet.tip_balance
 
 
 
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
- target_account=seller_account, prev_hash=prev_hash, tx_hash=tx_hash
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
- tx_id = f"TIP_{int(time.time())}_{uuid.uuid4().hex[:6]}"
179
- last_tx = db.query(Transaction).filter(Transaction.account == req.sender_account).order_by(Transaction.created_at.desc()).first()
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
- new_tx = Transaction(
184
- tx_id=tx_id, account=req.sender_account, tx_type="TIP", amount=-req.amount,
185
- target_account=req.target_account, prev_hash=prev_hash, tx_hash=tx_hash
186
- )
187
- db.add(new_tx)
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  db.commit()
189
 
190
- from notifications import add_notification
191
- display_sender = "匿名用户" if req.is_anonymous else req.sender_account
192
- add_notification(req.target_account, {
193
- "type": "tip",
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 req.amount > total_withdrawable:
214
  raise HTTPException(status_code=400, detail="可提现余额不足")
215
 
216
- if req.amount <= wallet.earn_balance:
217
- wallet.earn_balance -= req.amount
218
  else:
219
- remaining = req.amount - wallet.earn_balance
220
  wallet.earn_balance = 0
221
  wallet.tip_balance -= remaining
222
 
223
- wallet.frozen_balance += req.amount
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", -req.amount, prev_hash)
229
 
230
  new_tx = Transaction(
231
- tx_id=tx_id, account=req.account, tx_type="WITHDRAW", amount=-req.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 {"status": "success"}
 
 
 
 
 
 
 
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
- # ⚙️ 后端逻辑/数据库连接.py
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- if not os.path.exists(LOCAL_DB_DIR):
20
- os.makedirs(LOCAL_DB_DIR)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
- db_lock = threading.Lock()
 
 
 
 
 
 
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 db_lock:
 
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
- return json.load(f)
 
 
 
 
 
 
 
 
 
53
  except Exception as e:
54
- print(f"解析 {file_name} 失败,启用默认数据。原因: {e}")
55
  return default_data
56
 
57
- # 🟢 隐形炸弹修复:增加错误重试机制,防止由于 Hugging Face 接口瞬间限流导致数据云端丢失
58
- def _background_upload_to_hf(local_path, file_name, retries=3):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- if attempt == retries - 1:
 
 
 
 
72
  print(f"🚨 致命错误:重试 {retries} 次后,同步到 HF Dataset 依然失败: {e}")
73
- time.sleep(2) # 失败后休眠 2 秒避开接口限流再试
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
- def save_data(file_name: str, data):
 
 
 
 
 
 
 
76
  local_path = os.path.join(LOCAL_DB_DIR, file_name)
77
 
78
- with db_lock:
79
- with open(local_path, "w", encoding="utf-8") as f:
80
- json.dump(data, f, ensure_ascii=False, indent=2)
81
 
82
- if HF_TOKEN:
83
- threading.Thread(target=_background_upload_to_hf, args=(local_path, file_name)).start()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)