ZHIWEI666 commited on
Commit
2411edd
·
verified ·
1 Parent(s): 77d7d28

Upload 30 files

Browse files
app.py CHANGED
@@ -1,676 +1,708 @@
1
- # ⚙️ 后端逻辑/核心服务端.py (Hugging Face Spaces app.py)
2
- from runtime_log_privacy import install_uvicorn_privacy_filters, route_label, RequestTraceMiddleware
3
- install_uvicorn_privacy_filters()
4
-
5
- from fastapi import FastAPI, File, UploadFile, Form, Depends, HTTPException, Request
6
- from rate_limit import shared_limit
7
- from response_privacy import requires_no_store
8
- from fastapi.middleware.cors import CORSMiddleware
9
- from fastapi.responses import Response, JSONResponse, FileResponse
10
- from sqlalchemy.orm import Session
11
- from pydantic import BaseModel
12
- from huggingface_hub import hf_hub_download, HfApi
13
- import uuid
14
- import urllib.parse
15
- import urllib.request
16
- import urllib.error
17
- import os
18
- import json
19
- import logging
20
- import time
21
- import datetime
22
- import threading
23
- import 数据库连接 as db
24
- import asyncio
25
- from 安全认证 import require_auth
26
- from profile_io import start_profile_io, close_profile_io
27
- from script_execution import start_admin_scripts, stop_admin_scripts, close_admin_scripts
28
- from comment_store import start_comment_reads, stop_comment_reads, close_comment_reads
29
- from periodic_tasks import start_periodic_task, request_periodic_stop, wait_periodic_tasks
30
- from owned_thread import run_owned_thread
31
-
32
-
33
- # ==========================================
34
- # 📝 P2优化:日志配置
35
- # ==========================================
36
- logging.basicConfig(
37
- level=logging.INFO,
38
- format='%(asctime)s | %(levelname)s | %(name)s | %(message)s',
39
- datefmt='%Y-%m-%d %H:%M:%S'
40
- )
41
- logger = logging.getLogger("ComfyUI-Ranking")
42
- _UPLOAD_CONCURRENCY = max(1, min(int(os.environ.get("MAX_CONCURRENT_UPLOADS", "4")), 16))
43
- _UPLOAD_SEMAPHORE = threading.BoundedSemaphore(_UPLOAD_CONCURRENCY)
44
-
45
-
46
- from 云端_定时版本检测引擎 import daily_version_check_task, reset_daily_views_task
47
-
48
- # ==========================================
49
- # 👥 用户模块 (拆分为3个子模块)
50
- # ==========================================
51
- # router_users_auth.py - 🔐 登录/注册/密码重置/验证码
52
- # router_users_profile.py - 👤 获取/更新用户资料
53
- # router_users_social.py - 🤝 关注/隐私设置
54
- from router_users_auth import router as users_auth_router
55
- from router_users_profile import router as users_profile_router
56
- from router_users_social import router as users_social_router
57
-
58
- # ==========================================
59
- # 其他业务模块
60
- # ==========================================
61
- from router_items import router as items_router # 📦 内容管理(工具/Skill/应用/推荐)
62
- from router_comments import router as comments_router # 💬 评论系统
63
- from router_messages import router as messages_router # ✉️ 私信系统
64
- from router_wallet import router as wallet_router # 💰 钱包/提现
65
- from router_proxy import router as proxy_router # 🔗 代理下载
66
- from router_posts import router as posts_router # 💬 讨论区
67
- from router_prompts import router as prompts_router # ✨ 提示词市场
68
- from router_tasks import router as tasks_router # 📝 任务榜
69
- from router_open_api import router as open_api_router # 🌐 通用开放API(第三方插件)
70
- from router_admin_plugins import router as admin_plugins_router # 🔒 管理后台 - 第三方插件管理API
71
-
72
- from database_sql import init_sql_db, get_db, SessionLocal
73
- from models_sql import SystemConfig # 🧹 P2清理:Ownership 在本文件无引用
74
-
75
- from fastapi.exceptions import RequestValidationError
76
- from starlette.exceptions import HTTPException as StarletteHTTPException
77
-
78
- app = FastAPI(
79
- title="ComfyUI Ranking API",
80
- description="ComfyUI 社区排名系统 API",
81
- version="2.0.0",
82
- docs_url="/api/docs",
83
- redoc_url="/api/redoc"
84
- )
85
-
86
-
87
- # ==========================================
88
- # 🛡️ 稳定性优化:全局异常处理器
89
- # ==========================================
90
- # 作用:捕获所有未处理异常,防止单个请求崩溃整个服务
91
- # 🚀 P3优化:统一错误响应格式
92
-
93
- def create_error_response(status_code: int, detail: str, error_type: str = None, error_id: str = None, extra: dict = None):
94
- """🚀 P3优化:统一错误响应构造器"""
95
- response = {
96
- "success": False,
97
- "status": "error",
98
- "code": status_code,
99
- "detail": detail,
100
- }
101
- if error_type:
102
- response["type"] = error_type
103
- if error_id:
104
- response["error_id"] = error_id
105
- if extra:
106
- response.update(extra)
107
- return response
108
-
109
- @app.exception_handler(StarletteHTTPException)
110
- async def http_exception_handler(request, exc):
111
- """🚀 P3优化:HTTP 异常处理,返回统一格式"""
112
- logger.warning(f"HTTP {exc.status_code} | {route_label(request)} | detail_type={type(exc.detail).__name__}")
113
-
114
- # 根据状态码确定错误类型
115
- error_type_map = {
116
- 400: "bad_request",
117
- 401: "unauthorized",
118
- 403: "forbidden",
119
- 404: "not_found",
120
- 409: "conflict",
121
- 429: "rate_limited",
122
- 502: "upstream_error",
123
- }
124
- error_type = error_type_map.get(exc.status_code, "http_error")
125
-
126
- return JSONResponse(
127
- status_code=exc.status_code,
128
- content=create_error_response(exc.status_code, str(exc.detail), error_type),
129
- headers={"X-Ranking-Error-Scope": "endpoint"} if exc.status_code == 503 else None,
130
- )
131
-
132
- from upload_body_guard import UploadBodyGuard
133
- app.add_middleware(UploadBodyGuard, capacity=_UPLOAD_CONCURRENCY, error_response=create_error_response)
134
-
135
- from public_list_etag import PublicListETag
136
- app.add_middleware(PublicListETag)
137
-
138
- @app.middleware("http")
139
- async def private_response_cache_control(request: Request, call_next):
140
- response = await call_next(request)
141
- if requires_no_store(request.url.path, request.headers.get("Authorization")):
142
- response.headers["Cache-Control"] = "no-store"
143
- vary = [name.strip() for name in response.headers.get("Vary", "").split(",") if name.strip()]
144
- if not any(name.lower() in {"authorization", "*"} for name in vary):
145
- vary.append("Authorization")
146
- response.headers["Vary"] = ", ".join(vary)
147
- return response
148
-
149
-
150
- @app.exception_handler(RequestValidationError)
151
- async def validation_exception_handler(request, exc):
152
- """🚀 P3优化:请求参数校验失败处理"""
153
- logger.warning("Validation Error | %s | count=%d", route_label(request), len(exc.errors()))
154
-
155
- # 提取简洁的错误信息
156
- errors = []
157
- for err in exc.errors():
158
- field = ".".join(str(loc) for loc in err["loc"][1:]) # 跳过 'body'
159
- errors.append({"field": field, "message": "该字段格式不正确", "type": err["type"]})
160
-
161
- return JSONResponse(
162
- status_code=422,
163
- content=create_error_response(
164
- 422,
165
- "请求参数格式错误",
166
- "validation_error",
167
- extra={"errors": errors}
168
- )
169
- )
170
-
171
- @app.exception_handler(Exception)
172
- async def global_exception_handler(request, exc):
173
- """🚀 P3优化:全局异常处理,捕获所有未预期异常"""
174
- error_id = getattr(request.state, 'ranking_request_id', None) or uuid.uuid4().hex
175
- logger.error(f"Unhandled Exception [{error_id}] | {route_label(request)} | error_type={type(exc).__name__}")
176
- return JSONResponse(
177
- status_code=500,
178
- content=create_error_response(
179
- 500,
180
- "服务器内部错误,请稍后重试",
181
- "internal_error",
182
- error_id=error_id
183
- ),
184
- headers={"X-Ranking-Request-Id": error_id}
185
- )
186
-
187
-
188
- # ==========================================
189
- # ❤️ 健康检查接口 (增强版)
190
- # ==========================================
191
- @app.get("/")
192
- def health_check():
193
- return {"status": "ok", "message": "ComfyUI Ranking API is running perfectly!"}
194
-
195
- @app.get("/health")
196
- def detailed_health_check():
197
- """详细健康检查,含依赖状态"""
198
- health_status = {
199
- "status": "ok",
200
- "components": {}
201
- }
202
-
203
- # 检查 SQL 数据库
204
- try:
205
- from database_sql import check_db_connection
206
- if check_db_connection():
207
- health_status["components"]["sql_database"] = "ok"
208
- else:
209
- health_status["components"]["sql_database"] = "error: connection failed"
210
- health_status["status"] = "degraded"
211
- except Exception as e:
212
- logger.warning(f"健康检查 SQL 数据库异常: error_type={type(e).__name__}")
213
- health_status["components"]["sql_database"] = "error: connection failed"
214
- health_status["status"] = "degraded"
215
-
216
- # 检查 JSON 文件访问
217
- try:
218
- import 数据库连接 as json_db
219
- json_db.load_data("users.json", default_data={})
220
- health_status["components"]["json_storage"] = "ok"
221
- except Exception as e:
222
- logger.warning(f"健康检查 JSON 存储异常: error_type={type(e).__name__}")
223
- health_status["components"]["json_storage"] = "error: storage unavailable"
224
- health_status["status"] = "degraded"
225
-
226
- return health_status
227
-
228
- # ==========================================
229
- # 🚀 应用启动事件
230
- # ==========================================
231
- # 作用:初始化数据库 + 启动定时版本检测后台任务 + 预热检查
232
- @app.on_event("startup")
233
- async def on_startup():
234
- logger.info("🚀 ComfyUI-Ranking API 启动中...")
235
- if getattr(app.state, "_ranking_starting", False) or getattr(app.state, "_ranking_closers", 0):
236
- raise RuntimeError("application lifecycle is already in progress")
237
- if getattr(app.state, "_ranking_started", False):
238
- return
239
- app.state._ranking_starting = True
240
- try:
241
- start_profile_io()
242
- try:
243
- start_comment_reads()
244
- start_admin_scripts()
245
-
246
- # ========== 预热检查:SQL 数据库 ==========
247
- try:
248
- init_sql_db()
249
- logger.info("✅ SQL 数据库初始化完成")
250
- except Exception as e:
251
- logger.error(f"❌ SQL 数据库初始化失败: error_type={type(e).__name__}")
252
- # 不抛出异常,允许降级运行
253
-
254
- # ========== 加载第三方插件注册表(依赖 SQL 数据库) ==========
255
- try:
256
- from router_open_api import load_plugins_from_db
257
- load_plugins_from_db()
258
- logger.info("✅ 第三方插件注册表已加载")
259
- except Exception as e:
260
- logger.warning(f"⚠️ 第三方插件注册表加载失败: error_type={type(e).__name__}")
261
-
262
- # ========== 预热检查:JSON 数据库 ==========
263
- try:
264
- db.load_data("users.json", default_data={})
265
- db.load_data("items.json", default_data=[])
266
- logger.info("✅ JSON 数据库访问正常")
267
- except Exception as e:
268
- logger.warning(f"⚠️ JSON 数据库访问异常: error_type={type(e).__name__}")
269
-
270
- # SQL资金已冻结但JSON发布失败的任务,重启后继续投影;持有句柄供停机收尾。
271
- from task_publication import publication_worker
272
- from database_sql import engine
273
- from db_utils import sort_cache
274
- start_periodic_task(app.state, "task_publication",
275
- lambda stop: publication_worker(engine, db, sort_cache.invalidate, stop))
276
-
277
- from resource_publication import publication_worker as resource_publication_worker
278
- start_periodic_task(app.state, "resource_publication",
279
- lambda stop: resource_publication_worker(engine, db, sort_cache.invalidate, stop))
280
-
281
- from resource_deletion import resource_deletion_worker
282
- start_periodic_task(app.state, "resource_deletion",
283
- lambda stop: resource_deletion_worker(db, stop, sort_cache.invalidate))
284
-
285
- # ========== 启动后台任务 ==========
286
- start_periodic_task(app.state, "daily_version", daily_version_check_task)
287
- logger.info("✅ 定时版本检测任务已挂载")
288
-
289
- start_periodic_task(app.state, "daily_views", reset_daily_views_task)
290
- logger.info("✅ daily_views 每日重置任务已挂载")
291
-
292
- # ========== 启动 HF 批量同步定时器 ==========
293
- db.start_batch_sync()
294
- logger.info("✅ HF 同步调度初始化已完成")
295
-
296
- app.state._ranking_started = True
297
- logger.info("🎉 ComfyUI-Ranking API 启动完成!")
298
- except BaseException:
299
- # 启动可能只登记了部分句柄;正常关闭按已登记对象收尾。
300
- try:
301
- await on_shutdown()
302
- except BaseException as error:
303
- logger.error("启动收尾失败: error_type=%s", type(error).__name__)
304
- raise
305
- finally:
306
- app.state._ranking_starting = False
307
-
308
-
309
- @app.on_event("shutdown")
310
- async def on_shutdown():
311
- """优雅关闭,清理资源"""
312
- logger.info("🛑 ComfyUI-Ranking API 正在关闭...")
313
-
314
- app.state._ranking_closers = getattr(app.state, "_ranking_closers", 0) + 1
315
- try:
316
- app.state._ranking_started = False
317
- stop_admin_scripts()
318
- stop_comment_reads()
319
- workers = request_periodic_stop(app.state, (
320
- "daily_version", "daily_views", "task_publication", "resource_publication", "resource_deletion"))
321
-
322
- # 关闭等待被取消也先收尾全部所属工作,之后传播取消;不提前最终同步。
323
- cancelled = None
324
- try:
325
- await close_profile_io()
326
- except asyncio.CancelledError as error:
327
- cancelled = error
328
- try:
329
- await close_admin_scripts()
330
- except asyncio.CancelledError as error:
331
- cancelled = error
332
- try:
333
- await close_comment_reads()
334
- except asyncio.CancelledError as error:
335
- cancelled = error
336
- try:
337
- await wait_periodic_tasks(workers)
338
- except asyncio.CancelledError as error:
339
- cancelled = error
340
- if cancelled is not None:
341
- raise cancelled
342
-
343
- # ========== 关闭前同步所有脏文件 ==========
344
- await run_owned_thread(db.flush_sync)
345
- logger.info("✅ HF 关闭前同步尝试已结束")
346
-
347
- # 这里可以添加其他清理逻辑(如关闭连接池等)
348
- logger.info("✅ 关闭完成")
349
- finally:
350
- app.state._ranking_closers -= 1
351
-
352
- # 🛡️ CORS 配置:默认通配符(插件前端运行于用户本地 ComfyUI 页面,origin 为
353
- # 127.0.0.1/localhost/局域网 IP/自定义端口等无法穷举;本 API 通过 Authorization 头
354
- # 传递 Token 且 allow_credentials=False 不依赖 Cookie���通配风险可控)。
355
- # 如需收紧可在环境变量 ALLOWED_ORIGINS 中逗号分隔指定域名。
356
- ALLOWED_ORIGINS = os.environ.get("ALLOWED_ORIGINS", "*").split(",")
357
- logger.info(
358
- f"CORS 配置已加载: origin_count={len(ALLOWED_ORIGINS)} | "
359
- f"wildcard={len(ALLOWED_ORIGINS) == 1 and ALLOWED_ORIGINS[0] == '*'}"
360
- )
361
-
362
- app.add_middleware(
363
- CORSMiddleware,
364
- allow_origins=ALLOWED_ORIGINS,
365
- allow_credentials=False,
366
- allow_methods=["*"],
367
- allow_headers=["*"],
368
- expose_headers=["ETag", "X-Ranking-Error-Scope", "X-Ranking-Request-Id"],
369
- )
370
- app.add_middleware(RequestTraceMiddleware)
371
-
372
- # ==========================================
373
- # 路由挂载
374
- # ==========================================
375
- # 用户模块 (3个子模块)
376
- app.include_router(users_auth_router) # 🔐 登录/注册/密码重置
377
- app.include_router(users_profile_router) # 👤 用户资料
378
- app.include_router(users_social_router) # 🤝 关注/隐私
379
-
380
- # 其他业务模块
381
- app.include_router(items_router) # 📦 内容管理
382
- app.include_router(comments_router) # 💬 评论系统
383
- app.include_router(messages_router) # ✉️ 私信系统
384
- app.include_router(wallet_router) # 💰 钱包/提现
385
- app.include_router(proxy_router) # 🔗 代理下载
386
- app.include_router(posts_router) # 💬 讨论区
387
- app.include_router(prompts_router) # ✨ 提示词市场
388
- app.include_router(tasks_router) # 📝 任务榜
389
- app.include_router(open_api_router) # 🌐 通用开放API(供第三方插件调用)
390
- app.include_router(admin_plugins_router) # 🔒 管理后台 - 第三方插件管理API
391
-
392
- # ==========================================
393
- # 🟢 私有图床代理中心 (Image Proxy)
394
- # 解决 Private 仓库下,本地客户端报 401 Unauthorized 的终极方案
395
- # ==========================================
396
- from media_proxy_service import read_public_media
397
-
398
- @app.get("/api/image_proxy")
399
- def proxy_hf_image(url: str = None, path: str = None):
400
- return read_public_media(url, path, store=db, download=hf_hub_download,
401
- file_response=FileResponse, error_response=create_error_response)
402
-
403
- # ==========================================
404
- # 上传接口 (将返回的 URL 替换为 Proxy 代理链接)
405
- # 🔒 P0安全优化:集成图片内容审核
406
- # ==========================================
407
- from image_moderation import moderate_image_sync, ModerationResult
408
-
409
- # ==========================================
410
- # 🎵 音频安全净化:重编码压缩,剥离元数据与隐藏附加信息
411
- # ==========================================
412
- from upload_service import process_upload
413
-
414
- @app.post("/api/upload")
415
- @shared_limit(30)
416
- def upload_file(request: Request, file: UploadFile = File(...), file_type: str = Form(...), current_user: str = Depends(require_auth)):
417
- return process_upload(file, file_type, current_user, store=db, storage_factory=HfApi,
418
- moderate=moderate_image_sync, limiter=_UPLOAD_SEMAPHORE,
419
- error_response=create_error_response)
420
-
421
-
422
-
423
- from github_source import select_github_token
424
-
425
-
426
- class ValidateResourceRequest(BaseModel):
427
- url: str
428
- item_id: str
429
- account: str
430
-
431
- from resource_probe_service import probe_resource
432
- from github_probe_transport import open_github_probe
433
- from skill_preflight import verify_skill_repository
434
- from router_proxy import _send_with_validated_redirects
435
- from safe_http_transport import ValidatedAsyncHTTPTransport
436
- import httpx
437
-
438
-
439
- def _probe_skill_repository(item, token):
440
- return verify_skill_repository(item, token, send=_send_with_validated_redirects,
441
- client_factory=httpx.AsyncClient, transport_factory=ValidatedAsyncHTTPTransport)
442
-
443
- @app.post("/api/validate_resource")
444
- @shared_limit(30)
445
- def validate_resource(req_data: ValidateResourceRequest, request: Request, sql_db: Session = Depends(get_db), current_user: str = Depends(require_auth)):
446
- return probe_resource(req_data.item_id, store=db, download=hf_hub_download,
447
- open_url=open_github_probe, token_selector=select_github_token,
448
- error_response=create_error_response, skill_probe=_probe_skill_repository)
449
-
450
- # ==========================================
451
- # 🔒 管理员:系统配置 API
452
- # ==========================================
453
- from 安全认证 import require_auth, ADMIN_ACCOUNTS
454
-
455
- def _is_admin(account: str) -> bool:
456
- """检查账号是否为管理员"""
457
- from 安全认证 import is_admin as _check_admin
458
- return _check_admin(account)
459
-
460
- # 配置存储文件
461
- SYSTEM_CONFIG_FILE = "/tmp/system_config.json" # 保留作为降级方案
462
-
463
- def _load_system_config_from_db(db: Session, *, strict: bool = False) -> dict:
464
- """从数据库加载所有系统配置"""
465
- try:
466
- configs = db.query(SystemConfig).all()
467
- result = {}
468
- for cfg in configs:
469
- try:
470
- result[cfg.config_key] = json.loads(cfg.config_value)
471
- except (json.JSONDecodeError, TypeError):
472
- result[cfg.config_key] = cfg.config_value
473
- return result
474
- except Exception as e:
475
- logger.warning(f"从数据库加载系统配置失败,降级到文件: error_type={type(e).__name__}")
476
- if strict:
477
- return _load_system_config_from_file(strict=True)
478
- return _load_system_config_from_file()
479
-
480
- def _load_system_config_from_file(*, strict: bool = False) -> dict:
481
- """降级方案;严格读取不把故障或缺失文件冒充空配置。"""
482
- if strict:
483
- with open(SYSTEM_CONFIG_FILE, "r", encoding="utf-8") as stream:
484
- config = json.load(stream)
485
- if not isinstance(config, dict):
486
- raise ValueError("invalid system configuration root")
487
- return config
488
- try:
489
- if os.path.exists(SYSTEM_CONFIG_FILE):
490
- with open(SYSTEM_CONFIG_FILE, "r") as f:
491
- return json.load(f)
492
- except Exception as e:
493
- logger.debug(f"加载系统配置文件失败: error_type={type(e).__name__}")
494
- return {}
495
-
496
- def _save_system_config_to_db(db: Session, config_key: str, value, updated_by: str = None, *, strict: bool = False):
497
- """保存单个配置项到数据库"""
498
- try:
499
- cfg = db.query(SystemConfig).filter(SystemConfig.config_key == config_key).first()
500
- if not cfg:
501
- cfg = SystemConfig(config_key=config_key)
502
- db.add(cfg)
503
- cfg.config_value = json.dumps(value, ensure_ascii=False, allow_nan=not strict)
504
- cfg.updated_by = updated_by
505
- cfg.updated_at = datetime.datetime.utcnow()
506
- db.commit()
507
- except Exception as e:
508
- if strict:
509
- # commit异常可能发生在真实提交之后;回滚不能证明尚未保存。
510
- try:
511
- db.rollback()
512
- except Exception:
513
- logger.warning("SYSTEM_CONFIG_ROLLBACK_UNCONFIRMED")
514
- raise
515
- db.rollback()
516
- logger.warning(f"保存系统配置到数据库失败,降级到文件: error_type={type(e).__name__}")
517
- # 降级:保存到文件
518
- config = _load_system_config_from_file()
519
- config[config_key] = value
520
- try:
521
- with open(SYSTEM_CONFIG_FILE, "w") as f:
522
- json.dump(config, f, ensure_ascii=False)
523
- except Exception as fe:
524
- logger.warning(f"降级保存系统配置文件也失败: error_type={type(fe).__name__}")
525
-
526
- # 兼容旧调用的包装函数
527
- def _load_system_config(db: Session = None, *, strict: bool = False) -> dict:
528
- """加载系统配置(优先数据库,降级文件);严格模式保留故障。"""
529
- if strict:
530
- if db is not None:
531
- return _load_system_config_from_db(db, strict=True)
532
- temp_db = SessionLocal()
533
- try:
534
- return _load_system_config_from_db(temp_db, strict=True)
535
- finally:
536
- temp_db.close()
537
- if db:
538
- return _load_system_config_from_db(db)
539
- # 无 db session 时(如公开端点),创建临时 session
540
- temp_db = SessionLocal()
541
- try:
542
- return _load_system_config_from_db(temp_db)
543
- except Exception:
544
- return _load_system_config_from_file()
545
- finally:
546
- temp_db.close()
547
-
548
- def _save_system_config(config: dict, db: Session = None, updated_by: str = None):
549
- """保存系统配置(优先数据库,降级文件)"""
550
- if db:
551
- for key, value in config.items():
552
- _save_system_config_to_db(db, key, value, updated_by)
553
- return
554
- # 无 db session 时,创建临时 session
555
- temp_db = SessionLocal()
556
- try:
557
- for key, value in config.items():
558
- _save_system_config_to_db(temp_db, key, value, updated_by)
559
- except Exception:
560
- pass
561
- finally:
562
- temp_db.close()
563
-
564
-
565
- @app.get("/api/admin/config/{config_key}")
566
- async def get_system_config(config_key: str, current_user: str = Depends(require_auth), db: Session = Depends(get_db)):
567
- """ 🔒 获取系统配置(仅管理员) """
568
- # 🔒 权限验证
569
- if not _is_admin(current_user):
570
- raise HTTPException(status_code=403, detail="无权访问系统配置,仅管理员可操作")
571
-
572
- def reject_constant(_value):
573
- raise ValueError("invalid configuration constant")
574
- try:
575
- # 管理回读用于确认SQL保存结果,不能用另一个来源的旧值代替。
576
- storage_key = "image_moderation_enabled" if config_key == "image_moderation" else config_key
577
- row = db.query(SystemConfig).filter(SystemConfig.config_key == storage_key).first()
578
- value = None if row is None else json.loads(row.config_value, parse_constant=reject_constant)
579
- if config_key == "image_moderation":
580
- if row is None:
581
- from image_moderation import _is_moderation_enabled
582
- value = _is_moderation_enabled()
583
- if type(value) is not bool:
584
- raise ValueError("invalid moderation configuration")
585
- value = {"enabled": value}
586
- except Exception:
587
- raise HTTPException(status_code=503, detail="config_read_unavailable") from None
588
- return {"status": "success", "data": value}
589
-
590
-
591
- # 🏷️ 版本配置默认值
592
- DEFAULT_VERSION_CONFIG = {
593
- "stage": "beta",
594
- "major": 2,
595
- "minor": 0,
596
- "patch": 0
597
- }
598
-
599
-
600
- @app.get("/api/public/project-version")
601
- async def get_public_project_version(db: Session = Depends(get_db)):
602
- """获取项目版本信息(公开接口,无需认证)"""
603
- config = _load_system_config(db)
604
- version_data = config.get("project_version", DEFAULT_VERSION_CONFIG)
605
- return {"status": "success", "data": version_data}
606
-
607
-
608
- @app.get("/api/public/banner_config")
609
- async def get_public_banner_config(db: Session = Depends(get_db)):
610
- """公开接口:获取广告横幅配置(所有已登录/未登录用户均可访问)"""
611
- try:
612
- config = _load_system_config(db, strict=True)
613
- if not isinstance(config, dict):
614
- raise ValueError("invalid system configuration root")
615
- banner = config.get("banner_config")
616
- if banner is None:
617
- return {"status": "success", "data": None}
618
- if not isinstance(banner, dict) or type(banner.get("enabled")) is not bool:
619
- raise ValueError("invalid banner configuration")
620
- if not banner["enabled"]:
621
- return {"status": "success", "data": None}
622
- # 只投影展示字段,异常配置不能触发浏览器清除旧横幅。
623
- result = {"enabled": True}
624
- for field in ("bannerImage", "title", "description", "detailImage", "detailContent", "link"):
625
- value = banner.get(field, "")
626
- if not isinstance(value, str):
627
- raise ValueError("invalid banner display field")
628
- result[field] = value
629
- if not result["bannerImage"].strip():
630
- raise ValueError("missing banner image")
631
- return {"status": "success", "data": result}
632
- except Exception:
633
- raise HTTPException(status_code=503, detail="banner_config_unavailable") from None
634
-
635
-
636
- @app.put("/api/admin/config/{config_key}")
637
- async def set_system_config(config_key: str, value: dict, current_user: str = Depends(require_auth), db: Session = Depends(get_db)):
638
- """ 🔒 设置系统配置(仅管理员) """
639
- # 🔒 权限验证
640
- if not _is_admin(current_user):
641
- raise HTTPException(status_code=403, detail="无权修改系统配置,仅管理员可操作")
642
- storage_key = config_key
643
- storage_value = value
644
- if config_key == "image_moderation":
645
- if set(value) != {"enabled"} or type(value["enabled"]) is not bool:
646
- raise HTTPException(status_code=400, detail="invalid_moderation_configuration")
647
- storage_key = "image_moderation_enabled"
648
- storage_value = value["enabled"]
649
- try:
650
- _save_system_config_to_db(db, storage_key, storage_value, updated_by=current_user, strict=True)
651
- except Exception:
652
- raise HTTPException(status_code=503, detail="config_save_unconfirmed") from None
653
- return {"status": "success", "message": "配置已更新"}
654
-
655
-
656
- # ==========================================
657
- # 🖥️ 管理界面静态文件挂载
658
- # ⚠️ 必须放在所有 API 路由注册之后,避免拦截 API
659
- # 访问 /admin/ 会自动返回 管理界面/index.html
660
- # ==========================================
661
- from fastapi.staticfiles import StaticFiles
662
- app.mount("/admin", StaticFiles(directory="管理界面", html=True), name="admin")
663
-
664
- # ==========================================
665
- # 📱 移动端网页静态挂载(复用前端的 Web 版)
666
- # 部署约定:同步 HF Space 时,除本目录内容外,
667
- # 须将仓库根目录的 前端页面/ 整个目录一并复制到 Space 仓库根目录
668
- # /m/assets 提供共享前端模块;/m 返回 移动端网页/index.html
669
- # ⚠️ 必须放在所有 API 路由之后;先挂载更具体的 /m/assets,再挂载 /m
670
- # 容错:目录缺失时仅跳过挂载并警告,不阻断 API 服务启动(移动端页面将 404)
671
- # ==========================================
672
- if os.path.isdir("前端页面"):
673
- app.mount("/m/assets", StaticFiles(directory="前端页面"), name="web_assets")
674
- app.mount("/m", StaticFiles(directory="移动端网页", html=True), name="mobile_web")
675
- else:
676
- logger.warning("⚠️ 未找到 前端页面/ 目录,移动端网页 /m/ 未挂载(部署时须将仓库根目录的 前端页面/ 复制到 Space 仓库根目录),API 服务不受影响")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ⚙️ 后端逻辑/核心服务端.py (Hugging Face Spaces app.py)
2
+ from runtime_log_privacy import install_uvicorn_privacy_filters, route_label, RequestTraceMiddleware
3
+ install_uvicorn_privacy_filters()
4
+
5
+ from fastapi import FastAPI, File, UploadFile, Form, Depends, HTTPException, Request
6
+ from rate_limit import shared_limit
7
+ from response_privacy import requires_no_store
8
+ from fastapi.middleware.cors import CORSMiddleware
9
+ from fastapi.responses import Response, JSONResponse, FileResponse
10
+ from sqlalchemy.orm import Session
11
+ from pydantic import BaseModel
12
+ from huggingface_hub import hf_hub_download, HfApi
13
+ import uuid
14
+ import urllib.parse
15
+ import urllib.request
16
+ import urllib.error
17
+ import os
18
+ import json
19
+ import logging
20
+ import time
21
+ import datetime
22
+ import threading
23
+ import 数据库连接 as db
24
+ import asyncio
25
+ from 安全认证 import require_auth
26
+ from profile_io import start_profile_io, close_profile_io
27
+ from verification_io import start_verification_io, stop_verification_io, close_verification_io
28
+ from auth_io import start_auth_io, stop_auth_io, close_auth_io
29
+ from auth_io import start_identity_io, stop_identity_io, close_identity_io
30
+ from rate_limit import start_shared_work, stop_shared_work, close_shared_work
31
+ from script_execution import start_admin_scripts, stop_admin_scripts, close_admin_scripts
32
+ from comment_store import start_comment_reads, stop_comment_reads, close_comment_reads
33
+ from periodic_tasks import start_periodic_task, request_periodic_stop, wait_periodic_tasks
34
+ from owned_thread import run_owned_thread
35
+ from resource_work import start_resource_work, stop_resource_work, close_resource_work
36
+ from shutdown_lifecycle import (prepare_application_shutdown, run_application_shutdown,
37
+ shutdown_cancel_requested, ShutdownFailures)
38
+ from startup_warmup import (prepare_startup_warmup, run_startup_warmup, stop_startup_warmup,
39
+ wait_startup_warmup, startup_warmup_stopped, startup_shutdown_requested)
40
+
41
+
42
+ # ==========================================
43
+ # 📝 P2优化:日志配置
44
+ # ==========================================
45
+ logging.basicConfig(
46
+ level=logging.INFO,
47
+ format='%(asctime)s | %(levelname)s | %(name)s | %(message)s',
48
+ datefmt='%Y-%m-%d %H:%M:%S'
49
+ )
50
+ logger = logging.getLogger("ComfyUI-Ranking")
51
+ _UPLOAD_CONCURRENCY = max(1, min(int(os.environ.get("MAX_CONCURRENT_UPLOADS", "4")), 16))
52
+ _UPLOAD_SEMAPHORE = threading.BoundedSemaphore(_UPLOAD_CONCURRENCY)
53
+
54
+
55
+ from 云端_定时版本检测引擎 import daily_version_check_task, reset_daily_views_task
56
+
57
+ # ==========================================
58
+ # 👥 用户模块 (拆分为3个子模块)
59
+ # ==========================================
60
+ # router_users_auth.py - 🔐 登录/注册/密码重置/验证码
61
+ # router_users_profile.py - 👤 获取/更新用户资料
62
+ # router_users_social.py - 🤝 关注/隐私设置
63
+ from router_users_auth import router as users_auth_router
64
+ from router_users_profile import router as users_profile_router
65
+ from router_users_social import router as users_social_router
66
+
67
+ # ==========================================
68
+ # 其他业务模块
69
+ # ==========================================
70
+ from router_items import router as items_router # 📦 内容管理(工具/Skill/应用/推荐)
71
+ from router_comments import router as comments_router # 💬 评论系统
72
+ from router_messages import (router as messages_router, start_message_io, stop_message_io, close_message_io) # ✉️ 私信系统
73
+ from router_wallet import router as wallet_router # 💰 钱包/提现
74
+ from router_proxy import router as proxy_router # 🔗 代理下载
75
+ from router_posts import router as posts_router # 💬 讨论区
76
+ from router_prompts import router as prompts_router # ✨ 提示词市场
77
+ from router_tasks import router as tasks_router # 📝 任务榜
78
+ from router_open_api import start_open_sql, stop_open_sql, close_open_sql
79
+ from router_open_api import router as open_api_router # 🌐 通用开放API(第三方插件)
80
+ from router_admin_plugins import router as admin_plugins_router # 🔒 管理后台 - 第三方插件管理API
81
+
82
+ from database_sql import init_sql_db, get_db, SessionLocal
83
+ from models_sql import SystemConfig # 🧹 P2清理:Ownership 在本文件无引用
84
+
85
+ from fastapi.exceptions import RequestValidationError
86
+ from starlette.exceptions import HTTPException as StarletteHTTPException
87
+
88
+ app = FastAPI(
89
+ title="ComfyUI Ranking API",
90
+ description="ComfyUI 社区排名系统 API",
91
+ version="2.0.0",
92
+ docs_url="/api/docs",
93
+ redoc_url="/api/redoc"
94
+ )
95
+
96
+
97
+ # ==========================================
98
+ # 🛡️ 稳定性优化:全局异常处理器
99
+ # ==========================================
100
+ # 作用:捕获所有未处理异常,防止单个请求崩溃整个服务
101
+ # 🚀 P3优化:统一错误响应格式
102
+
103
+ def create_error_response(status_code: int, detail: str, error_type: str = None, error_id: str = None, extra: dict = None):
104
+ """🚀 P3优化:统一错误响应构造器"""
105
+ response = {
106
+ "success": False,
107
+ "status": "error",
108
+ "code": status_code,
109
+ "detail": detail,
110
+ }
111
+ if error_type:
112
+ response["type"] = error_type
113
+ if error_id:
114
+ response["error_id"] = error_id
115
+ if extra:
116
+ response.update(extra)
117
+ return response
118
+
119
+ @app.exception_handler(StarletteHTTPException)
120
+ async def http_exception_handler(request, exc):
121
+ """🚀 P3优化:HTTP 异常处理,返回统一格式"""
122
+ logger.warning(f"HTTP {exc.status_code} | {route_label(request)} | detail_type={type(exc.detail).__name__}")
123
+
124
+ # 根据状态码确定错误类型
125
+ error_type_map = {
126
+ 400: "bad_request",
127
+ 401: "unauthorized",
128
+ 403: "forbidden",
129
+ 404: "not_found",
130
+ 409: "conflict",
131
+ 429: "rate_limited",
132
+ 502: "upstream_error",
133
+ }
134
+ error_type = error_type_map.get(exc.status_code, "http_error")
135
+
136
+ return JSONResponse(
137
+ status_code=exc.status_code,
138
+ content=create_error_response(exc.status_code, str(exc.detail), error_type),
139
+ headers={"X-Ranking-Error-Scope": "endpoint"} if exc.status_code == 503 else None,
140
+ )
141
+
142
+ from request_body_guard import RequestBodyGuard
143
+ app.add_middleware(RequestBodyGuard, error_response=create_error_response)
144
+
145
+ from upload_body_guard import UploadBodyGuard
146
+ app.add_middleware(UploadBodyGuard, capacity=_UPLOAD_CONCURRENCY, error_response=create_error_response)
147
+
148
+ from public_list_etag import PublicListETag
149
+ app.add_middleware(PublicListETag)
150
+
151
+ @app.middleware("http")
152
+ async def private_response_cache_control(request: Request, call_next):
153
+ response = await call_next(request)
154
+ if requires_no_store(request.url.path, request.headers.get("Authorization")):
155
+ response.headers["Cache-Control"] = "no-store"
156
+ vary = [name.strip() for name in response.headers.get("Vary", "").split(",") if name.strip()]
157
+ if not any(name.lower() in {"authorization", "*"} for name in vary):
158
+ vary.append("Authorization")
159
+ response.headers["Vary"] = ", ".join(vary)
160
+ return response
161
+
162
+
163
+ @app.exception_handler(RequestValidationError)
164
+ async def validation_exception_handler(request, exc):
165
+ """🚀 P3优化:请求参数校验失败处理"""
166
+ logger.warning("Validation Error | %s | count=%d", route_label(request), len(exc.errors()))
167
+
168
+ # 提取简洁的错误信息
169
+ errors = []
170
+ for err in exc.errors():
171
+ field = ".".join(str(loc) for loc in err["loc"][1:]) # 跳过 'body'
172
+ errors.append({"field": field, "message": "该字段格式不正确", "type": err["type"]})
173
+
174
+ return JSONResponse(
175
+ status_code=422,
176
+ content=create_error_response(
177
+ 422,
178
+ "请求参数格式错误",
179
+ "validation_error",
180
+ extra={"errors": errors}
181
+ )
182
+ )
183
+
184
+ @app.exception_handler(Exception)
185
+ async def global_exception_handler(request, exc):
186
+ """🚀 P3优化:全局异常处理,捕获所有未预期异常"""
187
+ error_id = getattr(request.state, 'ranking_request_id', None) or uuid.uuid4().hex
188
+ logger.error(f"Unhandled Exception [{error_id}] | {route_label(request)} | error_type={type(exc).__name__}")
189
+ return JSONResponse(
190
+ status_code=500,
191
+ content=create_error_response(
192
+ 500,
193
+ "服务器内部错误,请稍后重试",
194
+ "internal_error",
195
+ error_id=error_id
196
+ ),
197
+ headers={"X-Ranking-Request-Id": error_id}
198
+ )
199
+
200
+
201
+ # ==========================================
202
+ # ❤️ 健康检查接口 (增强版)
203
+ # ==========================================
204
+ @app.get("/")
205
+ def health_check():
206
+ return {"status": "ok", "message": "ComfyUI Ranking API is running perfectly!"}
207
+
208
+ @app.get("/health")
209
+ def detailed_health_check():
210
+ """详细健康检查,含依赖状态"""
211
+ health_status = {
212
+ "status": "ok",
213
+ "components": {}
214
+ }
215
+
216
+ # 检查 SQL 数据库
217
+ try:
218
+ from database_sql import check_db_connection
219
+ if check_db_connection():
220
+ health_status["components"]["sql_database"] = "ok"
221
+ else:
222
+ health_status["components"]["sql_database"] = "error: connection failed"
223
+ health_status["status"] = "degraded"
224
+ except Exception as e:
225
+ logger.warning(f"健康检查 SQL 数据库异常: error_type={type(e).__name__}")
226
+ health_status["components"]["sql_database"] = "error: connection failed"
227
+ health_status["status"] = "degraded"
228
+
229
+ # 检查 JSON 文件访问
230
+ try:
231
+ import 数据库连接 as json_db
232
+ json_db.load_data("users.json", default_data={})
233
+ health_status["components"]["json_storage"] = "ok"
234
+ except Exception as e:
235
+ logger.warning(f"健康检查 JSON 存储异常: error_type={type(e).__name__}")
236
+ health_status["components"]["json_storage"] = "error: storage unavailable"
237
+ health_status["status"] = "degraded"
238
+
239
+ return health_status
240
+
241
+ def _warmup_dependencies():
242
+ if startup_warmup_stopped(app.state):
243
+ return
244
+ # ========== 预热检查:SQL 数据库 ==========
245
+ try:
246
+ init_sql_db()
247
+ logger.info("✅ SQL 数据库初始化完成")
248
+ except Exception as e:
249
+ logger.error(f"❌ SQL 数据库初始化失败: error_type={type(e).__name__}")
250
+ # 不抛出异常,允许降级运行
251
+
252
+ if startup_warmup_stopped(app.state):
253
+ return
254
+ # ========== 加载第三方插件注册表(依赖 SQL 数据库) ==========
255
+ try:
256
+ from router_open_api import load_plugins_from_db
257
+ load_plugins_from_db()
258
+ logger.info("✅ 第三方插件注册表已加载")
259
+ except Exception as e:
260
+ logger.warning(f"⚠️ 第三方插件注册表加载失败: error_type={type(e).__name__}")
261
+
262
+ if startup_warmup_stopped(app.state):
263
+ return
264
+ # ========== 预热检查:JSON 数据库 ==========
265
+ try:
266
+ db.load_data("users.json", default_data={})
267
+ if startup_warmup_stopped(app.state):
268
+ return
269
+ db.load_data("items.json", default_data=[])
270
+ logger.info("✅ JSON 数据库访问正常")
271
+ except Exception as e:
272
+ logger.warning(f"⚠️ JSON 数据库访问异常: error_type={type(e).__name__}")
273
+
274
+
275
+ # ==========================================
276
+ # 🚀 应用启动事件
277
+ # ==========================================
278
+ # 作用:初始化数据库 + 启动定时版本检测后台任务 + 预热检查
279
+ @app.on_event("startup")
280
+ async def on_startup():
281
+ logger.info("🚀 ComfyUI-Ranking API 启动中...")
282
+ if getattr(app.state, "_ranking_starting", False) or getattr(app.state, "_ranking_closers", 0):
283
+ raise RuntimeError("application lifecycle is already in progress")
284
+ if getattr(app.state, "_ranking_started", False):
285
+ return
286
+ app.state._ranking_starting = True
287
+ try:
288
+ prepare_startup_warmup(app.state)
289
+ prepare_application_shutdown(app.state)
290
+ start_profile_io()
291
+ try:
292
+ start_verification_io()
293
+ start_auth_io()
294
+ start_identity_io()
295
+ start_shared_work()
296
+ start_open_sql()
297
+ start_resource_work()
298
+ start_message_io()
299
+ start_comment_reads()
300
+ start_admin_scripts()
301
+
302
+ await run_startup_warmup(app.state, _warmup_dependencies)
303
+
304
+ # SQL资金已冻结但JSON发布失败的任务,重启后继续投影;持有句柄供停机收尾。
305
+ from task_publication import publication_worker
306
+ from database_sql import engine
307
+ from db_utils import sort_cache
308
+ start_periodic_task(app.state, "task_publication",
309
+ lambda stop: publication_worker(engine, db, sort_cache.invalidate, stop))
310
+
311
+ from resource_publication import publication_worker as resource_publication_worker
312
+ start_periodic_task(app.state, "resource_publication",
313
+ lambda stop: resource_publication_worker(engine, db, sort_cache.invalidate, stop))
314
+
315
+ from resource_deletion import resource_deletion_worker
316
+ start_periodic_task(app.state, "resource_deletion",
317
+ lambda stop: resource_deletion_worker(db, stop, sort_cache.invalidate))
318
+
319
+ # ========== 启动后台任务 ==========
320
+ start_periodic_task(app.state, "daily_version", daily_version_check_task)
321
+ logger.info("✅ 定时版本检测任务已挂载")
322
+
323
+ start_periodic_task(app.state, "daily_views", reset_daily_views_task)
324
+ logger.info("✅ daily_views 每日重置任务已挂载")
325
+
326
+ # ========== 启动 HF 批量同步定时器 ==========
327
+ db.start_batch_sync()
328
+ logger.info("✅ HF 同步调度初始化已完成")
329
+
330
+ app.state._ranking_started = True
331
+ logger.info("🎉 ComfyUI-Ranking API 启动完成!")
332
+ except BaseException:
333
+ # 启动可能只登记了部分句柄;正常关闭按已登记对象收尾。
334
+ if not startup_shutdown_requested(app.state):
335
+ try:
336
+ await on_shutdown()
337
+ except BaseException as error:
338
+ logger.error("启动收尾失败: error_type=%s", type(error).__name__)
339
+ raise
340
+ finally:
341
+ app.state._ranking_starting = False
342
+
343
+
344
+ @app.on_event("shutdown")
345
+ async def on_shutdown():
346
+ return await run_application_shutdown(app.state, _shutdown_resources)
347
+
348
+
349
+ async def _shutdown_resources():
350
+ """逐项收尾,错误在其它组件得到关闭机会后传播。"""
351
+ app.state._ranking_started = False
352
+ app.state._ranking_shutdown_incomplete = True
353
+ failures = ShutdownFailures(logger)
354
+ failures.call('log_start', logger.info, "🛑 ComfyUI-Ranking API 正在关闭...")
355
+ failures.call('stop_warmup', stop_startup_warmup, app.state)
356
+ for stage, stop in (
357
+ ('stop_messages', stop_message_io), ('stop_resources', stop_resource_work), ('stop_open_sql', stop_open_sql), ('stop_shared', stop_shared_work),
358
+ ('stop_identity', stop_identity_io), ('stop_auth', stop_auth_io),
359
+ ('stop_verification', stop_verification_io), ('stop_scripts', stop_admin_scripts),
360
+ ('stop_comments', stop_comment_reads),
361
+ ):
362
+ failures.call(stage, stop)
363
+ workers = []
364
+ for name in ('daily_version', 'daily_views', 'task_publication', 'resource_publication', 'resource_deletion'):
365
+ worker = failures.call('capture_' + name, getattr, app.state, name + '_worker', None)
366
+ if worker is not None:
367
+ workers.append(worker)
368
+ failures.call('stop_' + name, request_periodic_stop, app.state, (name,))
369
+ for stage, close, args in (
370
+ ('wait_warmup', wait_startup_warmup, (app.state,)),
371
+ ('close_messages', close_message_io, ()), ('close_resources', close_resource_work, ()), ('close_open_sql', close_open_sql, ()), ('close_shared', close_shared_work, ()),
372
+ ('close_identity', close_identity_io, ()), ('close_auth', close_auth_io, ()),
373
+ ('close_verification', close_verification_io, ()), ('close_profile', close_profile_io, ()),
374
+ ('close_scripts', close_admin_scripts, ()), ('close_comments', close_comment_reads, ()),
375
+ ('wait_periodic', wait_periodic_tasks, (workers,)),
376
+ ):
377
+ await failures.wait(stage, close, *args)
378
+ app.state._ranking_shutdown_incomplete = failures.incomplete
379
+ failures.raise_if_failed(cancel_requested=shutdown_cancel_requested(app.state))
380
+ await run_owned_thread(db.flush_sync)
381
+ logger.info("✅ HF 关闭前同步尝试已结束")
382
+ logger.info("✅ 关闭完成")
383
+
384
+ # 🛡️ CORS 配置:默认通配符(插件前端运行于用户本地 ComfyUI 页面,origin 为
385
+ # 127.0.0.1/localhost/局域网 IP/自定义端口等无法穷举;本 API 通过 Authorization 头
386
+ # 传递 Token 且 allow_credentials=False 不依赖 Cookie,通配风险可控)。
387
+ # 如需收紧可在环境变量 ALLOWED_ORIGINS 中逗号分隔指定域名。
388
+ ALLOWED_ORIGINS = os.environ.get("ALLOWED_ORIGINS", "*").split(",")
389
+ logger.info(
390
+ f"CORS 配置已加载: origin_count={len(ALLOWED_ORIGINS)} | "
391
+ f"wildcard={len(ALLOWED_ORIGINS) == 1 and ALLOWED_ORIGINS[0] == '*'}"
392
+ )
393
+
394
+ app.add_middleware(
395
+ CORSMiddleware,
396
+ allow_origins=ALLOWED_ORIGINS,
397
+ allow_credentials=False,
398
+ allow_methods=["*"],
399
+ allow_headers=["*"],
400
+ expose_headers=["ETag", "X-Ranking-Error-Scope", "X-Ranking-Request-Id"],
401
+ )
402
+ app.add_middleware(RequestTraceMiddleware)
403
+
404
+ # ==========================================
405
+ # 路由挂载
406
+ # ==========================================
407
+ # 用户模块 (3个子模块)
408
+ app.include_router(users_auth_router) # 🔐 登录/注册/密码重置
409
+ app.include_router(users_profile_router) # 👤 用户资料
410
+ app.include_router(users_social_router) # 🤝 关注/隐私
411
+
412
+ # 其他业务模块
413
+ app.include_router(items_router) # 📦 内容管理
414
+ app.include_router(comments_router) # 💬 评论系统
415
+ app.include_router(messages_router) # ✉️ 私信系统
416
+ app.include_router(wallet_router) # 💰 钱包/提现
417
+ app.include_router(proxy_router) # 🔗 代理下载
418
+ app.include_router(posts_router) # 💬 讨论区
419
+ app.include_router(prompts_router) # ✨ 提示词市场
420
+ app.include_router(tasks_router) # 📝 任务榜
421
+ app.include_router(open_api_router) # 🌐 通用开放API(供第三方插件调用)
422
+ app.include_router(admin_plugins_router) # 🔒 管理后台 - 第三方插件管理API
423
+
424
+ # ==========================================
425
+ # 🟢 私有图床代理中心 (Image Proxy)
426
+ # 解决 Private 仓库下,本地客户端报 401 Unauthorized 的终极方案
427
+ # ==========================================
428
+ from media_proxy_service import read_public_media
429
+
430
+ @app.get("/api/image_proxy")
431
+ def proxy_hf_image(url: str = None, path: str = None):
432
+ return read_public_media(url, path, store=db, download=hf_hub_download,
433
+ file_response=FileResponse, error_response=create_error_response)
434
+
435
+ # ==========================================
436
+ # 上传接口 (将返回的 URL 替换为 Proxy 代理链接)
437
+ # 🔒 P0安全优化:集成图片内容审核
438
+ # ==========================================
439
+ from image_moderation import moderate_image_sync, ModerationResult
440
+
441
+ # ==========================================
442
+ # 🎵 音频安全净化:重编码压缩,剥离元数据与隐藏附加信息
443
+ # ==========================================
444
+ from upload_service import process_upload
445
+
446
+ @app.post("/api/upload")
447
+ @shared_limit(30)
448
+ def upload_file(request: Request, file: UploadFile = File(...), file_type: str = Form(...), current_user: str = Depends(require_auth)):
449
+ return process_upload(file, file_type, current_user, store=db, storage_factory=HfApi,
450
+ moderate=moderate_image_sync, limiter=_UPLOAD_SEMAPHORE,
451
+ error_response=create_error_response)
452
+
453
+
454
+
455
+ from github_source import select_github_token
456
+
457
+
458
+ class ValidateResourceRequest(BaseModel):
459
+ url: str
460
+ item_id: str
461
+ account: str
462
+
463
+ from resource_probe_service import probe_resource
464
+ from github_probe_transport import open_github_probe
465
+ from skill_preflight import verify_skill_repository
466
+ from router_proxy import _send_with_validated_redirects
467
+ from safe_http_transport import ValidatedAsyncHTTPTransport
468
+ import httpx
469
+
470
+
471
+ def _probe_skill_repository(item, token):
472
+ return verify_skill_repository(item, token, send=_send_with_validated_redirects,
473
+ client_factory=httpx.AsyncClient, transport_factory=ValidatedAsyncHTTPTransport)
474
+
475
+ @app.post("/api/validate_resource")
476
+ @shared_limit(30)
477
+ def validate_resource(req_data: ValidateResourceRequest, request: Request, sql_db: Session = Depends(get_db), current_user: str = Depends(require_auth)):
478
+ return probe_resource(req_data.item_id, store=db, download=hf_hub_download,
479
+ open_url=open_github_probe, token_selector=select_github_token,
480
+ error_response=create_error_response, skill_probe=_probe_skill_repository)
481
+
482
+ # ==========================================
483
+ # 🔒 管理员:系统配置 API
484
+ # ==========================================
485
+ from 安全认证 import require_auth, ADMIN_ACCOUNTS
486
+
487
+ def _is_admin(account: str) -> bool:
488
+ """检查账号是否为管理员"""
489
+ from 安全认证 import is_admin as _check_admin
490
+ return _check_admin(account)
491
+
492
+ # 配置存储文件
493
+ SYSTEM_CONFIG_FILE = "/tmp/system_config.json" # 保留作为降级方案
494
+
495
+ def _load_system_config_from_db(db: Session, *, strict: bool = False) -> dict:
496
+ """从数据库加载所有系统配置"""
497
+ try:
498
+ configs = db.query(SystemConfig).all()
499
+ result = {}
500
+ for cfg in configs:
501
+ try:
502
+ result[cfg.config_key] = json.loads(cfg.config_value)
503
+ except (json.JSONDecodeError, TypeError):
504
+ result[cfg.config_key] = cfg.config_value
505
+ return result
506
+ except Exception as e:
507
+ logger.warning(f"从数据库加载系统配置失败,降级到文件: error_type={type(e).__name__}")
508
+ if strict:
509
+ return _load_system_config_from_file(strict=True)
510
+ return _load_system_config_from_file()
511
+
512
+ def _load_system_config_from_file(*, strict: bool = False) -> dict:
513
+ """降级方案;严格读取不把故障或缺失文件冒充空配置。"""
514
+ if strict:
515
+ with open(SYSTEM_CONFIG_FILE, "r", encoding="utf-8") as stream:
516
+ config = json.load(stream)
517
+ if not isinstance(config, dict):
518
+ raise ValueError("invalid system configuration root")
519
+ return config
520
+ try:
521
+ if os.path.exists(SYSTEM_CONFIG_FILE):
522
+ with open(SYSTEM_CONFIG_FILE, "r") as f:
523
+ return json.load(f)
524
+ except Exception as e:
525
+ logger.debug(f"加载系统配置文件失败: error_type={type(e).__name__}")
526
+ return {}
527
+
528
+ def _save_system_config_to_db(db: Session, config_key: str, value, updated_by: str = None, *, strict: bool = False):
529
+ """保存单个配置项到数据库"""
530
+ try:
531
+ cfg = db.query(SystemConfig).filter(SystemConfig.config_key == config_key).first()
532
+ if not cfg:
533
+ cfg = SystemConfig(config_key=config_key)
534
+ db.add(cfg)
535
+ cfg.config_value = json.dumps(value, ensure_ascii=False, allow_nan=not strict)
536
+ cfg.updated_by = updated_by
537
+ cfg.updated_at = datetime.datetime.utcnow()
538
+ db.commit()
539
+ except Exception as e:
540
+ if strict:
541
+ # commit异常可能发生在真实提交之后;回滚不能证明尚未保存。
542
+ try:
543
+ db.rollback()
544
+ except Exception:
545
+ logger.warning("SYSTEM_CONFIG_ROLLBACK_UNCONFIRMED")
546
+ raise
547
+ db.rollback()
548
+ logger.warning(f"保存系统配置到数据库失败,降级到文件: error_type={type(e).__name__}")
549
+ # 降级:保存到文件
550
+ config = _load_system_config_from_file()
551
+ config[config_key] = value
552
+ try:
553
+ with open(SYSTEM_CONFIG_FILE, "w") as f:
554
+ json.dump(config, f, ensure_ascii=False)
555
+ except Exception as fe:
556
+ logger.warning(f"降级保存系统配置文件也失败: error_type={type(fe).__name__}")
557
+
558
+ # 兼容旧调用的包装函数
559
+ def _load_system_config(db: Session = None, *, strict: bool = False) -> dict:
560
+ """加载系统配置(优先数据库,降级文件);严格模式保留故障。"""
561
+ if strict:
562
+ if db is not None:
563
+ return _load_system_config_from_db(db, strict=True)
564
+ temp_db = SessionLocal()
565
+ try:
566
+ return _load_system_config_from_db(temp_db, strict=True)
567
+ finally:
568
+ temp_db.close()
569
+ if db:
570
+ return _load_system_config_from_db(db)
571
+ # 无 db session 时(如公开端点),创建临时 session
572
+ temp_db = SessionLocal()
573
+ try:
574
+ return _load_system_config_from_db(temp_db)
575
+ except Exception:
576
+ return _load_system_config_from_file()
577
+ finally:
578
+ temp_db.close()
579
+
580
+ def _save_system_config(config: dict, db: Session = None, updated_by: str = None):
581
+ """保存系统配置(优先数据库,降级文件)"""
582
+ if db:
583
+ for key, value in config.items():
584
+ _save_system_config_to_db(db, key, value, updated_by)
585
+ return
586
+ # 无 db session 时,创建临时 session
587
+ temp_db = SessionLocal()
588
+ try:
589
+ for key, value in config.items():
590
+ _save_system_config_to_db(temp_db, key, value, updated_by)
591
+ except Exception:
592
+ pass
593
+ finally:
594
+ temp_db.close()
595
+
596
+
597
+ @app.get("/api/admin/config/{config_key}")
598
+ async def get_system_config(config_key: str, current_user: str = Depends(require_auth), db: Session = Depends(get_db)):
599
+ """ 🔒 获取系统配置(仅管理员) """
600
+ # 🔒 权限验证
601
+ if not _is_admin(current_user):
602
+ raise HTTPException(status_code=403, detail="无权访问系统配置,仅管理员可操作")
603
+
604
+ def reject_constant(_value):
605
+ raise ValueError("invalid configuration constant")
606
+ try:
607
+ # 管理回读用于确认SQL保存结果,不能用另一个来源的旧值代替。
608
+ storage_key = "image_moderation_enabled" if config_key == "image_moderation" else config_key
609
+ row = db.query(SystemConfig).filter(SystemConfig.config_key == storage_key).first()
610
+ value = None if row is None else json.loads(row.config_value, parse_constant=reject_constant)
611
+ if config_key == "image_moderation":
612
+ if row is None:
613
+ from image_moderation import _is_moderation_enabled
614
+ value = _is_moderation_enabled()
615
+ if type(value) is not bool:
616
+ raise ValueError("invalid moderation configuration")
617
+ value = {"enabled": value}
618
+ except Exception:
619
+ raise HTTPException(status_code=503, detail="config_read_unavailable") from None
620
+ return {"status": "success", "data": value}
621
+
622
+
623
+ # 🏷️ 版本配置默认值
624
+ DEFAULT_VERSION_CONFIG = {
625
+ "stage": "beta",
626
+ "major": 2,
627
+ "minor": 0,
628
+ "patch": 0
629
+ }
630
+
631
+
632
+ @app.get("/api/public/project-version")
633
+ async def get_public_project_version(db: Session = Depends(get_db)):
634
+ """获取项目版本信息(公开接口,无需认证)"""
635
+ config = _load_system_config(db)
636
+ version_data = config.get("project_version", DEFAULT_VERSION_CONFIG)
637
+ return {"status": "success", "data": version_data}
638
+
639
+
640
+ @app.get("/api/public/banner_config")
641
+ async def get_public_banner_config(db: Session = Depends(get_db)):
642
+ """公开接口:获取广告横幅配置(所有已登录/未登录用户均可访问)"""
643
+ try:
644
+ config = _load_system_config(db, strict=True)
645
+ if not isinstance(config, dict):
646
+ raise ValueError("invalid system configuration root")
647
+ banner = config.get("banner_config")
648
+ if banner is None:
649
+ return {"status": "success", "data": None}
650
+ if not isinstance(banner, dict) or type(banner.get("enabled")) is not bool:
651
+ raise ValueError("invalid banner configuration")
652
+ if not banner["enabled"]:
653
+ return {"status": "success", "data": None}
654
+ # 只投影展示字段,异常配置不能触发浏览器清除旧横幅。
655
+ result = {"enabled": True}
656
+ for field in ("bannerImage", "title", "description", "detailImage", "detailContent", "link"):
657
+ value = banner.get(field, "")
658
+ if not isinstance(value, str):
659
+ raise ValueError("invalid banner display field")
660
+ result[field] = value
661
+ if not result["bannerImage"].strip():
662
+ raise ValueError("missing banner image")
663
+ return {"status": "success", "data": result}
664
+ except Exception:
665
+ raise HTTPException(status_code=503, detail="banner_config_unavailable") from None
666
+
667
+
668
+ @app.put("/api/admin/config/{config_key}")
669
+ async def set_system_config(config_key: str, value: dict, current_user: str = Depends(require_auth), db: Session = Depends(get_db)):
670
+ """ 🔒 设置系统配置(仅管理员) """
671
+ # 🔒 权限验证
672
+ if not _is_admin(current_user):
673
+ raise HTTPException(status_code=403, detail="无权修改系统配置,仅管理员可操作")
674
+ storage_key = config_key
675
+ storage_value = value
676
+ if config_key == "image_moderation":
677
+ if set(value) != {"enabled"} or type(value["enabled"]) is not bool:
678
+ raise HTTPException(status_code=400, detail="invalid_moderation_configuration")
679
+ storage_key = "image_moderation_enabled"
680
+ storage_value = value["enabled"]
681
+ try:
682
+ _save_system_config_to_db(db, storage_key, storage_value, updated_by=current_user, strict=True)
683
+ except Exception:
684
+ raise HTTPException(status_code=503, detail="config_save_unconfirmed") from None
685
+ return {"status": "success", "message": "配置已更新"}
686
+
687
+
688
+ # ==========================================
689
+ # 🖥️ 管理界面静态文件挂载
690
+ # ⚠️ 必须放在所有 API 路由注册之后,避免拦截 API
691
+ # 访问 /admin/ 会自动返回 管理界面/index.html
692
+ # ==========================================
693
+ from fastapi.staticfiles import StaticFiles
694
+ app.mount("/admin", StaticFiles(directory="管理界面", html=True), name="admin")
695
+
696
+ # ==========================================
697
+ # 📱 移动端网页静态挂载(复用前端的 Web 版)
698
+ # 部署约定:同步 HF Space 时,除本目录内容外,
699
+ # 须将仓库根目录的 前端页面/ 整个目录一并复制到 Space 仓库根目录
700
+ # /m/assets 提供共享前端模块;/m 返回 移动端网页/index.html
701
+ # ⚠️ 必须放在所有 API 路由之后;先挂载更具体的 /m/assets,再挂载 /m
702
+ # 容错:目录缺失时仅跳过挂载并警告,不阻断 API 服务启动(移动端页面将 404)
703
+ # ==========================================
704
+ if os.path.isdir("前端页面"):
705
+ app.mount("/m/assets", StaticFiles(directory="前端页面"), name="web_assets")
706
+ app.mount("/m", StaticFiles(directory="移动端网页", html=True), name="mobile_web")
707
+ else:
708
+ logger.warning("⚠️ 未找到 前端页面/ 目录,移动端网页 /m/ 未挂载(部署时须将仓库根目录的 前端页面/ 复制到 Space 仓库根目录),API 服务不受影响")
auth_io.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """账号同步工作的有界准入与关闭等待。"""
2
+ from fastapi import HTTPException
3
+ from owned_thread import OwnedThreadGroup
4
+
5
+ MAX_AUTH_IO = 8
6
+ MAX_IDENTITY_IO = 32
7
+
8
+
9
+ class AuthIO(OwnedThreadGroup):
10
+ def __init__(self, capacity=MAX_AUTH_IO):
11
+ super().__init__(capacity, lambda: HTTPException(status_code=503, detail='用户数据暂不可用'))
12
+
13
+
14
+ _auth_io = AuthIO()
15
+ run_auth_io = _auth_io.run
16
+ start_auth_io = _auth_io.start
17
+ stop_auth_io = _auth_io.stop
18
+ close_auth_io = _auth_io.close
19
+
20
+ _identity_io = AuthIO(MAX_IDENTITY_IO)
21
+ run_identity_io = _identity_io.run
22
+ start_identity_io = _identity_io.start
23
+ stop_identity_io = _identity_io.stop
24
+ close_identity_io = _identity_io.close
github_versions.py CHANGED
@@ -1,89 +1,89 @@
1
- """每资源版本检测票据和局部发布;拒绝较早检测的迟到结果。"""
2
- import asyncio
3
- import uuid
4
- from github_archive import commit_sha, source_signature
5
- from github_source import select_github_token
6
- from proxy_access import read_proxy_item
7
-
8
-
9
- def _entry(versions, item_id):
10
- if not isinstance(versions, dict):
11
- raise ValueError('invalid version source')
12
- entry = versions.get(item_id)
13
- if entry is None:
14
- return {}
15
- if isinstance(entry, str):
16
- return {'hash': entry}
17
- if not isinstance(entry, dict):
18
- raise ValueError('invalid version record')
19
- return dict(entry)
20
-
21
-
22
- def _without_cache(entry):
23
- return {key: value for key, value in entry.items()
24
- if key not in ('cached_at', 'zip_size') and not key.startswith('archive_')}
25
-
26
-
27
- def begin_probe(store, item_id):
28
- item = read_proxy_item(store, item_id)
29
- token = select_github_token(item)
30
- source = source_signature(item, token)
31
- ticket = uuid.uuid4().hex
32
- def update(versions):
33
- entry = _entry(versions, item_id)
34
- entry['_probe_ticket'] = ticket
35
- entry['_probe_source'] = source
36
- versions[item_id] = entry
37
- store.atomic_update('versions.json', update, default_data={})
38
- return dict(item=item, token=token, source=source, ticket=ticket)
39
-
40
-
41
- def finish_probe(store, operation, sha):
42
- sha = commit_sha(sha)
43
- item_id = operation['item']['id']
44
- current = read_proxy_item(store, item_id)
45
- if source_signature(current, select_github_token(current)) != operation['source']:
46
- return None
47
- def update(versions):
48
- entry = _entry(versions, item_id)
49
- if entry.get('_probe_ticket') != operation['ticket'] or entry.get('_probe_source') != operation['source']:
50
- return None
51
- changed = entry.get('hash') != sha[:7]
52
- if (changed or entry.get('version_source') not in (None, operation['source'])
53
- or entry.get('version_commit') not in (None, sha)
54
- or entry.get('archive_commit') not in (None, sha)):
55
- entry = _without_cache(entry)
56
- entry.update(hash=sha[:7], version_commit=sha, version_source=operation['source'])
57
- entry.pop('_probe_ticket', None); entry.pop('_probe_source', None)
58
- versions[item_id] = entry
59
- return dict(item=current, token=operation['token'], commit=sha, changed=changed)
60
- return store.atomic_update('versions.json', update, default_data={})
61
-
62
-
63
- def invalidate_source(store, item_id):
64
- """旧编辑回调也读取最新资源;已属于最新来源的确认记录保持。"""
65
- current = read_proxy_item(store, item_id)
66
- try:
67
- source = source_signature(current, select_github_token(current))
68
- except ValueError:
69
- source = None
70
- def update(versions):
71
- entry = _entry(versions, item_id)
72
- if source is not None and entry.get('version_source') == source:
73
- return False
74
- entry = _without_cache(entry)
75
- for key in ('version_commit', '_probe_ticket', '_probe_source'):
76
- entry.pop(key, None)
77
- entry.update(hash='', version_source=source)
78
- versions[item_id] = entry
79
- return True
80
- return store.atomic_update('versions.json', update, default_data={})
81
-
82
-
83
- async def refresh_resource_version(store, item_id):
84
- from 云端_定时版本检测引擎 import fetch_github_commit
85
- operation = await asyncio.to_thread(begin_probe, store, item_id)
86
- sha = await fetch_github_commit(operation['item']['link'], operation['token'])
87
- if not sha:
88
- return None
89
- return await asyncio.to_thread(finish_probe, store, operation, sha)
 
1
+ """每资源版本检测票据和局部发布;拒绝较早检测的迟到结果。"""
2
+ from owned_thread import run_owned_thread
3
+ import uuid
4
+ from github_archive import commit_sha, source_signature
5
+ from github_source import select_github_token
6
+ from proxy_access import read_proxy_item
7
+
8
+
9
+ def _entry(versions, item_id):
10
+ if not isinstance(versions, dict):
11
+ raise ValueError('invalid version source')
12
+ entry = versions.get(item_id)
13
+ if entry is None:
14
+ return {}
15
+ if isinstance(entry, str):
16
+ return {'hash': entry}
17
+ if not isinstance(entry, dict):
18
+ raise ValueError('invalid version record')
19
+ return dict(entry)
20
+
21
+
22
+ def _without_cache(entry):
23
+ return {key: value for key, value in entry.items()
24
+ if key not in ('cached_at', 'zip_size') and not key.startswith('archive_')}
25
+
26
+
27
+ def begin_probe(store, item_id):
28
+ item = read_proxy_item(store, item_id)
29
+ token = select_github_token(item)
30
+ source = source_signature(item, token)
31
+ ticket = uuid.uuid4().hex
32
+ def update(versions):
33
+ entry = _entry(versions, item_id)
34
+ entry['_probe_ticket'] = ticket
35
+ entry['_probe_source'] = source
36
+ versions[item_id] = entry
37
+ store.atomic_update('versions.json', update, default_data={})
38
+ return dict(item=item, token=token, source=source, ticket=ticket)
39
+
40
+
41
+ def finish_probe(store, operation, sha):
42
+ sha = commit_sha(sha)
43
+ item_id = operation['item']['id']
44
+ current = read_proxy_item(store, item_id)
45
+ if source_signature(current, select_github_token(current)) != operation['source']:
46
+ return None
47
+ def update(versions):
48
+ entry = _entry(versions, item_id)
49
+ if entry.get('_probe_ticket') != operation['ticket'] or entry.get('_probe_source') != operation['source']:
50
+ return None
51
+ changed = entry.get('hash') != sha[:7]
52
+ if (changed or entry.get('version_source') not in (None, operation['source'])
53
+ or entry.get('version_commit') not in (None, sha)
54
+ or entry.get('archive_commit') not in (None, sha)):
55
+ entry = _without_cache(entry)
56
+ entry.update(hash=sha[:7], version_commit=sha, version_source=operation['source'])
57
+ entry.pop('_probe_ticket', None); entry.pop('_probe_source', None)
58
+ versions[item_id] = entry
59
+ return dict(item=current, token=operation['token'], commit=sha, changed=changed)
60
+ return store.atomic_update('versions.json', update, default_data={})
61
+
62
+
63
+ def invalidate_source(store, item_id):
64
+ """旧编辑回调也读取最新资源;已属于最新来源的确认记录保持。"""
65
+ current = read_proxy_item(store, item_id)
66
+ try:
67
+ source = source_signature(current, select_github_token(current))
68
+ except ValueError:
69
+ source = None
70
+ def update(versions):
71
+ entry = _entry(versions, item_id)
72
+ if source is not None and entry.get('version_source') == source:
73
+ return False
74
+ entry = _without_cache(entry)
75
+ for key in ('version_commit', '_probe_ticket', '_probe_source'):
76
+ entry.pop(key, None)
77
+ entry.update(hash='', version_source=source)
78
+ versions[item_id] = entry
79
+ return True
80
+ return store.atomic_update('versions.json', update, default_data={})
81
+
82
+
83
+ async def refresh_resource_version(store, item_id):
84
+ from 云端_定时版本检测引擎 import fetch_github_commit
85
+ operation = await run_owned_thread(begin_probe, store, item_id)
86
+ sha = await fetch_github_commit(operation['item']['link'], operation['token'])
87
+ if not sha:
88
+ return None
89
+ return await run_owned_thread(finish_probe, store, operation, sha)
models_sql.py CHANGED
@@ -5,13 +5,38 @@
5
  # 🚀 P1性能优化:添加复合索引加速查询
6
  # ==========================================
7
 
8
- from sqlalchemy import Column, Integer, String, DateTime, Boolean, Index, func, Text
9
  from sqlalchemy.orm import declarative_base
10
  import datetime
11
 
12
  Base = declarative_base()
13
 
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  class ResourcePublication(Base):
16
  """同次资源发布的持久原始请求和恢复内容;不参与资金变更。"""
17
  __tablename__ = "resource_publications"
 
5
  # 🚀 P1性能优化:添加复合索引加速查询
6
  # ==========================================
7
 
8
+ from sqlalchemy import Column, Integer, String, DateTime, Boolean, Index, func, Text, DDL, event
9
  from sqlalchemy.orm import declarative_base
10
  import datetime
11
 
12
  Base = declarative_base()
13
 
14
 
15
+ class OpenApiOperation(Base):
16
+ __tablename__ = "open_api_operations"
17
+ request_key = Column(String(64), primary_key=True)
18
+ request_hash = Column(String(64), nullable=False)
19
+ transaction_id = Column(String(100), nullable=False, unique=True)
20
+ response_payload = Column(Text, nullable=False)
21
+ created_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
22
+
23
+
24
+ event.listen(OpenApiOperation.__table__, "after_create",
25
+ DDL("ALTER TABLE %(fullname)s ENABLE ROW LEVEL SECURITY").execute_if(dialect="postgresql"))
26
+ event.listen(OpenApiOperation.__table__, "after_create",
27
+ DDL("REVOKE ALL ON TABLE %(fullname)s FROM PUBLIC").execute_if(dialect="postgresql"))
28
+ event.listen(OpenApiOperation.__table__, "after_create", DDL("""
29
+ DO $$ BEGIN
30
+ IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'anon') THEN
31
+ REVOKE ALL ON TABLE %(fullname)s FROM anon;
32
+ END IF;
33
+ IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'authenticated') THEN
34
+ REVOKE ALL ON TABLE %(fullname)s FROM authenticated;
35
+ END IF;
36
+ END; $$
37
+ """).execute_if(dialect="postgresql"))
38
+
39
+
40
  class ResourcePublication(Base):
41
  """同次资源发布的持久原始请求和恢复内容;不参与资金变更。"""
42
  __tablename__ = "resource_publications"
open_transaction.py CHANGED
@@ -1,9 +1,12 @@
1
  """开放资金接口的实时插件授权与钱包写锁;事务由调用方提交/回滚。"""
2
  import hmac
 
 
 
3
  from decimal import Decimal
4
 
5
  from fastapi import HTTPException
6
- from models_sql import RegisteredPlugin
7
  from plugin_permissions import parse_permissions
8
  from tip_transaction import lock_wallet_accounts
9
 
@@ -43,3 +46,52 @@ def quota_total(value):
43
  if isinstance(value, bool) or not isinstance(value, int) or value < 0:
44
  raise ValueError("invalid quota history")
45
  return value
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """开放资金接口的实时插件授权与钱包写锁;事务由调用方提交/回滚。"""
2
  import hmac
3
+ import hashlib
4
+ import json
5
+ import re
6
  from decimal import Decimal
7
 
8
  from fastapi import HTTPException
9
+ from models_sql import RegisteredPlugin, OpenApiOperation, Transaction
10
  from plugin_permissions import parse_permissions
11
  from tip_transaction import lock_wallet_accounts
12
 
 
46
  if isinstance(value, bool) or not isinstance(value, int) or value < 0:
47
  raise ValueError("invalid quota history")
48
  return value
49
+
50
+
51
+ def open_operation_identity(request_id, account, plugin_id, kind, payload):
52
+ if request_id is None:
53
+ return None
54
+ if not isinstance(request_id, str) or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{7,127}", request_id) is None:
55
+ raise HTTPException(status_code=422, detail="OPEN_REQUEST_ID_INVALID")
56
+ encode = lambda value: json.dumps(value, ensure_ascii=False, separators=(',', ':'), allow_nan=False).encode('utf-8')
57
+ key = hashlib.sha256(encode([plugin_id, account, kind, request_id])).hexdigest()
58
+ fingerprint = hashlib.sha256(encode(payload)).hexdigest()
59
+ return key, fingerprint
60
+
61
+
62
+ def read_open_operation(db, identity, account, plugin_id, kind, amount, reference_id):
63
+ if identity is None:
64
+ return None
65
+ key, fingerprint = identity
66
+ row = db.get(OpenApiOperation, key)
67
+ if row is None:
68
+ if db.get(Transaction, f'{kind}_{key}') is not None:
69
+ raise HTTPException(status_code=503, detail="OPEN_OPERATION_UNAVAILABLE")
70
+ return None
71
+ if row.request_hash != fingerprint:
72
+ raise HTTPException(status_code=409, detail="OPEN_REQUEST_CONFLICT")
73
+ try:
74
+ response = json.loads(row.response_payload)
75
+ transaction = db.get(Transaction, row.transaction_id)
76
+ if (not isinstance(response, dict) or set(response) != {'success', 'remaining_balance', 'transaction_id'}
77
+ or response['success'] is not True or type(response['remaining_balance']) is not int
78
+ or response['transaction_id'] != row.transaction_id or row.transaction_id != f'{kind}_{key}'
79
+ or transaction is None or transaction.account != account or transaction.tx_type != kind
80
+ or transaction.source_plugin != plugin_id or transaction.related_account != plugin_id
81
+ or type(transaction.amount) is not int or transaction.amount != amount
82
+ or transaction.item_id != reference_id
83
+ or not isinstance(transaction.prev_hash, str)
84
+ or transaction.tx_hash != hashlib.sha256(
85
+ f"{transaction.tx_id}{account}{kind}{amount}{transaction.prev_hash}".encode()).hexdigest()):
86
+ raise ValueError('invalid open operation')
87
+ except Exception:
88
+ raise HTTPException(status_code=503, detail="OPEN_OPERATION_UNAVAILABLE")
89
+ return response
90
+
91
+
92
+ def save_open_operation(db, identity, response):
93
+ if identity is not None:
94
+ key, fingerprint = identity
95
+ db.add(OpenApiOperation(request_key=key, request_hash=fingerprint,
96
+ transaction_id=response['transaction_id'],
97
+ response_payload=json.dumps(response, ensure_ascii=False, separators=(',', ':'), allow_nan=False)))
owned_thread.py CHANGED
@@ -73,3 +73,59 @@ async def run_owned_thread(function, *args, **kwargs):
73
  if not succeeded:
74
  raise result
75
  return result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  if not succeeded:
74
  raise result
75
  return result
76
+
77
+
78
+ class OwnedThreadGroup:
79
+ def __init__(self, capacity, unavailable):
80
+ if type(capacity) is not int or capacity < 1:
81
+ raise ValueError('invalid owned thread capacity')
82
+ self._capacity = threading.BoundedSemaphore(capacity)
83
+ self._unavailable = unavailable
84
+ self._gate = threading.Lock()
85
+ self._accepting = True
86
+ self._pending = 0
87
+ self._closers = 0
88
+ self._drained = threading.Event()
89
+ self._drained.set()
90
+
91
+ def start(self):
92
+ with self._gate:
93
+ if self._pending or self._closers:
94
+ raise RuntimeError('owned work is still pending')
95
+ self._accepting = True
96
+
97
+ def stop(self):
98
+ with self._gate:
99
+ self._accepting = False
100
+
101
+ async def close(self):
102
+ with self._gate:
103
+ self._accepting = False
104
+ self._closers += 1
105
+ try:
106
+ cancelled = False
107
+ while not self._drained.is_set():
108
+ try:
109
+ await asyncio.sleep(.01)
110
+ except asyncio.CancelledError:
111
+ cancelled = True
112
+ if cancelled:
113
+ raise asyncio.CancelledError
114
+ finally:
115
+ with self._gate:
116
+ self._closers -= 1
117
+
118
+ async def run(self, function, *args, **kwargs):
119
+ with self._gate:
120
+ if not self._accepting or not self._capacity.acquire(blocking=False):
121
+ raise self._unavailable()
122
+ self._pending += 1
123
+ self._drained.clear()
124
+ try:
125
+ return await run_owned_thread(function, *args, **kwargs)
126
+ finally:
127
+ with self._gate:
128
+ self._capacity.release()
129
+ self._pending -= 1
130
+ if not self._pending:
131
+ self._drained.set()
rate_limit.py CHANGED
@@ -11,7 +11,8 @@ import time
11
 
12
  from fastapi import Request
13
  from fastapi import HTTPException
14
- from starlette.concurrency import run_in_threadpool
 
15
  from sqlalchemy import case, delete
16
  from sqlalchemy.dialects.mysql import insert as mysql_insert
17
  from sqlalchemy.dialects.postgresql import insert as postgresql_insert
@@ -26,6 +27,14 @@ logger = logging.getLogger("ComfyUI-Ranking.RateLimit")
26
  _cleanup_lock = threading.Lock()
27
  _last_cleanup_at = 0.0
28
 
 
 
 
 
 
 
 
 
29
 
30
  def get_remote_address(request: Request) -> str:
31
  """仅信任 ASGI transport 提供的客户端地址,不直接信任可伪造的代理头。"""
@@ -181,11 +190,11 @@ def shared_limit(limit: int):
181
  request = next((arg for arg in args if isinstance(arg, Request)), None)
182
  if request is None:
183
  raise RuntimeError(f"{scope} requires a FastAPI Request parameter")
184
- identity = await run_in_threadpool(get_rate_limit_key, request)
185
- await run_in_threadpool(_consume_shared_limit, identity, scope, limit)
186
  if is_async:
187
  return await endpoint(*args, **kwargs)
188
- return await run_in_threadpool(endpoint, *args, **kwargs)
189
 
190
  return wrapped
191
 
 
11
 
12
  from fastapi import Request
13
  from fastapi import HTTPException
14
+ from auth_io import run_identity_io
15
+ from owned_thread import OwnedThreadGroup
16
  from sqlalchemy import case, delete
17
  from sqlalchemy.dialects.mysql import insert as mysql_insert
18
  from sqlalchemy.dialects.postgresql import insert as postgresql_insert
 
27
  _cleanup_lock = threading.Lock()
28
  _last_cleanup_at = 0.0
29
 
30
+ MAX_SHARED_WORK = 40
31
+ _shared_work = OwnedThreadGroup(MAX_SHARED_WORK,
32
+ lambda: HTTPException(status_code=503, detail='限流服务暂不可用'))
33
+ run_shared_work = _shared_work.run
34
+ start_shared_work = _shared_work.start
35
+ stop_shared_work = _shared_work.stop
36
+ close_shared_work = _shared_work.close
37
+
38
 
39
  def get_remote_address(request: Request) -> str:
40
  """仅信任 ASGI transport 提供的客户端地址,不直接信任可伪造的代理头。"""
 
190
  request = next((arg for arg in args if isinstance(arg, Request)), None)
191
  if request is None:
192
  raise RuntimeError(f"{scope} requires a FastAPI Request parameter")
193
+ identity = await run_identity_io(get_rate_limit_key, request)
194
+ await run_shared_work(_consume_shared_limit, identity, scope, limit)
195
  if is_async:
196
  return await endpoint(*args, **kwargs)
197
+ return await run_shared_work(endpoint, *args, **kwargs)
198
 
199
  return wrapped
200
 
request_body_guard.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """普通API提交在解析前有界接收,原始字节由请求持有到应用结束。"""
2
+ import asyncio
3
+ import math
4
+ import re
5
+ import threading
6
+ import time
7
+
8
+ from starlette.responses import JSONResponse
9
+
10
+ REQUEST_BODY_MAX_BYTES = 2 * 1024 * 1024
11
+ REQUEST_BODY_CAPACITY = 32
12
+ REQUEST_BODY_IDLE_SECONDS = 10
13
+ REQUEST_BODY_TOTAL_SECONDS = 30
14
+
15
+
16
+ async def _receive_owned(receive, timeout):
17
+ owner = asyncio.current_task()
18
+ initial_cancels = owner.cancelling()
19
+ task = asyncio.create_task(receive())
20
+ cancelled = None
21
+ expired = False
22
+ try:
23
+ done, _ = await asyncio.wait((task,), timeout=timeout)
24
+ if not done:
25
+ expired = True
26
+ task.cancel()
27
+ except asyncio.CancelledError as error:
28
+ cancelled = error
29
+ task.cancel()
30
+ while not task.done():
31
+ try:
32
+ await asyncio.shield(task)
33
+ except asyncio.CancelledError as error:
34
+ if owner.cancelling() > initial_cancels:
35
+ cancelled = error
36
+ except BaseException:
37
+ break
38
+ if not task.cancelled():
39
+ task.exception()
40
+ if cancelled is not None:
41
+ raise cancelled
42
+ if expired:
43
+ raise TimeoutError
44
+ return task.result()
45
+
46
+
47
+ class RequestBodyGuard:
48
+ def __init__(self, app, *, capacity=REQUEST_BODY_CAPACITY, max_bytes=REQUEST_BODY_MAX_BYTES,
49
+ idle_seconds=REQUEST_BODY_IDLE_SECONDS, total_seconds=REQUEST_BODY_TOTAL_SECONDS,
50
+ error_response=None):
51
+ if (type(capacity) is not int or not 1 <= capacity <= 128
52
+ or type(max_bytes) is not int or max_bytes <= 0
53
+ or any(type(value) not in (int, float) or not math.isfinite(value) or value <= 0
54
+ for value in (idle_seconds, total_seconds))):
55
+ raise ValueError('invalid request body budget')
56
+ self.app, self.max_bytes = app, max_bytes
57
+ self.idle_seconds, self.total_seconds = idle_seconds, total_seconds
58
+ self.slots = threading.BoundedSemaphore(capacity)
59
+ self.error_response = error_response
60
+
61
+ async def reject(self, scope, receive, send, status, detail, category):
62
+ content = (self.error_response(status, detail, category) if self.error_response else
63
+ {'success': False, 'status': 'error', 'code': status, 'detail': detail, 'type': category})
64
+ await JSONResponse(content, status_code=status, headers={
65
+ 'Cache-Control': 'no-store', 'Vary': 'Authorization'})(scope, receive, send)
66
+
67
+ async def __call__(self, scope, receive, send):
68
+ path = scope.get('path', '')
69
+ if (scope['type'] != 'http' or scope.get('method') not in {'POST', 'PUT', 'PATCH', 'DELETE'}
70
+ or not path.startswith('/api/') or path.rstrip('/') == '/api/upload'):
71
+ return await self.app(scope, receive, send)
72
+ headers = scope.get('headers', ())
73
+ lengths = [value.strip() for name, value in headers if name.lower() == b'content-length']
74
+ transfers = [value.strip().lower() for name, value in headers if name.lower() == b'transfer-encoding']
75
+ encodings = [value.strip().lower() for name, value in headers if name.lower() == b'content-encoding']
76
+ if (len(lengths) > 1 or lengths and (re.fullmatch(rb'[0-9]{1,20}', lengths[0]) is None or transfers)
77
+ or len(transfers) > 1 or transfers and transfers[0] != b'chunked'):
78
+ return await self.reject(scope, receive, send, 400, 'request_body_invalid', 'bad_request')
79
+ if len(encodings) > 1 or encodings and encodings[0] != b'identity':
80
+ return await self.reject(scope, receive, send, 415, 'request_body_encoding', 'unsupported_media_type')
81
+ declared = int(lengths[0]) if lengths else None
82
+ if declared is not None and declared > self.max_bytes:
83
+ return await self.reject(scope, receive, send, 413, 'request_body_too_large', 'payload_too_large')
84
+ if not self.slots.acquire(blocking=False):
85
+ return await self.reject(scope, receive, send, 429, 'request_body_busy', 'rate_limited')
86
+ try:
87
+ buffer = bytearray()
88
+ deadline = time.monotonic() + self.total_seconds
89
+ while True:
90
+ remaining = deadline - time.monotonic()
91
+ if remaining <= 0:
92
+ return await self.reject(scope, receive, send, 408, 'request_body_timeout', 'request_timeout')
93
+ try:
94
+ message = await _receive_owned(receive, min(remaining, self.idle_seconds))
95
+ except TimeoutError:
96
+ return await self.reject(scope, receive, send, 408, 'request_body_timeout', 'request_timeout')
97
+ except ConnectionError:
98
+ return await self.reject(scope, receive, send, 400, 'request_body_disconnected', 'bad_request')
99
+ if time.monotonic() > deadline:
100
+ return await self.reject(scope, receive, send, 408, 'request_body_timeout', 'request_timeout')
101
+ if message.get('type') == 'http.disconnect':
102
+ return await self.reject(scope, receive, send, 400, 'request_body_disconnected', 'bad_request')
103
+ chunk = message.get('body', b'')
104
+ more = message.get('more_body', False)
105
+ if message.get('type') != 'http.request' or type(chunk) is not bytes or type(more) is not bool:
106
+ return await self.reject(scope, receive, send, 400, 'request_body_invalid', 'bad_request')
107
+ if len(buffer) + len(chunk) > self.max_bytes:
108
+ return await self.reject(scope, receive, send, 413, 'request_body_too_large', 'payload_too_large')
109
+ if declared is not None and len(buffer) + len(chunk) > declared:
110
+ return await self.reject(scope, receive, send, 400, 'request_body_invalid', 'bad_request')
111
+ buffer.extend(chunk)
112
+ if not more:
113
+ break
114
+ if declared is not None and len(buffer) != declared:
115
+ return await self.reject(scope, receive, send, 400, 'request_body_invalid', 'bad_request')
116
+ raw = bytes(buffer)
117
+ buffer.clear()
118
+ delivered = False
119
+ async def buffered_receive():
120
+ nonlocal delivered
121
+ if not delivered:
122
+ delivered = True
123
+ return {'type': 'http.request', 'body': raw, 'more_body': False}
124
+ return await receive()
125
+ return await self.app(scope, buffered_receive, send)
126
+ finally:
127
+ self.slots.release()
resource_work.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """资源写入与可选预缓存的准入和关闭归属。"""
2
+ import asyncio
3
+ import logging
4
+
5
+ from fastapi import HTTPException
6
+ from owned_thread import OwnedThreadGroup
7
+
8
+ MAX_RESOURCE_WRITES = 8
9
+ MAX_RESOURCE_BACKGROUND = 2
10
+ logger = logging.getLogger('ComfyUI-Ranking.ResourceWork')
11
+
12
+
13
+ class ResourceWork:
14
+ def __init__(self, capacity=MAX_RESOURCE_WRITES, background_capacity=MAX_RESOURCE_BACKGROUND):
15
+ if type(background_capacity) is not int or background_capacity < 1:
16
+ raise ValueError('invalid resource background capacity')
17
+ self.writes = OwnedThreadGroup(capacity,
18
+ lambda: HTTPException(status_code=503, detail='资源保存暂时无法核实,请稍后核对'))
19
+ self.background_capacity = background_capacity
20
+ self._tasks = set()
21
+ self._accepting = True
22
+ self._closers = 0
23
+
24
+ def start(self):
25
+ if self._tasks or self._closers:
26
+ raise RuntimeError('resource work is still pending')
27
+ self.writes.start()
28
+ self._accepting = True
29
+
30
+ def stop(self):
31
+ self._accepting = False
32
+ self.writes.stop()
33
+
34
+ async def run(self, function, *args, **kwargs):
35
+ return await self.writes.run(function, *args, **kwargs)
36
+
37
+ def background(self, factory):
38
+ if not self._accepting or len(self._tasks) >= self.background_capacity:
39
+ return False
40
+ coroutine = None
41
+ try:
42
+ coroutine = factory()
43
+ task = asyncio.create_task(coroutine)
44
+ except BaseException:
45
+ if coroutine is not None:
46
+ coroutine.close()
47
+ raise
48
+ self._tasks.add(task)
49
+ task.add_done_callback(self._completed)
50
+ return True
51
+
52
+ def _completed(self, task):
53
+ self._tasks.discard(task)
54
+ if task.cancelled():
55
+ return
56
+ error = task.exception()
57
+ if error is not None:
58
+ try:
59
+ logger.error('RESOURCE_BACKGROUND_FAILED | error_type=%s', type(error).__name__)
60
+ except Exception:
61
+ pass
62
+
63
+ async def close(self):
64
+ self.stop()
65
+ self._closers += 1
66
+ cancelled = False
67
+ try:
68
+ try:
69
+ await self.writes.close()
70
+ except asyncio.CancelledError:
71
+ cancelled = True
72
+ while self._tasks:
73
+ try:
74
+ await asyncio.sleep(.01)
75
+ except asyncio.CancelledError:
76
+ cancelled = True
77
+ if cancelled:
78
+ raise asyncio.CancelledError
79
+ finally:
80
+ self._closers -= 1
81
+
82
+
83
+ _resource_work = ResourceWork()
84
+ run_resource_write = _resource_work.run
85
+ start_resource_background = _resource_work.background
86
+ start_resource_work = _resource_work.start
87
+ stop_resource_work = _resource_work.stop
88
+ close_resource_work = _resource_work.close
router_items.py CHANGED
@@ -1,786 +1,807 @@
1
- # router_items.py
2
- from fastapi import APIRouter, HTTPException, Depends, Request
3
- from rate_limit import shared_limit
4
- import time
5
- import uuid
6
- import datetime
7
- import os
8
- import re
9
- import urllib.request
10
- import urllib.error
11
- import json
12
- import asyncio
13
- import 数据库连接 as db
14
- from models import ItemCreate, ItemUpdate, RatingRequest
15
- from 安全认证 import require_auth, check_ownership, is_admin
16
- from 数据库连接 import invalidate_cache
17
- from db_utils import read_public_source, record_view, sort_cache
18
- from github_source import repository_parts, select_github_token
19
- from github_versions import refresh_resource_version, invalidate_source
20
- from resource_store import publish_resource, published_response, edit_resource, ResourceStoreError
21
- from purchase_transaction import effective_resource_price
22
- from resource_deletion import delete_resource
23
-
24
- router = APIRouter()
25
-
26
- def safe_str(val):
27
- """安全转换为字符串"""
28
- if val is None:
29
- return ""
30
- return str(val).lower()
31
-
32
- def _get_version_str(versions_db: dict, item_id: str) -> str:
33
- """兼容新旧格式获取版本hash字符串"""
34
- val = versions_db.get(item_id, "")
35
- if isinstance(val, dict):
36
- return val.get("hash", "") or ""
37
- return val or ""
38
-
39
- def _apply_effective_price(item: dict) -> None:
40
- """计算当前有效价格;调用者须传响应副本或处于编辑锁内。"""
41
- try:
42
- price = effective_resource_price(item)
43
- pending = item.get('pending_price')
44
- effective = item.get('pending_price_effective_at')
45
- if type(pending) in (int, float) and pending == price and effective:
46
- when = datetime.datetime.fromisoformat(effective)
47
- now = datetime.datetime.now(when.tzinfo) if when.tzinfo else datetime.datetime.now()
48
- if now >= when:
49
- item['pending_price'] = None
50
- item['pending_price_effective_at'] = None
51
- item['price'] = price
52
- except (ValueError, TypeError) as error:
53
- import logging
54
- kinds = {type(None): 'null', bool: 'boolean', int: 'integer', float: 'number',
55
- str: 'string', list: 'array', dict: 'object'}
56
- reason = ('invalid_effective_price' if len(error.args) == 1
57
- and type(error.args[0]) is str and error.args[0] == 'invalid resource price'
58
- else 'invalid_effective_time')
59
- try:
60
- logging.getLogger('ComfyUI-Ranking.Items').warning(
61
- 'RESOURCE_PRICE_INVALID | reason=%s | price_type=%s | pending_price_type=%s | effective_time_type=%s',
62
- reason, kinds.get(type(item.get('price', 0)), 'unknown'),
63
- kinds.get(type(item.get('pending_price')), 'unknown'),
64
- kinds.get(type(item.get('pending_price_effective_at')), 'unknown'))
65
- except Exception:
66
- pass
67
- raise HTTPException(status_code=503, detail="资源价格暂时无法核实") from None
68
-
69
-
70
- def get_last_6_months():
71
- res = []
72
- today = datetime.date.today()
73
- for i in range(5, -1, -1):
74
- m = today.month - i
75
- y = today.year
76
- while m <= 0:
77
- m += 12
78
- y -= 1
79
- res.append(f"{y}-{m:02d}")
80
- return res
81
-
82
- # 数据文件标识,用于排序缓存
83
- data_file = "items.json"
84
-
85
- @router.get("/api/items")
86
- async def get_items(type: str = "tool", sort: str = "time", limit: int = 50): # 优化:默认限制调大至 50,提升前端列表体验
87
- read_epoch = sort_cache.capture_read_epoch()
88
- items_db = read_public_source(db, "items.json", default_data=[])
89
- comments_db = read_public_source(db, "comments.json", default_data={})
90
- versions_db = read_public_source(db, "versions.json", default_data={}) # 读取版本库
91
-
92
- # 如果是推荐榜,匹配所有 recommend 开头的子类型
93
- if type == "recommend":
94
- filtered_items = [item for item in items_db if item.get("type", "").startswith("recommend")]
95
- else:
96
- filtered_items = [item for item in items_db if item.get("type") == type]
97
-
98
- # 🗂️ 使用排序缓存优化排序性能
99
- cache_sort = sort if sort in ("time", "likes", "favorites", "downloads", "tips", "views", "daily_views", "rating") else "time"
100
- cache_key = f"items:{type}:{cache_sort}"
101
-
102
- current_month = datetime.date.today().strftime("%Y-%m") if sort == "tips" else None
103
- def order_key(item):
104
- if sort == "likes":
105
- return item.get("likes", 0)
106
- elif sort == "favorites":
107
- return item.get("favorites", 0)
108
- elif sort == "downloads":
109
- return item.get("uses", 0)
110
- elif sort == "tips": # 🚀 按近期打赏排序
111
- return item.get("tip_history", {}).get(current_month, 0)
112
- elif sort == "views": # 👁️ 按总访问量排序
113
- return item.get("views", 0)
114
- elif sort == "daily_views": # 👁️ 按日访问量排序
115
- return item.get("daily_views", 0)
116
- elif sort == "rating": # ⭐ 按评分排序
117
- return (item.get("rating_avg", 0), item.get("rating_count", 0))
118
- else: # time 或其他默认
119
- return item.get("created_at", 0)
120
-
121
- def sort_fn(data):
122
- data.sort(key=order_key, reverse=True)
123
-
124
- filtered_items = sort_cache.get_sorted(cache_key, filtered_items, sort_fn, read_epoch=read_epoch, fresh=True)
125
-
126
- result_items = []
127
- for item in filtered_items:
128
- item_data = {**item}
129
- _apply_effective_price(item_data)
130
- item_data["commentsData"] = comments_db.get(item_data["id"], [])
131
- item_data["comments"] = len(item_data["commentsData"])
132
- item_data["latest_version"] = _get_version_str(versions_db, item_data["id"])
133
-
134
- # 🔴 【绝对核心防线】:在下发给前端前,强行在内存中抹除敏感信息!
135
- item_data["has_private_token"] = bool(item_data.get("github_token"))
136
- item_data.pop("github_token", None)
137
- item_data.pop("netdisk_password", None) # ☁️ 网盘密码不在列表中显示
138
- item_data.pop("viewed_by", None) # 👁️ 访问者列表不暴露给前端
139
- result_items.append(item_data)
140
-
141
- return {"status": "success", "data": result_items[:limit]}
142
-
143
- def _build_creator_trend_data(account: str, u_items: list, months: list) -> dict:
144
- """
145
- 构建创作者趋势数据
146
- 提取为独立函数供列表接口和详情接口复用
147
- """
148
- trend_tools = {m: 0 for m in months}
149
- trend_skills = {m: 0 for m in months}
150
- trend_apps = {m: 0 for m in months}
151
- trend_recommends = {m: 0 for m in months}
152
-
153
- for i in u_items:
154
- itype = i.get("type", "")
155
- history = i.get("use_history", {})
156
- if itype == "tool" or itype == "recommend_tool":
157
- for m, v in history.items():
158
- if m in trend_tools: trend_tools[m] += v
159
- elif itype == "skill" or itype == "recommend_skill":
160
- for m, v in history.items():
161
- if m in trend_skills: trend_skills[m] += v
162
- elif itype == "app" or itype == "recommend_app":
163
- for m, v in history.items():
164
- if m in trend_apps: trend_apps[m] += v
165
- elif itype.startswith("recommend"):
166
- for m, v in history.items():
167
- if m in trend_recommends: trend_recommends[m] += v
168
-
169
- return {
170
- "months": months,
171
- "tools": [trend_tools[m] for m in months],
172
- "skills": [trend_skills[m] for m in months],
173
- "apps": [trend_apps[m] for m in months],
174
- "recommends": [trend_recommends[m] for m in months]
175
- }
176
-
177
-
178
- @router.get("/api/creators/search")
179
- async def search_creators(keyword: str, sort: str = "downloads", limit: int = 50):
180
- """
181
- 搜索创作者
182
- 根据关键词搜索创作者(name、account、shortDesc 不区分大小写子串匹配)
183
- """
184
- users_db = read_public_source(db, "users.json", default_data={})
185
- items_db = read_public_source(db, "items.json", default_data=[])
186
-
187
- # 🚀 P1性能优化:预构建 author->items 索引
188
- author_items_index = {}
189
- for item in items_db:
190
- author = item.get("author")
191
- if author:
192
- if author not in author_items_index:
193
- author_items_index[author] = []
194
- author_items_index[author].append(item)
195
-
196
- creators = []
197
- months = get_last_6_months()
198
- keyword_lower = keyword.lower()
199
-
200
- for account, u in users_db.items():
201
- # 🚀 P1性能优化:直接从索引获取
202
- u_items = author_items_index.get(account, [])
203
-
204
- # 获取搜索字段并转为小写
205
- name = u.get("name", account)
206
- short_desc = u.get("shortDesc") or u.get("intro") or ""
207
-
208
- # 不区分大小写的子串匹配(同时覆盖 shortDesc 和 intro 两个字段)
209
- search_text = f"{safe_str(name)} {safe_str(account)} {safe_str(short_desc)} {safe_str(u.get('intro'))} {safe_str(u.get('shortDesc'))}"
210
- if keyword_lower not in search_text:
211
- continue
212
-
213
- tools_count = 0
214
- skills_count = 0
215
- apps_count = 0
216
-
217
- for i in u_items:
218
- itype = i.get("type", "")
219
- if itype == "tool":
220
- tools_count += 1
221
- elif itype == "skill":
222
- skills_count += 1
223
- elif itype == "app":
224
- apps_count += 1
225
-
226
- creators.append({
227
- "account": account, "name": name, "avatar": u.get("avatarDataUrl", ""),
228
- "bannerUrl": u.get("bannerUrl"),
229
- "shortDesc": short_desc, "fullDesc": short_desc,
230
- "likes": sum(i.get("likes", 0) for i in u_items), "favorites": sum(i.get("favorites", 0) for i in u_items),
231
- "downloads": sum(i.get("uses", 0) for i in u_items),
232
- "views": sum(i.get("views", 0) for i in u_items),
233
- "daily_views": sum(i.get("daily_views", 0) for i in u_items),
234
- "toolsCount": tools_count, "skillsCount": skills_count, "appsCount": apps_count, "followers": len(u.get("followers", [])), "created_at": u.get("created_at", 0),
235
- "recent_tips": u.get("tip_history", {}).get(datetime.date.today().strftime("%Y-%m"), 0),
236
- })
237
-
238
- # 排序逻辑与 /api/creators 保持一致
239
- if sort == "likes": creators.sort(key=lambda x: x.get("likes", 0), reverse=True)
240
- elif sort == "favorites": creators.sort(key=lambda x: x.get("favorites", 0), reverse=True)
241
- elif sort == "downloads": creators.sort(key=lambda x: x.get("downloads", 0), reverse=True)
242
- elif sort == "tips": creators.sort(key=lambda x: x.get("recent_tips", 0), reverse=True)
243
- elif sort == "views": creators.sort(key=lambda x: x.get("views", 0), reverse=True)
244
- elif sort == "daily_views": creators.sort(key=lambda x: x.get("daily_views", 0), reverse=True)
245
- else: creators.sort(key=lambda x: x.get("created_at", 0), reverse=True)
246
-
247
- return {"status": "success", "data": creators[:limit]}
248
-
249
-
250
- @router.get("/api/creators")
251
- async def get_creators(sort: str = "downloads", limit: int = 100):
252
- """
253
- 获取创作者列表
254
- 🚀 P1性能优化:预构建 author->items 索引,避免 N+1 查询
255
- ⚡ P2性能优化:移除大字段(trendData、commentsData、tip_board),移至详情接口按需加载
256
- """
257
- users_db = read_public_source(db, "users.json", default_data={})
258
- items_db = read_public_source(db, "items.json", default_data=[])
259
-
260
- # 🚀 P1性能优化:预构建 author->items 索引,复杂度从 O(n*m) 降到 O(n+m)
261
- author_items_index = {}
262
- for item in items_db:
263
- author = item.get("author")
264
- if author:
265
- if author not in author_items_index:
266
- author_items_index[author] = []
267
- author_items_index[author].append(item)
268
-
269
- creators = []
270
- months = get_last_6_months()
271
-
272
- for account, u in users_db.items():
273
- # 🚀 P1性能优化:直接从索引获取,而非遍历全表
274
- u_items = author_items_index.get(account, [])
275
-
276
- tools_count = 0
277
- skills_count = 0
278
- apps_count = 0
279
-
280
- for i in u_items:
281
- itype = i.get("type", "")
282
- if itype == "tool":
283
- tools_count += 1
284
- elif itype == "skill":
285
- skills_count += 1
286
- elif itype == "app":
287
- apps_count += 1
288
-
289
- creators.append({
290
- "account": account, "name": u.get("name", account), "avatar": u.get("avatarDataUrl", ""),
291
- "bannerUrl": u.get("bannerUrl"), # 🖼️ 个人资料卡背景图
292
- "shortDesc": u.get("shortDesc") or u.get("intro") or "", "fullDesc": u.get("shortDesc") or u.get("intro") or "",
293
- "likes": sum(i.get("likes", 0) for i in u_items), "favorites": sum(i.get("favorites", 0) for i in u_items),
294
- "downloads": sum(i.get("uses", 0) for i in u_items),
295
- "views": sum(i.get("views", 0) for i in u_items),
296
- "daily_views": sum(i.get("daily_views", 0) for i in u_items),
297
- "toolsCount": tools_count, "skillsCount": skills_count, "appsCount": apps_count, "followers": len(u.get("followers", [])), "created_at": u.get("created_at", 0),
298
- "recent_tips": u.get("tip_history", {}).get(datetime.date.today().strftime("%Y-%m"), 0), # 🚀 新增:本月收益统计
299
- })
300
-
301
- if sort == "likes": creators.sort(key=lambda x: x.get("likes", 0), reverse=True)
302
- elif sort == "favorites": creators.sort(key=lambda x: x.get("favorites", 0), reverse=True)
303
- elif sort == "downloads": creators.sort(key=lambda x: x.get("downloads", 0), reverse=True)
304
- elif sort == "tips": creators.sort(key=lambda x: x.get("recent_tips", 0), reverse=True) # 🚀 新增:按近期打赏排序
305
- elif sort == "views": creators.sort(key=lambda x: x.get("views", 0), reverse=True)
306
- elif sort == "daily_views": creators.sort(key=lambda x: x.get("daily_views", 0), reverse=True)
307
- else: creators.sort(key=lambda x: x.get("created_at", 0), reverse=True)
308
-
309
- return {"status": "success", "data": creators[:limit]}
310
-
311
-
312
- @router.get("/api/creators/{account}/details")
313
- async def get_creator_details(account: str):
314
- """
315
- 获取单个创作者的详细数据
316
- 供前端展开卡片时按需加载
317
- 包含:trendData、commentsData、tip_board
318
- """
319
- users_db = read_public_source(db, "users.json", default_data={})
320
- items_db = read_public_source(db, "items.json", default_data=[])
321
- comments_db = read_public_source(db, "comments.json", default_data={})
322
-
323
- # 检查用户是否存在
324
- if account not in users_db:
325
- raise HTTPException(status_code=404, detail="创作者不存在")
326
-
327
- u = users_db[account]
328
-
329
- # 获取该用��的所有作品
330
- u_items = [item for item in items_db if item.get("author") == account]
331
-
332
- # 构建趋势数据
333
- months = get_last_6_months()
334
- trend_data = _build_creator_trend_data(account, u_items, months)
335
-
336
- return {
337
- "status": "success",
338
- "data": {
339
- "account": account,
340
- "trendData": trend_data,
341
- "commentsData": comments_db.get(account, []),
342
- "tip_board": u.get("tip_board", [])
343
- }
344
- }
345
-
346
- @router.post("/api/items")
347
- @shared_limit(5)
348
- async def create_item(request: Request, item: ItemCreate, current_user: str = Depends(require_auth)):
349
- # 🔒 P0安全修复:使用 JWT 认证的用户账号作为作者,防止伪造
350
- item.author = current_user
351
- if not re.fullmatch(r'[A-Za-z0-9_-]{1,50}', item.type or ''):
352
- raise HTTPException(status_code=400, detail="资源类型无效")
353
- new_item = {
354
- "id": f"{item.type}_{int(time.time())}_{uuid.uuid4().hex}", "type": item.type, "title": item.title, "author": item.author,
355
- "shortDesc": item.shortDesc, "fullDesc": item.fullDesc, "link": item.link, "coverUrl": item.coverUrl,
356
- "imageUrls": item.imageUrls or [], # 🖼️ 效果展示图列表
357
- "price": item.price,
358
- "github_token": item.github_token,
359
- "netdisk_password": item.netdisk_password, # ☁️ 网盘密码
360
- "is_netdisk": item.is_netdisk, # ☁️ 是否网盘资源
361
- "is_original": item.is_original, # 🎨 是否为原创作品
362
- "allow_refund": item.allow_refund, # 💸 是否支持退款
363
- "likes": 0, "favorites": 0, "comments": 0, "uses": 0, "use_history": {}, "created_at": int(time.time()), "liked_by": [], "favorited_by": [],
364
- "views": 0,
365
- "daily_views": 0,
366
- "viewed_by": [],
367
- "daily_views_date": "",
368
- "rating_avg": 0.0,
369
- "rating_count": 0,
370
- "rating_dist": {"1": 0, "2": 0, "3": 0, "4": 0, "5": 0},
371
- "rated_by": {}
372
- }
373
- try:
374
- if item.request_id is not None:
375
- from database_sql import engine
376
- from resource_publication import publish_once
377
- payload = {key: new_item[key] for key in item.model_dump(exclude={'request_id'})}
378
- new_item = await asyncio.to_thread(publish_once, engine, db, new_item, item.request_id, payload)
379
- else:
380
- new_item = await asyncio.to_thread(publish_resource, db, new_item)
381
- except ResourceStoreError as error:
382
- raise HTTPException(status_code=error.status_code, detail=error.message) from None
383
- # 已提交内容不因派生排序缓存故障而误报发布失败。
384
- try:
385
- sort_cache.invalidate("items:")
386
- except Exception as error:
387
- print(f"[资源发布] 缓存失效失败: error_type={type(error).__name__}")
388
-
389
- # 🚀 首次发布立即触发版本检测与预缓存
390
- try:
391
- link = item.link or ""
392
- if "github.com" in link:
393
- from 云端_定时版本检测引擎 import precache_github_zip
394
-
395
- async def _first_publish_precache():
396
- try:
397
- refreshed = await refresh_resource_version(db, new_item['id'])
398
- if refreshed:
399
- await precache_github_zip(refreshed['item']['link'], refreshed['token'], new_item['id'], refreshed['commit'])
400
- except Exception as e:
401
- print(f"[缓存刷新] 首次发布预缓存失败(不影响发布): item_id={new_item['id']}, error_type={type(e).__name__}")
402
-
403
- asyncio.create_task(_first_publish_precache())
404
- except Exception as e:
405
- print(f"[缓存刷新] 触发首次发布缓存异常(不影响发布): item_id={new_item['id']}, error_type={type(e).__name__}")
406
-
407
- return {"status": "success", "data": published_response(new_item)}
408
-
409
- @router.put("/api/items/{item_id}")
410
- @shared_limit(10)
411
- async def update_item(request: Request, item_id: str, update_data: ItemUpdate, current_user: str = Depends(require_auth)):
412
- """
413
- 更新内容接口
414
- 🔒 P0安全修复:使用 JWT Token 验证用户身份,而非前端传入的 author 参数
415
- 🔄 P7后悔模式:价格修改延迟24小时生效
416
- 🔧 Bug修复:使用 atomic_update 避免并发竞态条件
417
- """
418
- try:
419
- result_holder = await asyncio.to_thread(edit_resource, db, item_id, current_user,
420
- update_data.model_dump(exclude_unset=True))
421
- except ResourceStoreError as error:
422
- raise HTTPException(status_code=error.status_code, detail=error.message) from None
423
- # 编辑已经提交,派生缓存故障不改写主操作的成功结果。
424
- for invalidate, key in ((sort_cache.invalidate, "items:"), (invalidate_cache, "items.json")):
425
- try:
426
- invalidate(key)
427
- except Exception as error:
428
- print(f"[资源编辑] 缓存失效失败: error_type={type(error).__name__}")
429
-
430
- # 来源变更撤销旧版本;后台重新检测当前资源,绝不用旧仓库hash请求新仓库。
431
- if result_holder.get('source_changed'):
432
- try:
433
- await asyncio.to_thread(invalidate_source, db, item_id)
434
- async def refresh_changed_source():
435
- try:
436
- refreshed = await refresh_resource_version(db, item_id)
437
- if refreshed:
438
- from 云端_定时版本检测引擎 import precache_github_zip
439
- await precache_github_zip(refreshed['item']['link'], refreshed['token'], item_id, refreshed['commit'])
440
- except Exception as error:
441
- print(f"[缓存刷新] 来源检测失败: error_type={type(error).__name__}")
442
- asyncio.create_task(refresh_changed_source())
443
- except Exception as error:
444
- print(f"[缓存刷新] 来源撤销失败: error_type={type(error).__name__}")
445
-
446
- result = {"status": "success", "edit_revision": result_holder["edit_revision"]}
447
- if result_holder.get("price_change_info"):
448
- price_change_info = result_holder["price_change_info"]
449
- result["price_change"] = price_change_info
450
- result["message"] = f"价格将于24小时后从{price_change_info['current_price']}调整为{price_change_info['new_price']}积分"
451
- return result
452
-
453
- # ==========================================
454
- # 🚀 定时任务接口:检查 GitHub 仓库最新版本
455
- # 可通过外部调度器 (cron-job.org / GitHub Actions) 每日 02:00 触发
456
- # ==========================================
457
- @router.post("/api/check_updates")
458
- @shared_limit(30)
459
- async def check_github_updates(request: Request, current_user: str = Depends(require_auth)):
460
- """
461
- 遍历所有 GitHub 类型的工具,获取最新 commit hash,存入 versions.json
462
- """
463
- if not is_admin(current_user):
464
- raise HTTPException(status_code=403, detail="无权限")
465
- items_db = await asyncio.to_thread(db.load_data, "items.json", default_data=[], strict=True, skip_cache=True)
466
- updated_count = 0
467
- for item in items_db:
468
- if not item.get('link', '').startswith('https://github.com/'):
469
- continue
470
- try:
471
- refreshed = await refresh_resource_version(db, item['id'])
472
- if refreshed and refreshed['changed']:
473
- updated_count += 1
474
- except Exception as error:
475
- print(f"[版本检查异常] error_type={type(error).__name__}")
476
-
477
- return {
478
- "status": "success",
479
- "message": f"版本检查完成,共更新 {updated_count} 个工具的版本号",
480
- "updated_count": updated_count
481
- }
482
-
483
- @router.get("/api/items/{item_id}")
484
- async def get_item_by_id(item_id: str):
485
- """根据ID获取单个资源的详细信息"""
486
- items_db = read_public_source(db, "items.json", default_data=[])
487
- comments_db = read_public_source(db, "comments.json", default_data={})
488
- versions_db = read_public_source(db, "versions.json", default_data={})
489
-
490
- for item in items_db:
491
- if item["id"] == item_id:
492
- item_data = {**item}
493
- _apply_effective_price(item_data)
494
- # 添加关联数据
495
- item_data["commentsData"] = comments_db.get(item_id, [])
496
- item_data["comments"] = len(item_data["commentsData"])
497
- item_data["latest_version"] = _get_version_str(versions_db, item_id)
498
-
499
- # 🔴 【安全防线】:抹除敏感信息(在拷贝上操作)
500
- item_data["has_private_token"] = bool(item_data.get("github_token"))
501
- item_data.pop("github_token", None)
502
- item_data.pop("netdisk_password", None)
503
- item_data.pop("viewed_by", None)
504
-
505
- return {"status": "success", "data": item_data}
506
-
507
- raise HTTPException(status_code=404, detail="资源不存在")
508
-
509
- @router.get("/api/item/{item_id}/version")
510
- async def get_item_version(item_id: str):
511
- """获取单个资源的最新版本号"""
512
- versions_db = read_public_source(db, "versions.json", default_data={})
513
- return {"status": "success", "version": _get_version_str(versions_db, item_id)}
514
-
515
- @router.delete("/api/items/{item_id}")
516
- @shared_limit(10)
517
- async def delete_item(request: Request, item_id: str, current_user: str = Depends(require_auth)):
518
- """
519
- 删除内容(仅作者或管理员可操作)
520
- 🔧 Bug修复:使用 atomic_update 避免并发竞态条件
521
- """
522
- try:
523
- result = await asyncio.to_thread(delete_resource, db, item_id, current_user, is_admin(current_user))
524
- except ResourceStoreError as error:
525
- raise HTTPException(status_code=error.status_code, detail=error.message) from None
526
- for invalidate, key in ((invalidate_cache, "items.json"), (invalidate_cache, "comments.json"),
527
- (invalidate_cache, "versions.json"), (sort_cache.invalidate, "items:")):
528
- try:
529
- invalidate(key)
530
- except Exception as error:
531
- print(f"[资源删除] 缓存失效失败: error_type={type(error).__name__}")
532
- return {"status": "success", "message": "内容已删除", **result}
533
-
534
-
535
- @router.post("/api/items/{item_id}/view")
536
- @shared_limit(60)
537
- async def record_item_view(request: Request, item_id: str, current_user: str = Depends(require_auth)):
538
- """
539
- 记录资源访问量
540
- 👁️ 需要用户认证,每个用户只计算一次总访问量,日访问量每次调用都增加
541
- """
542
- result = record_view("items.json", item_id, current_user)
543
-
544
- if result is None:
545
- raise HTTPException(status_code=404, detail="找不到该内容记录")
546
-
547
- # 🗂️ 清除排序缓存(浏览量变化可能影响排序)
548
- sort_cache.invalidate("items:")
549
-
550
- return {"status": "success", "views": result["views"], "daily_views": result["daily_views"]}
551
-
552
-
553
- @router.post("/api/items/{item_id}/use")
554
- @shared_limit(30)
555
- async def record_item_use(request: Request, item_id: str, current_user: str = Depends(require_auth)):
556
- """
557
- 记录资源使用量/下载量(原子操作,并发安全)
558
- 📥 每个用户对同一资源只计一次,防止重复计数
559
- """
560
- result_container = [None]
561
- current_month = datetime.date.today().strftime("%Y-%m")
562
-
563
- def updater(data):
564
- for item in data:
565
- if item["id"] == item_id:
566
- used_by = item.get("used_by", [])
567
- if current_user in used_by:
568
- # 用户已使用过,不重复计数
569
- result_container[0] = {"status": "success", "action": "already_used", "uses": item.get("uses", 0)}
570
- return
571
- # 首次使用,增加计数
572
- used_by.append(current_user)
573
- item["uses"] = item.get("uses", 0) + 1
574
- # 更新月度使用历史
575
- use_history = item.get("use_history", {})
576
- use_history[current_month] = use_history.get(current_month, 0) + 1
577
- item["use_history"] = use_history
578
- item["used_by"] = used_by
579
- result_container[0] = {"status": "success", "action": "recorded", "uses": item["uses"]}
580
- return
581
- result_container[0] = None # 未找到资源
582
-
583
- db.atomic_update("items.json", updater, default_data=[])
584
-
585
- if result_container[0] is None:
586
- raise HTTPException(status_code=404, detail="资源不存在")
587
-
588
- # 🗂️ 清除排序缓存(使用数变化可能影响排序)
589
- sort_cache.invalidate("items:")
590
-
591
- return result_container[0]
592
-
593
-
594
- # ==========================================
595
- # ❤️ 互动接口(点赞/收藏)
596
- # ==========================================
597
-
598
- @router.post("/api/items/{item_id}/rating")
599
- @shared_limit(20)
600
- async def rate_item(request: Request, item_id: str, payload: RatingRequest, current_user: str = Depends(require_auth)):
601
- """
602
- 为资源评分(原子操作,并发安全)
603
- ⭐ score: 1-5
604
- """
605
- score = payload.score
606
- if score < 1 or score > 5:
607
- raise HTTPException(status_code=400, detail="评分必须在1-5之间")
608
-
609
- result_container = [None]
610
-
611
- def updater(data):
612
- for item in data:
613
- if item["id"] == item_id:
614
- # 禁止自评
615
- if item.get("author") == current_user:
616
- result_container[0] = {"error": "self_rating"}
617
- return
618
- # 初始化评分字段
619
- if "rating_avg" not in item:
620
- item["rating_avg"] = 0.0
621
- if "rating_count" not in item:
622
- item["rating_count"] = 0
623
- if "rating_dist" not in item:
624
- item["rating_dist"] = {"1": 0, "2": 0, "3": 0, "4": 0, "5": 0}
625
- if "rated_by" not in item:
626
- item["rated_by"] = {}
627
-
628
- rated_by = item["rated_by"]
629
- rating_dist = item["rating_dist"]
630
- old_score = None
631
- if current_user in rated_by:
632
- old_score = rated_by[current_user]["score"]
633
-
634
- if old_score is not None:
635
- # 已评分,先减去旧分数分布
636
- rating_dist[str(old_score)] = max(0, rating_dist.get(str(old_score), 0) - 1)
637
- rating_dist[str(score)] = rating_dist.get(str(score), 0) + 1
638
- else:
639
- # 未评分,增加计数
640
- item["rating_count"] = item.get("rating_count", 0) + 1
641
- rating_dist[str(score)] = rating_dist.get(str(score), 0) + 1
642
-
643
- rated_by[current_user] = {"score": score, "time": int(time.time())}
644
-
645
- # ��新计算平均分
646
- total = sum(int(k) * v for k, v in rating_dist.items())
647
- count = item["rating_count"]
648
- item["rating_avg"] = round(total / count, 2) if count > 0 else 0.0
649
-
650
- result_container[0] = {
651
- "status": "success",
652
- "rating_avg": item["rating_avg"],
653
- "rating_count": item["rating_count"],
654
- "rating_dist": item["rating_dist"],
655
- "user_score": score
656
- }
657
- return
658
- result_container[0] = None # 未找到资源
659
-
660
- db.atomic_update("items.json", updater, default_data=[])
661
-
662
- if result_container[0] is None:
663
- raise HTTPException(status_code=404, detail="资源不存在")
664
- if result_container[0].get("error") == "self_rating":
665
- raise HTTPException(status_code=400, detail="不能给自己发布的资源评分")
666
-
667
- # 🗂️ 清除排序缓存(评分变化可能影响排序)
668
- sort_cache.invalidate("items:")
669
-
670
- return result_container[0]
671
-
672
-
673
- @router.post("/api/items/{item_id}/like")
674
- @shared_limit(30)
675
- async def toggle_item_like(request: Request, item_id: str, current_user: str = Depends(require_auth)):
676
- """
677
- 点赞/取消点赞(原子操作,并发安全)
678
- """
679
- result_container = [None]
680
-
681
- def updater(data):
682
- for item in data:
683
- if item["id"] == item_id:
684
- liked_by = item.get("liked_by", [])
685
- if current_user in liked_by:
686
- liked_by.remove(current_user)
687
- item["likes"] = max(0, item.get("likes", 0) - 1)
688
- action = "unliked"
689
- else:
690
- liked_by.append(current_user)
691
- item["likes"] = item.get("likes", 0) + 1
692
- action = "liked"
693
- item["liked_by"] = liked_by
694
- result_container[0] = {"status": "success", "action": action, "likes": item["likes"]}
695
- return
696
- result_container[0] = None # 未找到资源
697
-
698
- db.atomic_update("items.json", updater, default_data=[])
699
-
700
- if result_container[0] is None:
701
- raise HTTPException(status_code=404, detail="资源不存在")
702
-
703
- # 🗂️ 清除排序缓存(点赞数变化可能影响排序)
704
- sort_cache.invalidate("items:")
705
-
706
- return result_container[0]
707
-
708
-
709
- @router.post("/api/items/{item_id}/favorite")
710
- @shared_limit(30)
711
- async def toggle_item_favorite(request: Request, item_id: str, current_user: str = Depends(require_auth)):
712
- """
713
- 收藏/取消收藏(原子操作,并发安全)
714
- """
715
- result_container = [None]
716
-
717
- def updater(data):
718
- for item in data:
719
- if item["id"] == item_id:
720
- favorited_by = item.get("favorited_by", [])
721
- if current_user in favorited_by:
722
- favorited_by.remove(current_user)
723
- item["favorites"] = max(0, item.get("favorites", 0) - 1)
724
- action = "unfavorited"
725
- else:
726
- favorited_by.append(current_user)
727
- item["favorites"] = item.get("favorites", 0) + 1
728
- action = "favorited"
729
- item["favorited_by"] = favorited_by
730
- result_container[0] = {"status": "success", "action": action, "favorites": item["favorites"]}
731
- return
732
- result_container[0] = None # 未找到资源
733
-
734
- db.atomic_update("items.json", updater, default_data=[])
735
-
736
- if result_container[0] is None:
737
- raise HTTPException(status_code=404, detail="资源不存在")
738
-
739
- # 🗂️ 清除排序缓存(收藏数变化可能影响排序)
740
- sort_cache.invalidate("items:")
741
-
742
- return result_container[0]
743
-
744
-
745
- # ==========================================
746
- # 🔄 手动触发预缓存刷新接口
747
- # ==========================================
748
- @router.post("/api/items/{item_id}/refresh_cache")
749
- @shared_limit(3)
750
- async def refresh_item_cache(request: Request, item_id: str, current_user: str = Depends(require_auth)):
751
- """手动触发指定资源的 ZIP 预缓存刷新,仅限作者调用"""
752
- items_db = db.load_data("items.json", default_data=[])
753
-
754
- # 查找目标 item
755
- target_item = None
756
- for item in items_db:
757
- if item["id"] == item_id:
758
- target_item = item
759
- break
760
-
761
- if not target_item:
762
- raise HTTPException(status_code=404, detail="资源不存在")
763
-
764
- # 权限校验:仅作者可操作
765
- if target_item.get("author") != current_user:
766
- raise HTTPException(status_code=403, detail="仅作者可触发缓存刷新")
767
-
768
- # 仅支持 GitHub 仓库类型
769
- link = target_item.get("link", "")
770
- if "github.com" not in link:
771
- raise HTTPException(status_code=400, detail="仅支持 GitHub 仓库类型的资源")
772
-
773
- try:
774
- from 云端_定时版本检测引擎 import precache_github_zip
775
- except ImportError:
776
- raise HTTPException(status_code=500, detail="预缓存模块不可用")
777
-
778
- refreshed = await refresh_resource_version(db, item_id)
779
- if not refreshed:
780
- raise HTTPException(status_code=503, detail="资源版本暂时无法核实,请稍后重试")
781
- success = await precache_github_zip(refreshed['item']['link'], refreshed['token'], item_id, refreshed['commit'])
782
-
783
- if success:
784
- return {"status": "success", "message": "缓存刷新完成"}
785
- else:
786
- return {"status": "error", "message": "缓存刷新失败,请查看云端日志获取详细信息"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from resource_work import run_resource_write, start_resource_background
2
+ # router_items.py
3
+ from fastapi import APIRouter, HTTPException, Depends, Request
4
+ from rate_limit import shared_limit
5
+ import time
6
+ import uuid
7
+ import datetime
8
+ import os
9
+ import re
10
+ import urllib.request
11
+ import urllib.error
12
+ import json
13
+ import asyncio
14
+ import 数据库连接 as db
15
+ from models import ItemCreate, ItemUpdate, RatingRequest
16
+ from 安全认证 import require_auth, check_ownership, is_admin
17
+ from 数据库连接 import invalidate_cache
18
+ from db_utils import read_public_source, record_view, sort_cache
19
+ from github_source import repository_parts, select_github_token
20
+ from github_versions import refresh_resource_version, invalidate_source
21
+ from resource_store import publish_resource, published_response, edit_resource, ResourceStoreError
22
+ from purchase_transaction import effective_resource_price
23
+ from resource_deletion import delete_resource
24
+
25
+ router = APIRouter()
26
+
27
+ def safe_str(val):
28
+ """安全转换为字符串"""
29
+ if val is None:
30
+ return ""
31
+ return str(val).lower()
32
+
33
+ def _get_version_str(versions_db: dict, item_id: str) -> str:
34
+ """兼容新旧格式获取版本hash字符串"""
35
+ val = versions_db.get(item_id, "")
36
+ if isinstance(val, dict):
37
+ return val.get("hash", "") or ""
38
+ return val or ""
39
+
40
+ def _apply_effective_price(item: dict) -> None:
41
+ """计算当前有效价格;调用者须传响应副本或处于编辑锁内。"""
42
+ try:
43
+ price = effective_resource_price(item)
44
+ pending = item.get('pending_price')
45
+ effective = item.get('pending_price_effective_at')
46
+ if type(pending) in (int, float) and pending == price and effective:
47
+ when = datetime.datetime.fromisoformat(effective)
48
+ now = datetime.datetime.now(when.tzinfo) if when.tzinfo else datetime.datetime.now()
49
+ if now >= when:
50
+ item['pending_price'] = None
51
+ item['pending_price_effective_at'] = None
52
+ item['price'] = price
53
+ except (ValueError, TypeError) as error:
54
+ import logging
55
+ kinds = {type(None): 'null', bool: 'boolean', int: 'integer', float: 'number',
56
+ str: 'string', list: 'array', dict: 'object'}
57
+ reason = ('invalid_effective_price' if len(error.args) == 1
58
+ and type(error.args[0]) is str and error.args[0] == 'invalid resource price'
59
+ else 'invalid_effective_time')
60
+ try:
61
+ logging.getLogger('ComfyUI-Ranking.Items').warning(
62
+ 'RESOURCE_PRICE_INVALID | reason=%s | price_type=%s | pending_price_type=%s | effective_time_type=%s',
63
+ reason, kinds.get(type(item.get('price', 0)), 'unknown'),
64
+ kinds.get(type(item.get('pending_price')), 'unknown'),
65
+ kinds.get(type(item.get('pending_price_effective_at')), 'unknown'))
66
+ except Exception:
67
+ pass
68
+ raise HTTPException(status_code=503, detail="资源价格暂时无法核实") from None
69
+
70
+
71
+ def get_last_6_months():
72
+ res = []
73
+ today = datetime.date.today()
74
+ for i in range(5, -1, -1):
75
+ m = today.month - i
76
+ y = today.year
77
+ while m <= 0:
78
+ m += 12
79
+ y -= 1
80
+ res.append(f"{y}-{m:02d}")
81
+ return res
82
+
83
+ # 数据文件标识,用于排序缓存
84
+ data_file = "items.json"
85
+
86
+ @router.get("/api/items")
87
+ async def get_items(type: str = "tool", sort: str = "time", limit: int = 50): # 优化:默认限制调大至 50,提升前端列表体验
88
+ read_epoch = sort_cache.capture_read_epoch()
89
+ items_db = read_public_source(db, "items.json", default_data=[])
90
+ comments_db = read_public_source(db, "comments.json", default_data={})
91
+ versions_db = read_public_source(db, "versions.json", default_data={}) # 读取版本库
92
+
93
+ # 如果是推荐榜,匹配所有 recommend 开头的子类型
94
+ if type == "recommend":
95
+ filtered_items = [item for item in items_db if item.get("type", "").startswith("recommend")]
96
+ else:
97
+ filtered_items = [item for item in items_db if item.get("type") == type]
98
+
99
+ # 🗂️ 使用排序缓存优化排序性能
100
+ cache_sort = sort if sort in ("time", "likes", "favorites", "downloads", "tips", "views", "daily_views", "rating") else "time"
101
+ cache_key = f"items:{type}:{cache_sort}"
102
+
103
+ current_month = datetime.date.today().strftime("%Y-%m") if sort == "tips" else None
104
+ def order_key(item):
105
+ if sort == "likes":
106
+ return item.get("likes", 0)
107
+ elif sort == "favorites":
108
+ return item.get("favorites", 0)
109
+ elif sort == "downloads":
110
+ return item.get("uses", 0)
111
+ elif sort == "tips": # 🚀 按近期打赏排序
112
+ return item.get("tip_history", {}).get(current_month, 0)
113
+ elif sort == "views": # 👁️ 按总访问量排序
114
+ return item.get("views", 0)
115
+ elif sort == "daily_views": # 👁️ 按日访问量排序
116
+ return item.get("daily_views", 0)
117
+ elif sort == "rating": # ⭐ 按评分排序
118
+ return (item.get("rating_avg", 0), item.get("rating_count", 0))
119
+ else: # time 或其他默认
120
+ return item.get("created_at", 0)
121
+
122
+ def sort_fn(data):
123
+ data.sort(key=order_key, reverse=True)
124
+
125
+ filtered_items = sort_cache.get_sorted(cache_key, filtered_items, sort_fn, read_epoch=read_epoch, fresh=True)
126
+
127
+ result_items = []
128
+ for item in filtered_items:
129
+ item_data = {**item}
130
+ _apply_effective_price(item_data)
131
+ item_data["commentsData"] = comments_db.get(item_data["id"], [])
132
+ item_data["comments"] = len(item_data["commentsData"])
133
+ item_data["latest_version"] = _get_version_str(versions_db, item_data["id"])
134
+
135
+ # 🔴 【绝对核心防线】:在下发给前端前,强行在内存中抹除敏感信息!
136
+ item_data["has_private_token"] = bool(item_data.get("github_token"))
137
+ item_data.pop("github_token", None)
138
+ item_data.pop("netdisk_password", None) # ☁️ 网盘密码不在列表中显示
139
+ item_data.pop("viewed_by", None) # 👁️ 访问者列表不暴露给前端
140
+ result_items.append(item_data)
141
+
142
+ return {"status": "success", "data": result_items[:limit]}
143
+
144
+ def _build_creator_trend_data(account: str, u_items: list, months: list) -> dict:
145
+ """
146
+ 构建创作者趋势数据
147
+ 提取为独立函数供列表接口和详情接口复用
148
+ """
149
+ trend_tools = {m: 0 for m in months}
150
+ trend_skills = {m: 0 for m in months}
151
+ trend_apps = {m: 0 for m in months}
152
+ trend_recommends = {m: 0 for m in months}
153
+
154
+ for i in u_items:
155
+ itype = i.get("type", "")
156
+ history = i.get("use_history", {})
157
+ if itype == "tool" or itype == "recommend_tool":
158
+ for m, v in history.items():
159
+ if m in trend_tools: trend_tools[m] += v
160
+ elif itype == "skill" or itype == "recommend_skill":
161
+ for m, v in history.items():
162
+ if m in trend_skills: trend_skills[m] += v
163
+ elif itype == "app" or itype == "recommend_app":
164
+ for m, v in history.items():
165
+ if m in trend_apps: trend_apps[m] += v
166
+ elif itype.startswith("recommend"):
167
+ for m, v in history.items():
168
+ if m in trend_recommends: trend_recommends[m] += v
169
+
170
+ return {
171
+ "months": months,
172
+ "tools": [trend_tools[m] for m in months],
173
+ "skills": [trend_skills[m] for m in months],
174
+ "apps": [trend_apps[m] for m in months],
175
+ "recommends": [trend_recommends[m] for m in months]
176
+ }
177
+
178
+
179
+ @router.get("/api/creators/search")
180
+ async def search_creators(keyword: str, sort: str = "downloads", limit: int = 50):
181
+ """
182
+ 搜索创作者
183
+ 根据关键词搜索创作者(name、account、shortDesc 不区分大小写子串匹配)
184
+ """
185
+ users_db = read_public_source(db, "users.json", default_data={})
186
+ items_db = read_public_source(db, "items.json", default_data=[])
187
+
188
+ # 🚀 P1性能优化:预构建 author->items 索引
189
+ author_items_index = {}
190
+ for item in items_db:
191
+ author = item.get("author")
192
+ if author:
193
+ if author not in author_items_index:
194
+ author_items_index[author] = []
195
+ author_items_index[author].append(item)
196
+
197
+ creators = []
198
+ months = get_last_6_months()
199
+ keyword_lower = keyword.lower()
200
+
201
+ for account, u in users_db.items():
202
+ # 🚀 P1性能优化:直接从索引获取
203
+ u_items = author_items_index.get(account, [])
204
+
205
+ # 获取搜索字段并转为小写
206
+ name = u.get("name", account)
207
+ short_desc = u.get("shortDesc") or u.get("intro") or ""
208
+
209
+ # 不区分大小写的子串匹配(同时覆盖 shortDesc 和 intro 两个字段)
210
+ search_text = f"{safe_str(name)} {safe_str(account)} {safe_str(short_desc)} {safe_str(u.get('intro'))} {safe_str(u.get('shortDesc'))}"
211
+ if keyword_lower not in search_text:
212
+ continue
213
+
214
+ tools_count = 0
215
+ skills_count = 0
216
+ apps_count = 0
217
+
218
+ for i in u_items:
219
+ itype = i.get("type", "")
220
+ if itype == "tool":
221
+ tools_count += 1
222
+ elif itype == "skill":
223
+ skills_count += 1
224
+ elif itype == "app":
225
+ apps_count += 1
226
+
227
+ creators.append({
228
+ "account": account, "name": name, "avatar": u.get("avatarDataUrl", ""),
229
+ "bannerUrl": u.get("bannerUrl"),
230
+ "shortDesc": short_desc, "fullDesc": short_desc,
231
+ "likes": sum(i.get("likes", 0) for i in u_items), "favorites": sum(i.get("favorites", 0) for i in u_items),
232
+ "downloads": sum(i.get("uses", 0) for i in u_items),
233
+ "views": sum(i.get("views", 0) for i in u_items),
234
+ "daily_views": sum(i.get("daily_views", 0) for i in u_items),
235
+ "toolsCount": tools_count, "skillsCount": skills_count, "appsCount": apps_count, "followers": len(u.get("followers", [])), "created_at": u.get("created_at", 0),
236
+ "recent_tips": u.get("tip_history", {}).get(datetime.date.today().strftime("%Y-%m"), 0),
237
+ })
238
+
239
+ # 排序逻辑与 /api/creators 保持一致
240
+ if sort == "likes": creators.sort(key=lambda x: x.get("likes", 0), reverse=True)
241
+ elif sort == "favorites": creators.sort(key=lambda x: x.get("favorites", 0), reverse=True)
242
+ elif sort == "downloads": creators.sort(key=lambda x: x.get("downloads", 0), reverse=True)
243
+ elif sort == "tips": creators.sort(key=lambda x: x.get("recent_tips", 0), reverse=True)
244
+ elif sort == "views": creators.sort(key=lambda x: x.get("views", 0), reverse=True)
245
+ elif sort == "daily_views": creators.sort(key=lambda x: x.get("daily_views", 0), reverse=True)
246
+ else: creators.sort(key=lambda x: x.get("created_at", 0), reverse=True)
247
+
248
+ return {"status": "success", "data": creators[:limit]}
249
+
250
+
251
+ @router.get("/api/creators")
252
+ async def get_creators(sort: str = "downloads", limit: int = 100):
253
+ """
254
+ 获取创作者列表
255
+ 🚀 P1性能优化:预构建 author->items 索引,避免 N+1 查询
256
+ ⚡ P2性能优化:移除大字段(trendData、commentsData、tip_board),移至详情接口按需加载
257
+ """
258
+ users_db = read_public_source(db, "users.json", default_data={})
259
+ items_db = read_public_source(db, "items.json", default_data=[])
260
+
261
+ # 🚀 P1性能优化:预构建 author->items 索引,复杂度从 O(n*m) 降到 O(n+m)
262
+ author_items_index = {}
263
+ for item in items_db:
264
+ author = item.get("author")
265
+ if author:
266
+ if author not in author_items_index:
267
+ author_items_index[author] = []
268
+ author_items_index[author].append(item)
269
+
270
+ creators = []
271
+ months = get_last_6_months()
272
+
273
+ for account, u in users_db.items():
274
+ # 🚀 P1性能优化:直接从索引获取,而非遍历全表
275
+ u_items = author_items_index.get(account, [])
276
+
277
+ tools_count = 0
278
+ skills_count = 0
279
+ apps_count = 0
280
+
281
+ for i in u_items:
282
+ itype = i.get("type", "")
283
+ if itype == "tool":
284
+ tools_count += 1
285
+ elif itype == "skill":
286
+ skills_count += 1
287
+ elif itype == "app":
288
+ apps_count += 1
289
+
290
+ creators.append({
291
+ "account": account, "name": u.get("name", account), "avatar": u.get("avatarDataUrl", ""),
292
+ "bannerUrl": u.get("bannerUrl"), # 🖼️ 个人资料卡背景图
293
+ "shortDesc": u.get("shortDesc") or u.get("intro") or "", "fullDesc": u.get("shortDesc") or u.get("intro") or "",
294
+ "likes": sum(i.get("likes", 0) for i in u_items), "favorites": sum(i.get("favorites", 0) for i in u_items),
295
+ "downloads": sum(i.get("uses", 0) for i in u_items),
296
+ "views": sum(i.get("views", 0) for i in u_items),
297
+ "daily_views": sum(i.get("daily_views", 0) for i in u_items),
298
+ "toolsCount": tools_count, "skillsCount": skills_count, "appsCount": apps_count, "followers": len(u.get("followers", [])), "created_at": u.get("created_at", 0),
299
+ "recent_tips": u.get("tip_history", {}).get(datetime.date.today().strftime("%Y-%m"), 0), # 🚀 新增:本月收益统计
300
+ })
301
+
302
+ if sort == "likes": creators.sort(key=lambda x: x.get("likes", 0), reverse=True)
303
+ elif sort == "favorites": creators.sort(key=lambda x: x.get("favorites", 0), reverse=True)
304
+ elif sort == "downloads": creators.sort(key=lambda x: x.get("downloads", 0), reverse=True)
305
+ elif sort == "tips": creators.sort(key=lambda x: x.get("recent_tips", 0), reverse=True) # 🚀 新增:按近期打赏排序
306
+ elif sort == "views": creators.sort(key=lambda x: x.get("views", 0), reverse=True)
307
+ elif sort == "daily_views": creators.sort(key=lambda x: x.get("daily_views", 0), reverse=True)
308
+ else: creators.sort(key=lambda x: x.get("created_at", 0), reverse=True)
309
+
310
+ return {"status": "success", "data": creators[:limit]}
311
+
312
+
313
+ @router.get("/api/creators/{account}/details")
314
+ async def get_creator_details(account: str):
315
+ """
316
+ 获取单个创作者的详细数据
317
+ 供前端展开卡片时按需加载
318
+ 包含:trendData、commentsData、tip_board
319
+ """
320
+ users_db = read_public_source(db, "users.json", default_data={})
321
+ items_db = read_public_source(db, "items.json", default_data=[])
322
+ comments_db = read_public_source(db, "comments.json", default_data={})
323
+
324
+ # 检查用户是否存在
325
+ if account not in users_db:
326
+ raise HTTPException(status_code=404, detail="创作者不存在")
327
+
328
+ u = users_db[account]
329
+
330
+ # 获取该用户的所有作品
331
+ u_items = [item for item in items_db if item.get("author") == account]
332
+
333
+ # 构建趋势数据
334
+ months = get_last_6_months()
335
+ trend_data = _build_creator_trend_data(account, u_items, months)
336
+
337
+ return {
338
+ "status": "success",
339
+ "data": {
340
+ "account": account,
341
+ "trendData": trend_data,
342
+ "commentsData": comments_db.get(account, []),
343
+ "tip_board": u.get("tip_board", [])
344
+ }
345
+ }
346
+
347
+ def _publish_item_record(new_item, request_id, payload_fields):
348
+ if request_id is not None:
349
+ from database_sql import engine
350
+ from resource_publication import publish_once
351
+ payload = {key: new_item[key] for key in payload_fields}
352
+ new_item = publish_once(engine, db, new_item, request_id, payload)
353
+ else:
354
+ new_item = publish_resource(db, new_item)
355
+ # 已提交内容不因派生排序缓存故障而误报发布失败。
356
+ try:
357
+ sort_cache.invalidate("items:")
358
+ except Exception as error:
359
+ print(f"[资源发布] 缓存失效失败: error_type={type(error).__name__}")
360
+ return new_item
361
+
362
+
363
+ def _edit_item_record(item_id, current_user, changes):
364
+ result_holder = edit_resource(db, item_id, current_user, changes)
365
+ # 编辑已经提交,派生缓存故障不改写主操作的成功结果。
366
+ for invalidate, key in ((sort_cache.invalidate, "items:"), (invalidate_cache, "items.json")):
367
+ try:
368
+ invalidate(key)
369
+ except Exception as error:
370
+ print(f"[资源编辑] 缓存失效失败: error_type={type(error).__name__}")
371
+ refresh_source = False
372
+ if result_holder.get('source_changed'):
373
+ try:
374
+ invalidate_source(db, item_id)
375
+ refresh_source = True
376
+ except Exception as error:
377
+ print(f"[缓存刷新] 来源撤销失败: error_type={type(error).__name__}")
378
+ return result_holder, refresh_source
379
+
380
+
381
+ def _delete_item_record(item_id, current_user, administrator):
382
+ result = delete_resource(db, item_id, current_user, administrator)
383
+ for invalidate, key in ((invalidate_cache, "items.json"), (invalidate_cache, "comments.json"),
384
+ (invalidate_cache, "versions.json"), (sort_cache.invalidate, "items:")):
385
+ try:
386
+ invalidate(key)
387
+ except Exception as error:
388
+ print(f"[资源删除] 缓存失效失败: error_type={type(error).__name__}")
389
+ return result
390
+
391
+
392
+ @router.post("/api/items")
393
+ @shared_limit(5)
394
+ async def create_item(request: Request, item: ItemCreate, current_user: str = Depends(require_auth)):
395
+ # 🔒 P0安全修复:使用 JWT 认证的用户账号作为作者,防止伪造
396
+ item.author = current_user
397
+ if not re.fullmatch(r'[A-Za-z0-9_-]{1,50}', item.type or ''):
398
+ raise HTTPException(status_code=400, detail="资源类型无效")
399
+ new_item = {
400
+ "id": f"{item.type}_{int(time.time())}_{uuid.uuid4().hex}", "type": item.type, "title": item.title, "author": item.author,
401
+ "shortDesc": item.shortDesc, "fullDesc": item.fullDesc, "link": item.link, "coverUrl": item.coverUrl,
402
+ "imageUrls": item.imageUrls or [], # 🖼️ 效果展示图列表
403
+ "price": item.price,
404
+ "github_token": item.github_token,
405
+ "netdisk_password": item.netdisk_password, # ☁️ 网盘密码
406
+ "is_netdisk": item.is_netdisk, # ☁️ 是否网盘资源
407
+ "is_original": item.is_original, # 🎨 是否为原创作品
408
+ "allow_refund": item.allow_refund, # 💸 是否支持退款
409
+ "likes": 0, "favorites": 0, "comments": 0, "uses": 0, "use_history": {}, "created_at": int(time.time()), "liked_by": [], "favorited_by": [],
410
+ "views": 0,
411
+ "daily_views": 0,
412
+ "viewed_by": [],
413
+ "daily_views_date": "",
414
+ "rating_avg": 0.0,
415
+ "rating_count": 0,
416
+ "rating_dist": {"1": 0, "2": 0, "3": 0, "4": 0, "5": 0},
417
+ "rated_by": {}
418
+ }
419
+ try:
420
+ new_item = await run_resource_write(_publish_item_record, new_item, item.request_id,
421
+ item.model_dump(exclude={'request_id'}))
422
+ except ResourceStoreError as error:
423
+ raise HTTPException(status_code=error.status_code, detail=error.message) from None
424
+
425
+ # 🚀 首次发布立即触发版本检测与预缓存
426
+ try:
427
+ link = item.link or ""
428
+ if "github.com" in link:
429
+ from 云端_定时版本检测引擎 import precache_github_zip
430
+
431
+ async def _first_publish_precache():
432
+ try:
433
+ refreshed = await refresh_resource_version(db, new_item['id'])
434
+ if refreshed:
435
+ await precache_github_zip(refreshed['item']['link'], refreshed['token'], new_item['id'], refreshed['commit'])
436
+ except Exception as e:
437
+ print(f"[缓存刷新] 首次发布预缓存失败(不影响发布): item_id={new_item['id']}, error_type={type(e).__name__}")
438
+
439
+ start_resource_background(_first_publish_precache)
440
+ except Exception as e:
441
+ print(f"[缓存刷新] 触发首次发布缓存异常(不影响发布): item_id={new_item['id']}, error_type={type(e).__name__}")
442
+
443
+ return {"status": "success", "data": published_response(new_item)}
444
+
445
+ @router.put("/api/items/{item_id}")
446
+ @shared_limit(10)
447
+ async def update_item(request: Request, item_id: str, update_data: ItemUpdate, current_user: str = Depends(require_auth)):
448
+ """
449
+ 更新内容接口
450
+ 🔒 P0安全修复:使用 JWT Token 验证用户身份,而非前端传入的 author 参数
451
+ 🔄 P7后悔模式:价格修改延迟24小时生效
452
+ 🔧 Bug修复:使用 atomic_update 避免并发竞态条件
453
+ """
454
+ try:
455
+ result_holder, refresh_source = await run_resource_write(_edit_item_record, item_id, current_user,
456
+ update_data.model_dump(exclude_unset=True))
457
+ except ResourceStoreError as error:
458
+ raise HTTPException(status_code=error.status_code, detail=error.message) from None
459
+ if refresh_source:
460
+ try:
461
+ async def refresh_changed_source():
462
+ try:
463
+ refreshed = await refresh_resource_version(db, item_id)
464
+ if refreshed:
465
+ from 云端_定时版本检测引擎 import precache_github_zip
466
+ await precache_github_zip(refreshed['item']['link'], refreshed['token'], item_id, refreshed['commit'])
467
+ except Exception as error:
468
+ print(f"[缓存刷新] 来源检测失败: error_type={type(error).__name__}")
469
+ start_resource_background(refresh_changed_source)
470
+ except Exception as error:
471
+ print(f"[缓存刷新] 来源撤销失败: error_type={type(error).__name__}")
472
+
473
+ result = {"status": "success", "edit_revision": result_holder["edit_revision"]}
474
+ if result_holder.get("price_change_info"):
475
+ price_change_info = result_holder["price_change_info"]
476
+ result["price_change"] = price_change_info
477
+ result["message"] = f"价格将于24小时后从{price_change_info['current_price']}调整为{price_change_info['new_price']}积分"
478
+ return result
479
+
480
+ # ==========================================
481
+ # 🚀 定时任务接口:检查 GitHub 仓库最新版本
482
+ # 可通过外部调度器 (cron-job.org / GitHub Actions) 每日 02:00 触发
483
+ # ==========================================
484
+ @router.post("/api/check_updates")
485
+ @shared_limit(30)
486
+ async def check_github_updates(request: Request, current_user: str = Depends(require_auth)):
487
+ """
488
+ 遍历所有 GitHub 类型的工具,获取最新 commit hash,存入 versions.json
489
+ """
490
+ if not is_admin(current_user):
491
+ raise HTTPException(status_code=403, detail="无权限")
492
+ items_db = await asyncio.to_thread(db.load_data, "items.json", default_data=[], strict=True, skip_cache=True)
493
+ updated_count = 0
494
+ for item in items_db:
495
+ if not item.get('link', '').startswith('https://github.com/'):
496
+ continue
497
+ try:
498
+ refreshed = await refresh_resource_version(db, item['id'])
499
+ if refreshed and refreshed['changed']:
500
+ updated_count += 1
501
+ except Exception as error:
502
+ print(f"[版本检查异常] error_type={type(error).__name__}")
503
+
504
+ return {
505
+ "status": "success",
506
+ "message": f"版本检查完成,共更新 {updated_count} 个工具的版本号",
507
+ "updated_count": updated_count
508
+ }
509
+
510
+ @router.get("/api/items/{item_id}")
511
+ async def get_item_by_id(item_id: str):
512
+ """根据ID获取单个资源的详细信息"""
513
+ items_db = read_public_source(db, "items.json", default_data=[])
514
+ comments_db = read_public_source(db, "comments.json", default_data={})
515
+ versions_db = read_public_source(db, "versions.json", default_data={})
516
+
517
+ for item in items_db:
518
+ if item["id"] == item_id:
519
+ item_data = {**item}
520
+ _apply_effective_price(item_data)
521
+ # 添加关联数据
522
+ item_data["commentsData"] = comments_db.get(item_id, [])
523
+ item_data["comments"] = len(item_data["commentsData"])
524
+ item_data["latest_version"] = _get_version_str(versions_db, item_id)
525
+
526
+ # 🔴 【安全防线】:抹除敏感信息(在拷贝上操作)
527
+ item_data["has_private_token"] = bool(item_data.get("github_token"))
528
+ item_data.pop("github_token", None)
529
+ item_data.pop("netdisk_password", None)
530
+ item_data.pop("viewed_by", None)
531
+
532
+ return {"status": "success", "data": item_data}
533
+
534
+ raise HTTPException(status_code=404, detail="资源不存在")
535
+
536
+ @router.get("/api/item/{item_id}/version")
537
+ async def get_item_version(item_id: str):
538
+ """获取单个资源的最新版本号"""
539
+ versions_db = read_public_source(db, "versions.json", default_data={})
540
+ return {"status": "success", "version": _get_version_str(versions_db, item_id)}
541
+
542
+ @router.delete("/api/items/{item_id}")
543
+ @shared_limit(10)
544
+ async def delete_item(request: Request, item_id: str, current_user: str = Depends(require_auth)):
545
+ """
546
+ 删除内容(仅作者或管理员可操作)
547
+ 🔧 Bug修复:使用 atomic_update 避免并发竞态条件
548
+ """
549
+ try:
550
+ result = await run_resource_write(_delete_item_record, item_id, current_user, is_admin(current_user))
551
+ except ResourceStoreError as error:
552
+ raise HTTPException(status_code=error.status_code, detail=error.message) from None
553
+ return {"status": "success", "message": "内容已删除", **result}
554
+
555
+
556
+ @router.post("/api/items/{item_id}/view")
557
+ @shared_limit(60)
558
+ async def record_item_view(request: Request, item_id: str, current_user: str = Depends(require_auth)):
559
+ """
560
+ 记录资源访问量
561
+ 👁️ 需要用户认证,每个用户只计算一次总访问量,日访问量每次调用都增加
562
+ """
563
+ result = record_view("items.json", item_id, current_user)
564
+
565
+ if result is None:
566
+ raise HTTPException(status_code=404, detail="找不到该内容记录")
567
+
568
+ # 🗂️ 清除排序缓存(浏览量变化可能影响排序)
569
+ sort_cache.invalidate("items:")
570
+
571
+ return {"status": "success", "views": result["views"], "daily_views": result["daily_views"]}
572
+
573
+
574
+ @router.post("/api/items/{item_id}/use")
575
+ @shared_limit(30)
576
+ async def record_item_use(request: Request, item_id: str, current_user: str = Depends(require_auth)):
577
+ """
578
+ 记录资源使用量/下载量(原子操作,并发安全)
579
+ 📥 每个用户对同一资源只计一次,防止重复计数
580
+ """
581
+ result_container = [None]
582
+ current_month = datetime.date.today().strftime("%Y-%m")
583
+
584
+ def updater(data):
585
+ for item in data:
586
+ if item["id"] == item_id:
587
+ used_by = item.get("used_by", [])
588
+ if current_user in used_by:
589
+ # 用户已使用过,不重复计数
590
+ result_container[0] = {"status": "success", "action": "already_used", "uses": item.get("uses", 0)}
591
+ return
592
+ # 首次使用,增加计数
593
+ used_by.append(current_user)
594
+ item["uses"] = item.get("uses", 0) + 1
595
+ # 更新月度使用历史
596
+ use_history = item.get("use_history", {})
597
+ use_history[current_month] = use_history.get(current_month, 0) + 1
598
+ item["use_history"] = use_history
599
+ item["used_by"] = used_by
600
+ result_container[0] = {"status": "success", "action": "recorded", "uses": item["uses"]}
601
+ return
602
+ result_container[0] = None # 未找到资源
603
+
604
+ db.atomic_update("items.json", updater, default_data=[])
605
+
606
+ if result_container[0] is None:
607
+ raise HTTPException(status_code=404, detail="资源不存在")
608
+
609
+ # 🗂️ 清除排序缓存(使用数变化可能影响排序)
610
+ sort_cache.invalidate("items:")
611
+
612
+ return result_container[0]
613
+
614
+
615
+ # ==========================================
616
+ # ❤️ 互动接口(点赞/收藏)
617
+ # ==========================================
618
+
619
+ @router.post("/api/items/{item_id}/rating")
620
+ @shared_limit(20)
621
+ async def rate_item(request: Request, item_id: str, payload: RatingRequest, current_user: str = Depends(require_auth)):
622
+ """
623
+ 为资源评分(原子操作,并发安全)
624
+ ⭐ score: 1-5
625
+ """
626
+ score = payload.score
627
+ if score < 1 or score > 5:
628
+ raise HTTPException(status_code=400, detail="评分必须在1-5之间")
629
+
630
+ result_container = [None]
631
+
632
+ def updater(data):
633
+ for item in data:
634
+ if item["id"] == item_id:
635
+ # 禁止自评
636
+ if item.get("author") == current_user:
637
+ result_container[0] = {"error": "self_rating"}
638
+ return
639
+ # 初始化评分字段
640
+ if "rating_avg" not in item:
641
+ item["rating_avg"] = 0.0
642
+ if "rating_count" not in item:
643
+ item["rating_count"] = 0
644
+ if "rating_dist" not in item:
645
+ item["rating_dist"] = {"1": 0, "2": 0, "3": 0, "4": 0, "5": 0}
646
+ if "rated_by" not in item:
647
+ item["rated_by"] = {}
648
+
649
+ rated_by = item["rated_by"]
650
+ rating_dist = item["rating_dist"]
651
+ old_score = None
652
+ if current_user in rated_by:
653
+ old_score = rated_by[current_user]["score"]
654
+
655
+ if old_score is not None:
656
+ # 已评分,先减去旧分数分布
657
+ rating_dist[str(old_score)] = max(0, rating_dist.get(str(old_score), 0) - 1)
658
+ rating_dist[str(score)] = rating_dist.get(str(score), 0) + 1
659
+ else:
660
+ # 未评分,增加计数
661
+ item["rating_count"] = item.get("rating_count", 0) + 1
662
+ rating_dist[str(score)] = rating_dist.get(str(score), 0) + 1
663
+
664
+ rated_by[current_user] = {"score": score, "time": int(time.time())}
665
+
666
+ # 重新计算平均分
667
+ total = sum(int(k) * v for k, v in rating_dist.items())
668
+ count = item["rating_count"]
669
+ item["rating_avg"] = round(total / count, 2) if count > 0 else 0.0
670
+
671
+ result_container[0] = {
672
+ "status": "success",
673
+ "rating_avg": item["rating_avg"],
674
+ "rating_count": item["rating_count"],
675
+ "rating_dist": item["rating_dist"],
676
+ "user_score": score
677
+ }
678
+ return
679
+ result_container[0] = None # 未找到资源
680
+
681
+ db.atomic_update("items.json", updater, default_data=[])
682
+
683
+ if result_container[0] is None:
684
+ raise HTTPException(status_code=404, detail="资源不存在")
685
+ if result_container[0].get("error") == "self_rating":
686
+ raise HTTPException(status_code=400, detail="不能给自己发布的资源评分")
687
+
688
+ # 🗂️ 清除排序缓存(评分变化可能影响排序)
689
+ sort_cache.invalidate("items:")
690
+
691
+ return result_container[0]
692
+
693
+
694
+ @router.post("/api/items/{item_id}/like")
695
+ @shared_limit(30)
696
+ async def toggle_item_like(request: Request, item_id: str, current_user: str = Depends(require_auth)):
697
+ """
698
+ 点赞/取消点赞(原子操作,并发安全)
699
+ """
700
+ result_container = [None]
701
+
702
+ def updater(data):
703
+ for item in data:
704
+ if item["id"] == item_id:
705
+ liked_by = item.get("liked_by", [])
706
+ if current_user in liked_by:
707
+ liked_by.remove(current_user)
708
+ item["likes"] = max(0, item.get("likes", 0) - 1)
709
+ action = "unliked"
710
+ else:
711
+ liked_by.append(current_user)
712
+ item["likes"] = item.get("likes", 0) + 1
713
+ action = "liked"
714
+ item["liked_by"] = liked_by
715
+ result_container[0] = {"status": "success", "action": action, "likes": item["likes"]}
716
+ return
717
+ result_container[0] = None # 未找到资源
718
+
719
+ db.atomic_update("items.json", updater, default_data=[])
720
+
721
+ if result_container[0] is None:
722
+ raise HTTPException(status_code=404, detail="资源不存在")
723
+
724
+ # 🗂️ 清除排序缓存(点赞数变化可能影响排序)
725
+ sort_cache.invalidate("items:")
726
+
727
+ return result_container[0]
728
+
729
+
730
+ @router.post("/api/items/{item_id}/favorite")
731
+ @shared_limit(30)
732
+ async def toggle_item_favorite(request: Request, item_id: str, current_user: str = Depends(require_auth)):
733
+ """
734
+ 收藏/取消收藏(原子操作,并发安全)
735
+ """
736
+ result_container = [None]
737
+
738
+ def updater(data):
739
+ for item in data:
740
+ if item["id"] == item_id:
741
+ favorited_by = item.get("favorited_by", [])
742
+ if current_user in favorited_by:
743
+ favorited_by.remove(current_user)
744
+ item["favorites"] = max(0, item.get("favorites", 0) - 1)
745
+ action = "unfavorited"
746
+ else:
747
+ favorited_by.append(current_user)
748
+ item["favorites"] = item.get("favorites", 0) + 1
749
+ action = "favorited"
750
+ item["favorited_by"] = favorited_by
751
+ result_container[0] = {"status": "success", "action": action, "favorites": item["favorites"]}
752
+ return
753
+ result_container[0] = None # 未找到资源
754
+
755
+ db.atomic_update("items.json", updater, default_data=[])
756
+
757
+ if result_container[0] is None:
758
+ raise HTTPException(status_code=404, detail="资源不存在")
759
+
760
+ # 🗂️ 清除排序缓存(收藏数变化可能影响排序)
761
+ sort_cache.invalidate("items:")
762
+
763
+ return result_container[0]
764
+
765
+
766
+ # ==========================================
767
+ # 🔄 手动触发预缓存刷新接口
768
+ # ==========================================
769
+ @router.post("/api/items/{item_id}/refresh_cache")
770
+ @shared_limit(3)
771
+ async def refresh_item_cache(request: Request, item_id: str, current_user: str = Depends(require_auth)):
772
+ """手动触发指定资源的 ZIP 预缓存刷新,仅限作者调用"""
773
+ items_db = db.load_data("items.json", default_data=[])
774
+
775
+ # 查找目标 item
776
+ target_item = None
777
+ for item in items_db:
778
+ if item["id"] == item_id:
779
+ target_item = item
780
+ break
781
+
782
+ if not target_item:
783
+ raise HTTPException(status_code=404, detail="资源不存在")
784
+
785
+ # 权限校验:仅作者可操作
786
+ if target_item.get("author") != current_user:
787
+ raise HTTPException(status_code=403, detail="仅作者可触发缓存刷新")
788
+
789
+ # 仅支持 GitHub 仓库类型
790
+ link = target_item.get("link", "")
791
+ if "github.com" not in link:
792
+ raise HTTPException(status_code=400, detail="仅支持 GitHub 仓库类型的资源")
793
+
794
+ try:
795
+ from 云端_定时版本检测引擎 import precache_github_zip
796
+ except ImportError:
797
+ raise HTTPException(status_code=500, detail="预缓存模块不可用")
798
+
799
+ refreshed = await refresh_resource_version(db, item_id)
800
+ if not refreshed:
801
+ raise HTTPException(status_code=503, detail="资源版本暂时无法核实,请稍后重试")
802
+ success = await precache_github_zip(refreshed['item']['link'], refreshed['token'], item_id, refreshed['commit'])
803
+
804
+ if success:
805
+ return {"status": "success", "message": "缓存刷新完成"}
806
+ else:
807
+ return {"status": "error", "message": "缓存刷新失败,请查看云端日志获取详细信息"}
router_messages.py CHANGED
@@ -1,188 +1,196 @@
1
- # router_messages.py
2
- from fastapi import APIRouter, HTTPException, Depends, Request
3
- from rate_limit import shared_limit
4
- from pydantic import BaseModel
5
- import asyncio
6
- import time
7
- import uuid
8
- import subprocess
9
- import logging
10
- from script_execution import run_admin_script_process, ScriptExecutionBusy
11
- import os
12
- import 数据库连接 as db
13
- import chat_store
14
- import notification_store
15
- from models import PrivateMessage, IdempotentPrivateMessage
16
- from 安全认证 import require_auth, is_admin
17
-
18
- # 🔒 频次限制(与 router_users_auth.py / router_wallet.py / router_prompts.py 同一写法)
19
-
20
- logger = logging.getLogger("ComfyUI-Ranking.AdminScripts")
21
- router = APIRouter()
22
-
23
- # ==========================================
24
- # 新增:系统公告请求体模型
25
- # ==========================================
26
- class SystemAnnouncement(BaseModel):
27
- admin_account: str
28
- content: str
29
-
30
- # ==========================================
31
- # 新增:发布系统公告接口 (仅限管理员,使用JWT验证)
32
- # ==========================================
33
- @router.post("/api/system/announcement")
34
- @shared_limit(5)
35
- async def publish_announcement(request: Request, ann: SystemAnnouncement, current_user: str = Depends(require_auth)):
36
- # 🔒 P0安全修复:使用环境变量配置的管理员列表
37
- if not is_admin(current_user):
38
- raise HTTPException(status_code=403, detail="无权发布系统公告,仅管理员可操作")
39
-
40
- await asyncio.to_thread(notification_store.publish_announcement, current_user, ann.content)
41
- return {"status": "success"}
42
-
43
-
44
- # ==========================================
45
- # 管理员调试:执行 Python 脚本
46
- # ==========================================
47
- class AdminScriptRequest(BaseModel):
48
- admin_account: str
49
- script_name: str
50
-
51
- # 🔒 P0安全修复:脚本白名单(仅允许执行指定的脚本)
52
- # 警告:添加新脚本前请确保其安全性
53
- ALLOWED_SCRIPTS = {
54
- "密码迁移.py", # 用户密码哈希化迁移
55
- "测试脚本.py", # 接口测试工具
56
- "迁移_余额合并.py", # 一次性余额合并迁移(执行后可移除)
57
- "迁移_补全历史SALE记录.py", # 补全历史SALE记录迁移
58
- }
59
-
60
- @router.post("/api/admin/run-script")
61
- @shared_limit(3)
62
- async def run_admin_script(request: Request, req: AdminScriptRequest, current_user: str = Depends(require_auth)):
63
- """
64
- 管理员专属:执行指定的 Python 脚本
65
- 🔒 P0安全修复:白名单 + 路径穿越防护
66
- """
67
- # 🔒 P0安全修复:使用环境变量配置的管理员列表
68
- if not is_admin(current_user):
69
- raise HTTPException(status_code=403, detail="无权执行此操作,仅管理员可操作")
70
-
71
- script_name = req.script_name.strip()
72
- if not script_name:
73
- raise HTTPException(status_code=400, detail="脚本名称不能为空")
74
-
75
- # 🔒 P0安全修复:路径穿越攻击防护
76
- if ".." in script_name or "/" in script_name or "\\" in script_name:
77
- raise HTTPException(status_code=400, detail="🚨 安全拦截:脚本名称包含非法字符")
78
-
79
- # 🔒 P0安全修复:白名单检查
80
- if script_name not in ALLOWED_SCRIPTS:
81
- raise HTTPException(
82
- status_code=403,
83
- detail=f"🚨 安全拦截:脚本 [{script_name}] 不在白名单中。允许的脚本: {list(ALLOWED_SCRIPTS)}"
84
- )
85
-
86
- # 获取当前工作目录
87
- current_dir = os.path.dirname(os.path.abspath(__file__))
88
- script_path = os.path.join(current_dir, script_name)
89
-
90
- # 检查文件是否存在
91
- if not os.path.exists(script_path):
92
- return {
93
- "status": "error",
94
- "output": f"❌ 脚本文件不存在: {script_name}\n\n白名单脚本: {list(ALLOWED_SCRIPTS)}"
95
- }
96
-
97
- try:
98
- # 清理敏感环境变量,防止泄露到子进程
99
- sensitive_prefixes = ('GITHUB', 'JWT', 'HF_', 'DATABASE', 'ALIPAY', 'PASSWORD')
100
- clean_env = {k: v for k, v in os.environ.items()
101
- if not any(k.startswith(prefix) for prefix in sensitive_prefixes)}
102
- result = await run_admin_script_process(script_path, cwd=current_dir, env=clean_env)
103
-
104
- output = ""
105
- if result.stdout:
106
- output += f"📝 标准输出:\n{result.stdout}\n"
107
- if result.stderr:
108
- output += f"\n⚠️ 错误输出:\n{result.stderr}"
109
- if not output:
110
- output = "✅ 脚本执行完成,无输出"
111
-
112
- # 限制输出大小防止返回大量数据
113
- if len(output) > 1000:
114
- output = output[:1000] + "\n...(输出已截断)"
115
-
116
- return {
117
- "status": "success" if result.returncode == 0 else "error",
118
- "return_code": result.returncode,
119
- "output": output
120
- }
121
-
122
- except ScriptExecutionBusy:
123
- raise HTTPException(status_code=503, detail="脚本执行繁忙,请先确认当前执行状态") from None
124
- except subprocess.TimeoutExpired:
125
- return {
126
- "status": "error",
127
- "output": "❌ 脚本执行超时 (30秒)"
128
- }
129
- except Exception as error:
130
- logger.error("ADMIN_SCRIPT_ERROR | error_type=%s", type(error).__name__)
131
- return {
132
- "status": "error",
133
- "output": "脚本执行暂不可用,请先确认当前状态"
134
- }
135
-
136
- # ==========================================
137
- # 原有功能:私信与聊天
138
- # ==========================================
139
- @router.post("/api/messages/private")
140
- @shared_limit(30) # 🔒 安全限流:防止私信骚扰/刷屏(聊天节奏快,阈值比发帖宽松)
141
- async def send_private_message(request: Request, msg: PrivateMessage, current_user: str = Depends(require_auth)):
142
- await asyncio.to_thread(chat_store.send_message, current_user, msg.receiver, msg.content)
143
- return {"status": "success"}
144
-
145
- @router.post("/api/messages/private/idempotent")
146
- @shared_limit(30)
147
- async def send_idempotent_private_message(request: Request, msg: IdempotentPrivateMessage, current_user: str = Depends(require_auth)):
148
- receipt = await asyncio.to_thread(chat_store.send_message, current_user, msg.receiver, msg.content, msg.client_request_id)
149
- return {"status": "success", "data": receipt}
150
-
151
-
152
- @router.get("/api/chats/{account}")
153
- async def get_chat_list(account: str, current_user: str = Depends(require_auth)):
154
- if account != current_user:
155
- raise HTTPException(status_code=403, detail="无权访问他人私信")
156
- data = await asyncio.to_thread(chat_store.chat_list, account)
157
- return {"status": "success", "data": data}
158
-
159
- @router.get("/api/chats/{account}/{target_account}")
160
- async def get_chat_history(account: str, target_account: str, current_user: str = Depends(require_auth)):
161
- if account != current_user:
162
- raise HTTPException(status_code=403, detail="无权访问他人聊天记录")
163
- data = await asyncio.to_thread(chat_store.chat_history, account, target_account)
164
- return {"status": "success", "data": data}
165
-
166
- # ==========================================
167
- # 改造:获取通知列表 (加入系统公告懒加载注入)
168
- # 使用 atomic_update 避免并发覆盖问题
169
- # 🔥 性能优化:先用只读方式检查是否有实际变更,避免无意义的写入和HF上传
170
- # ==========================================
171
- @router.get("/api/messages/{account}")
172
- async def get_messages(account: str, count_only: bool = False, current_user: str = Depends(require_auth)):
173
- # 🔒 P0安全加固:拦截越权读取他人消息(含 count_only 轮询)
174
- if account != current_user:
175
- raise HTTPException(status_code=403, detail="无权访问他人消息")
176
- return await asyncio.to_thread(notification_store.get_messages, account, count_only)
177
-
178
- @router.post("/api/messages/{account}/read")
179
- @shared_limit(60)
180
- async def mark_messages_read(request: Request, account: str, current_user: str = Depends(require_auth)):
181
- """
182
- 标记消息为已读(原子操作,并发安全)
183
- """
184
- # 🔒 P0安全加固:补全鉴权,防止任意用户篡改他人消息已读状态
185
- if account != current_user:
186
- raise HTTPException(status_code=403, detail="无权操作他人消息")
187
- await asyncio.to_thread(notification_store.mark_messages_read, account)
188
- return {"status": "success"}
 
 
 
 
 
 
 
 
 
1
+ # router_messages.py
2
+ from fastapi import APIRouter, HTTPException, Depends, Request
3
+ from rate_limit import shared_limit
4
+ from pydantic import BaseModel
5
+ from owned_thread import OwnedThreadGroup
6
+ import time
7
+ import uuid
8
+ import subprocess
9
+ import logging
10
+ from script_execution import run_admin_script_process, ScriptExecutionBusy
11
+ import os
12
+ import 数据库连接 as db
13
+ import chat_store
14
+ import notification_store
15
+ from models import PrivateMessage, IdempotentPrivateMessage
16
+ from 安全认证 import require_auth, is_admin
17
+
18
+ # 🔒 频次限制(与 router_users_auth.py / router_wallet.py / router_prompts.py 同一写法)
19
+
20
+ logger = logging.getLogger("ComfyUI-Ranking.AdminScripts")
21
+ MAX_MESSAGE_IO = 16
22
+ _message_io = OwnedThreadGroup(MAX_MESSAGE_IO,
23
+ lambda: HTTPException(status_code=503, detail="消息服务暂不可用"))
24
+ run_message_io = _message_io.run
25
+ start_message_io = _message_io.start
26
+ stop_message_io = _message_io.stop
27
+ close_message_io = _message_io.close
28
+
29
+ router = APIRouter()
30
+
31
+ # ==========================================
32
+ # 新增:系统公告请求体模型
33
+ # ==========================================
34
+ class SystemAnnouncement(BaseModel):
35
+ admin_account: str
36
+ content: str
37
+
38
+ # ==========================================
39
+ # 新增:发布系统公告接口 (仅限管理员,使用JWT验证)
40
+ # ==========================================
41
+ @router.post("/api/system/announcement")
42
+ @shared_limit(5)
43
+ async def publish_announcement(request: Request, ann: SystemAnnouncement, current_user: str = Depends(require_auth)):
44
+ # 🔒 P0安全修复:使用环境变量配置的管理员列表
45
+ if not is_admin(current_user):
46
+ raise HTTPException(status_code=403, detail="无权发布系统公告,仅管理员可操作")
47
+
48
+ await run_message_io(notification_store.publish_announcement, current_user, ann.content)
49
+ return {"status": "success"}
50
+
51
+
52
+ # ==========================================
53
+ # 管理员调试:执行 Python 脚本
54
+ # ==========================================
55
+ class AdminScriptRequest(BaseModel):
56
+ admin_account: str
57
+ script_name: str
58
+
59
+ # 🔒 P0安全修复:脚本白名单(仅允许执行指定的脚本)
60
+ # 警告:添加新脚本前请确保其安全性
61
+ ALLOWED_SCRIPTS = {
62
+ "密码迁移.py", # 用户密码哈希化迁移
63
+ "测试脚本.py", # 接口测试工具
64
+ "迁移_余额合并.py", # 一次性余额合并迁移(执行后可移除)
65
+ "迁移_补全历史SALE记录.py", # 补全历史SALE记录迁移
66
+ }
67
+
68
+ @router.post("/api/admin/run-script")
69
+ @shared_limit(3)
70
+ async def run_admin_script(request: Request, req: AdminScriptRequest, current_user: str = Depends(require_auth)):
71
+ """
72
+ 管理员专属:执行指定的 Python 脚本
73
+ 🔒 P0安全修复:白名单 + 路径穿越防护
74
+ """
75
+ # 🔒 P0安全修复:使用环境变量配置的管理员列表
76
+ if not is_admin(current_user):
77
+ raise HTTPException(status_code=403, detail="无权执行此操作,仅管理员可操作")
78
+
79
+ script_name = req.script_name.strip()
80
+ if not script_name:
81
+ raise HTTPException(status_code=400, detail="脚本名称不能为空")
82
+
83
+ # 🔒 P0安全修复:路径穿越攻击防护
84
+ if ".." in script_name or "/" in script_name or "\\" in script_name:
85
+ raise HTTPException(status_code=400, detail="🚨 安全拦截:脚本名称包含非法字符")
86
+
87
+ # 🔒 P0安全修复:白名单检查
88
+ if script_name not in ALLOWED_SCRIPTS:
89
+ raise HTTPException(
90
+ status_code=403,
91
+ detail=f"🚨 安全拦截:脚本 [{script_name}] 不在白名单中。允许的脚本: {list(ALLOWED_SCRIPTS)}"
92
+ )
93
+
94
+ # 获取当前工作目录
95
+ current_dir = os.path.dirname(os.path.abspath(__file__))
96
+ script_path = os.path.join(current_dir, script_name)
97
+
98
+ # 检查文件是否存在
99
+ if not os.path.exists(script_path):
100
+ return {
101
+ "status": "error",
102
+ "output": f"❌ 脚本文件不存在: {script_name}\n\n白名单脚本: {list(ALLOWED_SCRIPTS)}"
103
+ }
104
+
105
+ try:
106
+ # 清理敏感环境变量,防止泄露到子进程
107
+ sensitive_prefixes = ('GITHUB', 'JWT', 'HF_', 'DATABASE', 'ALIPAY', 'PASSWORD')
108
+ clean_env = {k: v for k, v in os.environ.items()
109
+ if not any(k.startswith(prefix) for prefix in sensitive_prefixes)}
110
+ result = await run_admin_script_process(script_path, cwd=current_dir, env=clean_env)
111
+
112
+ output = ""
113
+ if result.stdout:
114
+ output += f"📝 标准输出:\n{result.stdout}\n"
115
+ if result.stderr:
116
+ output += f"\n⚠️ 错误输出:\n{result.stderr}"
117
+ if not output:
118
+ output = "✅ 脚本执行完成,无输出"
119
+
120
+ # 限制输出大小防止返回大量数据
121
+ if len(output) > 1000:
122
+ output = output[:1000] + "\n...(输出已截断)"
123
+
124
+ return {
125
+ "status": "success" if result.returncode == 0 else "error",
126
+ "return_code": result.returncode,
127
+ "output": output
128
+ }
129
+
130
+ except ScriptExecutionBusy:
131
+ raise HTTPException(status_code=503, detail="脚本执行繁忙,请先确认当前执行状态") from None
132
+ except subprocess.TimeoutExpired:
133
+ return {
134
+ "status": "error",
135
+ "output": "❌ 脚本执行超时 (30秒)"
136
+ }
137
+ except Exception as error:
138
+ logger.error("ADMIN_SCRIPT_ERROR | error_type=%s", type(error).__name__)
139
+ return {
140
+ "status": "error",
141
+ "output": "脚本执行暂不可用,请先确认当前状态"
142
+ }
143
+
144
+ # ==========================================
145
+ # 原有功能:私信与聊天
146
+ # ==========================================
147
+ @router.post("/api/messages/private")
148
+ @shared_limit(30) # 🔒 安全限流:防止私信骚扰/刷屏(聊天节奏快,阈值比发帖宽松)
149
+ async def send_private_message(request: Request, msg: PrivateMessage, current_user: str = Depends(require_auth)):
150
+ await run_message_io(chat_store.send_message, current_user, msg.receiver, msg.content)
151
+ return {"status": "success"}
152
+
153
+ @router.post("/api/messages/private/idempotent")
154
+ @shared_limit(30)
155
+ async def send_idempotent_private_message(request: Request, msg: IdempotentPrivateMessage, current_user: str = Depends(require_auth)):
156
+ receipt = await run_message_io(chat_store.send_message, current_user, msg.receiver, msg.content, msg.client_request_id)
157
+ return {"status": "success", "data": receipt}
158
+
159
+
160
+ @router.get("/api/chats/{account}")
161
+ async def get_chat_list(account: str, current_user: str = Depends(require_auth)):
162
+ if account != current_user:
163
+ raise HTTPException(status_code=403, detail="无权访问他人私信")
164
+ data = await run_message_io(chat_store.chat_list, account)
165
+ return {"status": "success", "data": data}
166
+
167
+ @router.get("/api/chats/{account}/{target_account}")
168
+ async def get_chat_history(account: str, target_account: str, current_user: str = Depends(require_auth)):
169
+ if account != current_user:
170
+ raise HTTPException(status_code=403, detail="无权访问他人聊天记录")
171
+ data = await run_message_io(chat_store.chat_history, account, target_account)
172
+ return {"status": "success", "data": data}
173
+
174
+ # ==========================================
175
+ # 改造:获取通知列表 (加入系统公告懒加载注入)
176
+ # 使用 atomic_update 避免并发覆盖问题
177
+ # 🔥 性能优化:先用只读方式检查是否有实际变更,避免无意义的写入和HF上传
178
+ # ==========================================
179
+ @router.get("/api/messages/{account}")
180
+ async def get_messages(account: str, count_only: bool = False, current_user: str = Depends(require_auth)):
181
+ # 🔒 P0安全加固:拦截越权读取他人消息(含 count_only 轮询)
182
+ if account != current_user:
183
+ raise HTTPException(status_code=403, detail="无权访问他人消息")
184
+ return await run_message_io(notification_store.get_messages, account, count_only)
185
+
186
+ @router.post("/api/messages/{account}/read")
187
+ @shared_limit(60)
188
+ async def mark_messages_read(request: Request, account: str, current_user: str = Depends(require_auth)):
189
+ """
190
+ 标记消息为已读(原子操作,并发安全)
191
+ """
192
+ # 🔒 P0安全加固:补全鉴权,防止任意用户篡改他人消息已读状态
193
+ if account != current_user:
194
+ raise HTTPException(status_code=403, detail="无权操作他人消息")
195
+ await run_message_io(notification_store.mark_messages_read, account)
196
+ return {"status": "success"}
router_open_api.py CHANGED
@@ -1,4 +1,3 @@
1
- import asyncio
2
  # router_open_api.py
3
  # ==========================================
4
  # 🌐 通用开放 API 模块(供第三方插件调用)
@@ -7,7 +6,7 @@ import asyncio
7
  # 供 NodeCraft AI 等第三方插件复用 ComfyUI-Ranking 账号体系。
8
  # 关联文件:
9
  # - 安全认证.py (verify_token_with_fallback, require_auth)
10
- # - database_sql.py (get_db, SessionLocal)
11
  # - models_sql.py (Wallet, Transaction)
12
  # - 数据库连接.py (users.json 读写)
13
  # 安全机制:
@@ -25,17 +24,20 @@ import hashlib
25
  import logging
26
  from typing import Optional
27
 
28
- from fastapi import APIRouter, Depends, Header, HTTPException, Query
29
  from fastapi.responses import JSONResponse
30
  from sqlalchemy.orm import Session
31
  from sqlalchemy import func, text
32
  from pydantic import BaseModel, Field
33
 
34
- from database_sql import get_db, SessionLocal
35
  from models_sql import Wallet, Transaction, RegisteredPlugin, OpenApiRateLimitBucket
36
  from open_transaction import lock_open_wallet, positive_integer, validate_live_plugin, quota_total
 
37
  from plugin_permissions import parse_permissions
38
  from tip_store import run_tip_effect
 
 
39
  from 安全认证 import verify_token_with_fallback
40
  import 数据库连接 as json_db
41
 
@@ -44,6 +46,21 @@ logger = logging.getLogger("ComfyUI-Ranking.OpenAPI")
44
  router = APIRouter(prefix="/api/open", tags=["open-api"])
45
 
46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  # ═══════════════ 插件注册表(从数据库动态加载) ═══════════════
48
  # 反向索引:plugin_key -> {id, key, name, rate_limit, permissions, max_deduct_per_tx, max_deduct_per_day}
49
  _PLUGIN_KEY_INDEX: dict = {}
@@ -215,7 +232,7 @@ async def _resolve_user_from_token(token: str) -> str:
215
  """从 Token 中解析出账号;失败时抛 401"""
216
  if not token:
217
  raise HTTPException(status_code=401, detail="缺少用户 Token")
218
- is_valid, account, error_msg = await asyncio.to_thread(verify_token_with_fallback, token)
219
  if not is_valid or not account:
220
  raise HTTPException(status_code=401, detail=error_msg or "Token 无效")
221
  return account
@@ -239,12 +256,16 @@ class DeductRequest(BaseModel):
239
  amount: int = Field(..., gt=0, strict=True, description="扣款金额(正整数)")
240
  reason: str = Field(..., description="业务标识/扣款原因")
241
  reference_id: Optional[str] = Field(None, description="业务侧参考 ID")
 
 
242
 
243
 
244
  class RefundRequest(BaseModel):
245
  token: str = Field(..., description="用户 JWT Token")
246
  transaction_id: str = Field(..., description="原扣款交易 ID")
247
  amount: int = Field(..., gt=0, strict=True, description="退款金额(正整数)")
 
 
248
 
249
 
250
  # ==========================================
@@ -254,20 +275,19 @@ class RefundRequest(BaseModel):
254
  async def open_verify_token(
255
  req: VerifyTokenRequest,
256
  x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
257
- db: Session = Depends(get_db),
258
  ):
259
  """
260
  供第三方插件验证用户 Token 有效性。
261
  成功返回账号与基础资料(昵称、头像)。
262
  """
263
- plugin_info = _require_plugin(x_plugin_key, "verify", db)
264
 
265
- is_valid, account, error_msg = await asyncio.to_thread(verify_token_with_fallback, req.token)
266
  if not is_valid or not account:
267
  logger.info(f"OPEN_VERIFY_TOKEN_FAIL | plugin={plugin_info['id']} | category=invalid_token")
268
  return {"valid": False, "error": error_msg or "Token 无效"}
269
 
270
- users_db = json_db.load_data("users.json", default_data={})
271
  user = users_db.get(account, {}) or {}
272
 
273
  logger.info(f"OPEN_VERIFY_TOKEN | plugin={plugin_info['id']}")
@@ -286,13 +306,12 @@ async def open_verify_token(
286
  async def open_get_balance(
287
  authorization: Optional[str] = Header(None, alias="Authorization"),
288
  x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
289
- db: Session = Depends(get_db),
290
  ):
291
  """
292
  查询用户钱包余额。
293
  认证:Authorization: Bearer <user_token> + X-Plugin-Key
294
  """
295
- plugin_info = _require_plugin(x_plugin_key, "balance", db)
296
 
297
  if not authorization:
298
  raise HTTPException(status_code=401, detail="缺少 Authorization Header")
@@ -301,6 +320,105 @@ async def open_get_balance(
301
  raise HTTPException(status_code=401, detail="Authorization 格式错误,请使用 Bearer Token")
302
 
303
  account = await _resolve_user_from_token(parts[1])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
  wallet = db.query(Wallet).filter(Wallet.account == account).first()
305
 
306
  if not wallet:
@@ -326,30 +444,17 @@ async def open_get_balance(
326
  return result
327
 
328
 
329
- # ==========================================
330
- # 3️⃣ 通用扣款:POST /api/open/deduct
331
- # ==========================================
332
- @router.post("/deduct")
333
- async def open_deduct(
334
- req: DeductRequest,
335
- x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
336
- db: Session = Depends(get_db),
337
- ):
338
- """
339
- 通用扣款接口(从 wallet.balance 扣减)。
340
- 流程:
341
- 1. 校验 Plugin Key + 权限 + 限流
342
- 2. 解析 Token -> account
343
- 3. 共用写锁锁定钱包与实时插件配置,检查额度/余额
344
- 4. 扣款 + 写入 Transaction(含哈希链)
345
- """
346
- plugin_info = _require_plugin(x_plugin_key, "deduct", db)
347
- account = await _resolve_user_from_token(req.token)
348
  amount = req.amount
349
  if isinstance(amount, bool) or not isinstance(amount, int) or amount <= 0:
350
  raise HTTPException(status_code=422, detail="扣款金额必须为正整数")
 
 
351
  try:
352
  wallet, plugin_row = lock_open_wallet(db, account, plugin_info["id"], x_plugin_key, "deduct")
 
 
 
353
  max_per_tx = positive_integer(plugin_row.max_deduct_per_tx)
354
  max_daily = positive_integer(plugin_row.max_deduct_per_day)
355
  if amount > max_per_tx:
@@ -374,7 +479,7 @@ async def open_deduct(
374
  if wallet.balance < amount:
375
  raise HTTPException(status_code=402, detail="余额不足")
376
  wallet.balance -= amount
377
- tx_id = f"OPEN_DEDUCT_{uuid.uuid4().hex}"
378
  last_tx = db.query(Transaction).filter(Transaction.account == account).order_by(
379
  Transaction.created_at.desc(), Transaction.tx_id.desc()).with_for_update().first()
380
  prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
@@ -383,6 +488,7 @@ async def open_deduct(
383
  tx_hash=_calc_tx_hash(tx_id, account, "OPEN_DEDUCT", -amount, prev_hash),
384
  description=f"[{plugin_row.name}] {req.reason}", source_plugin=plugin_info["id"]))
385
  response = {"success": True, "remaining_balance": wallet.balance, "transaction_id": tx_id}
 
386
  db.commit()
387
  except HTTPException:
388
  db.rollback()
@@ -395,31 +501,17 @@ async def open_deduct(
395
  return response
396
 
397
 
398
- # ==========================================
399
- # 4️⃣ 退款:POST /api/open/refund
400
- # ==========================================
401
- @router.post("/refund")
402
- async def open_refund(
403
- req: RefundRequest,
404
- x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
405
- db: Session = Depends(get_db),
406
- ):
407
- """
408
- 退款接口:将之前 OPEN_DEDUCT 的金额(部分或全额)退还到用户钱包。
409
-
410
- 校验:
411
- - 原交易必须存在且 tx_type == OPEN_DEDUCT
412
- - 原交易归属当前 token 用户
413
- - 原交易由当前插件发起(related_account == plugin_id)
414
- - 累计退款金额 ≤ 原扣款金额
415
- """
416
- plugin_info = _require_plugin(x_plugin_key, "refund", db)
417
- account = await _resolve_user_from_token(req.token)
418
  refund_amount = req.amount
419
  if isinstance(refund_amount, bool) or not isinstance(refund_amount, int) or refund_amount <= 0:
420
  raise HTTPException(status_code=422, detail="退款金额必须为正整数")
 
 
421
  try:
422
  wallet, plugin_row = lock_open_wallet(db, account, plugin_info["id"], x_plugin_key, "refund")
 
 
 
423
  original_tx = db.query(Transaction).filter(Transaction.tx_id == req.transaction_id).populate_existing().with_for_update().first()
424
  if original_tx is None:
425
  raise HTTPException(status_code=404, detail="原交易不存在")
@@ -447,7 +539,7 @@ async def open_refund(
447
  if isinstance(wallet.balance, bool) or not isinstance(wallet.balance, int):
448
  raise ValueError("invalid wallet balance")
449
  wallet.balance += refund_amount
450
- tx_id = f"OPEN_REFUND_{uuid.uuid4().hex}"
451
  last_tx = db.query(Transaction).filter(Transaction.account == account).order_by(
452
  Transaction.created_at.desc(), Transaction.tx_id.desc()).with_for_update().first()
453
  prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
@@ -456,6 +548,7 @@ async def open_refund(
456
  tx_hash=_calc_tx_hash(tx_id, account, "OPEN_REFUND", refund_amount, prev_hash),
457
  description=f"[{plugin_row.name}] 退款 {req.transaction_id}", source_plugin=plugin_info["id"]))
458
  response = {"success": True, "remaining_balance": wallet.balance, "transaction_id": tx_id}
 
459
  db.commit()
460
  except HTTPException:
461
  db.rollback()
@@ -468,35 +561,7 @@ async def open_refund(
468
  return response
469
 
470
 
471
- # ==========================================
472
- # 5️⃣ 消费记录查询:GET /api/open/transactions
473
- # ==========================================
474
- @router.get("/transactions")
475
- async def open_list_transactions(
476
- authorization: Optional[str] = Header(None, alias="Authorization"),
477
- x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
478
- page: int = Query(1, ge=1, description="页码(从 1 开始)"),
479
- page_size: int = Query(20, ge=1, le=100, description="每页条数(1-100)"),
480
- start_time: Optional[int] = Query(None, description="起始时间(unix 秒)"),
481
- end_time: Optional[int] = Query(None, description="结束时间(unix 秒)"),
482
- tx_type: Optional[str] = Query(None, description="交易类型,如 OPEN_DEDUCT / OPEN_REFUND"),
483
- db: Session = Depends(get_db),
484
- ):
485
- """
486
- 查询当前用户由当前插件产生的消费/退款记录。
487
-
488
- 认证:Authorization: Bearer <user_token> + X-Plugin-Key
489
- 安全:仅返回 source_plugin == 当前 plugin_id 的记录,防止跨插件读取。
490
- """
491
- plugin_info = _require_plugin(x_plugin_key, "verify", db)
492
-
493
- if not authorization:
494
- raise HTTPException(status_code=401, detail="缺少 Authorization Header")
495
- parts = authorization.split(" ", 1)
496
- if len(parts) != 2 or parts[0].lower() != "bearer":
497
- raise HTTPException(status_code=401, detail="Authorization 格式错误,请使用 Bearer Token")
498
- account = await _resolve_user_from_token(parts[1])
499
-
500
  plugin_id = plugin_info["id"]
501
 
502
  # 构造查询:按 source_plugin 过滤;兼容历史 NULL 数据走 related_account 兜底
@@ -546,25 +611,7 @@ async def open_list_transactions(
546
  }
547
 
548
 
549
- # ==========================================
550
- # 6️⃣ 消费汇总:GET /api/open/usage-summary
551
- # ==========================================
552
- @router.get("/usage-summary")
553
- async def open_usage_summary(
554
- x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
555
- account: Optional[str] = Query(None, description="指定用户账号,不传则统计所有用户"),
556
- period: str = Query("daily", description="聚合粒度:daily / monthly / total"),
557
- start_time: Optional[int] = Query(None, description="起始时间(unix 秒)"),
558
- end_time: Optional[int] = Query(None, description="结束时间(unix 秒)"),
559
- db: Session = Depends(get_db),
560
- ):
561
- """
562
- 本插件维度的消费汇总统计。
563
-
564
- 返回:总扣款 / 总退款 / 总笔数 / 净消费 + 按 period 分桶的明细。
565
- 安全:仅统计 source_plugin == 当前 plugin_id 的记录。
566
- """
567
- plugin_info = _require_plugin(x_plugin_key, "verify", db)
568
  plugin_id = plugin_info["id"]
569
 
570
  period = (period or "daily").lower()
@@ -638,3 +685,4 @@ async def open_usage_summary(
638
  },
639
  "breakdown": breakdown,
640
  }
 
 
 
1
  # router_open_api.py
2
  # ==========================================
3
  # 🌐 通用开放 API 模块(供第三方插件调用)
 
6
  # 供 NodeCraft AI 等第三方插件复用 ComfyUI-Ranking 账号体系。
7
  # 关联文件:
8
  # - 安全认证.py (verify_token_with_fallback, require_auth)
9
+ # - database_sql.py (SessionLocal)
10
  # - models_sql.py (Wallet, Transaction)
11
  # - 数据库连接.py (users.json 读写)
12
  # 安全机制:
 
24
  import logging
25
  from typing import Optional
26
 
27
+ from fastapi import APIRouter, Header, HTTPException, Query
28
  from fastapi.responses import JSONResponse
29
  from sqlalchemy.orm import Session
30
  from sqlalchemy import func, text
31
  from pydantic import BaseModel, Field
32
 
33
+ from database_sql import SessionLocal
34
  from models_sql import Wallet, Transaction, RegisteredPlugin, OpenApiRateLimitBucket
35
  from open_transaction import lock_open_wallet, positive_integer, validate_live_plugin, quota_total
36
+ from open_transaction import open_operation_identity, read_open_operation, save_open_operation
37
  from plugin_permissions import parse_permissions
38
  from tip_store import run_tip_effect
39
+ from auth_io import run_identity_io
40
+ from owned_thread import OwnedThreadGroup
41
  from 安全认证 import verify_token_with_fallback
42
  import 数据库连接 as json_db
43
 
 
46
  router = APIRouter(prefix="/api/open", tags=["open-api"])
47
 
48
 
49
+ MAX_OPEN_SQL_WORK = 8
50
+ _open_sql_work = OwnedThreadGroup(MAX_OPEN_SQL_WORK,
51
+ lambda: HTTPException(status_code=503, detail="插件限流服务暂不可用"))
52
+ start_open_sql = _open_sql_work.start
53
+ stop_open_sql = _open_sql_work.stop
54
+ close_open_sql = _open_sql_work.close
55
+
56
+
57
+ async def _run_open_sql(function, *args, **kwargs):
58
+ def execute():
59
+ with SessionLocal() as session:
60
+ return function(*args, db=session, **kwargs)
61
+ return await _open_sql_work.run(execute)
62
+
63
+
64
  # ═══════════════ 插件注册表(从数据库动态加载) ═══════════════
65
  # 反向索引:plugin_key -> {id, key, name, rate_limit, permissions, max_deduct_per_tx, max_deduct_per_day}
66
  _PLUGIN_KEY_INDEX: dict = {}
 
232
  """从 Token 中解析出账号;失败时抛 401"""
233
  if not token:
234
  raise HTTPException(status_code=401, detail="缺少用户 Token")
235
+ is_valid, account, error_msg = await run_identity_io(verify_token_with_fallback, token)
236
  if not is_valid or not account:
237
  raise HTTPException(status_code=401, detail=error_msg or "Token 无效")
238
  return account
 
256
  amount: int = Field(..., gt=0, strict=True, description="扣款金额(正整数)")
257
  reason: str = Field(..., description="业务标识/扣款原因")
258
  reference_id: Optional[str] = Field(None, description="业务侧参考 ID")
259
+ request_id: Optional[str] = Field(None, strict=True, min_length=8, max_length=128,
260
+ pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]*$", description="同次扣款重试使用相同请求标识")
261
 
262
 
263
  class RefundRequest(BaseModel):
264
  token: str = Field(..., description="用户 JWT Token")
265
  transaction_id: str = Field(..., description="原扣款交易 ID")
266
  amount: int = Field(..., gt=0, strict=True, description="退款金额(正整数)")
267
+ request_id: Optional[str] = Field(None, strict=True, min_length=8, max_length=128,
268
+ pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]*$", description="同次退款重试使用相同请求标识")
269
 
270
 
271
  # ==========================================
 
275
  async def open_verify_token(
276
  req: VerifyTokenRequest,
277
  x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
 
278
  ):
279
  """
280
  供第三方插件验证用户 Token 有效性。
281
  成功返回账号与基础资料(昵称、头像)。
282
  """
283
+ plugin_info = await _run_open_sql(_require_plugin, x_plugin_key, "verify")
284
 
285
+ is_valid, account, error_msg = await run_identity_io(verify_token_with_fallback, req.token)
286
  if not is_valid or not account:
287
  logger.info(f"OPEN_VERIFY_TOKEN_FAIL | plugin={plugin_info['id']} | category=invalid_token")
288
  return {"valid": False, "error": error_msg or "Token 无效"}
289
 
290
+ users_db = await run_identity_io(json_db.load_data, "users.json", default_data={})
291
  user = users_db.get(account, {}) or {}
292
 
293
  logger.info(f"OPEN_VERIFY_TOKEN | plugin={plugin_info['id']}")
 
306
  async def open_get_balance(
307
  authorization: Optional[str] = Header(None, alias="Authorization"),
308
  x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
 
309
  ):
310
  """
311
  查询用户钱包余额。
312
  认证:Authorization: Bearer <user_token> + X-Plugin-Key
313
  """
314
+ plugin_info = await _run_open_sql(_require_plugin, x_plugin_key, "balance")
315
 
316
  if not authorization:
317
  raise HTTPException(status_code=401, detail="缺少 Authorization Header")
 
320
  raise HTTPException(status_code=401, detail="Authorization 格式错误,请使用 Bearer Token")
321
 
322
  account = await _resolve_user_from_token(parts[1])
323
+ return await _run_open_sql(_read_open_balance, account, plugin_info)
324
+
325
+
326
+ # ==========================================
327
+ # 3️⃣ 通用扣款:POST /api/open/deduct
328
+ # ==========================================
329
+ @router.post("/deduct")
330
+ async def open_deduct(
331
+ req: DeductRequest,
332
+ x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
333
+ ):
334
+ """
335
+ 通用扣款接口(从 wallet.balance 扣减)。
336
+ 流程:
337
+ 1. 校验 Plugin Key + 权限 + 限流
338
+ 2. 解析 Token -> account
339
+ 3. 共用写锁锁定钱包与实时插件配置,检查额度/余额
340
+ 4. 扣款 + 写入 Transaction(含哈希链)
341
+ """
342
+ plugin_info = await _run_open_sql(_require_plugin, x_plugin_key, "deduct")
343
+ account = await _resolve_user_from_token(req.token)
344
+ return await _run_open_sql(_deduct_open_balance, req, account, plugin_info, x_plugin_key)
345
+
346
+
347
+ # ==========================================
348
+ # 4️⃣ 退款:POST /api/open/refund
349
+ # ==========================================
350
+ @router.post("/refund")
351
+ async def open_refund(
352
+ req: RefundRequest,
353
+ x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
354
+ ):
355
+ """
356
+ 退款接口:将之前 OPEN_DEDUCT 的金额(部分或全额)退还到用户钱包。
357
+
358
+ 校验:
359
+ - 原交易必须存在且 tx_type == OPEN_DEDUCT
360
+ - 原交易归属当前 token 用户
361
+ - 原交易由当前插件发起(related_account == plugin_id)
362
+ - 累计退款金额 ≤ 原扣款金额
363
+ """
364
+ plugin_info = await _run_open_sql(_require_plugin, x_plugin_key, "refund")
365
+ account = await _resolve_user_from_token(req.token)
366
+ return await _run_open_sql(_refund_open_balance, req, account, plugin_info, x_plugin_key)
367
+
368
+
369
+ # ==========================================
370
+ # 5️⃣ 消费记录查询:GET /api/open/transactions
371
+ # ==========================================
372
+ @router.get("/transactions")
373
+ async def open_list_transactions(
374
+ authorization: Optional[str] = Header(None, alias="Authorization"),
375
+ x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
376
+ page: int = Query(1, ge=1, description="页码(从 1 开始)"),
377
+ page_size: int = Query(20, ge=1, le=100, description="每页条数(1-100)"),
378
+ start_time: Optional[int] = Query(None, description="起始时间(unix 秒)"),
379
+ end_time: Optional[int] = Query(None, description="结束时间(unix 秒)"),
380
+ tx_type: Optional[str] = Query(None, description="交易类型,如 OPEN_DEDUCT / OPEN_REFUND"),
381
+ ):
382
+ """
383
+ 查询当前用户由当前插件产生的消费/退款记录。
384
+
385
+ 认证:Authorization: Bearer <user_token> + X-Plugin-Key
386
+ 安全:仅返回 source_plugin == 当前 plugin_id 的记录,防止跨插件读取。
387
+ """
388
+ plugin_info = await _run_open_sql(_require_plugin, x_plugin_key, "verify")
389
+
390
+ if not authorization:
391
+ raise HTTPException(status_code=401, detail="缺少 Authorization Header")
392
+ parts = authorization.split(" ", 1)
393
+ if len(parts) != 2 or parts[0].lower() != "bearer":
394
+ raise HTTPException(status_code=401, detail="Authorization 格式错误,请使用 Bearer Token")
395
+ account = await _resolve_user_from_token(parts[1])
396
+
397
+ return await _run_open_sql(_read_open_transactions, account, plugin_info, page, page_size, start_time, end_time, tx_type)
398
+
399
+
400
+ # ==========================================
401
+ # 6️⃣ 消费汇总:GET /api/open/usage-summary
402
+ # ==========================================
403
+ @router.get("/usage-summary")
404
+ async def open_usage_summary(
405
+ x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
406
+ account: Optional[str] = Query(None, description="指定用户账号,不传则统计所有用户"),
407
+ period: str = Query("daily", description="聚合粒度:daily / monthly / total"),
408
+ start_time: Optional[int] = Query(None, description="起始时间(unix 秒)"),
409
+ end_time: Optional[int] = Query(None, description="结束时间(unix 秒)"),
410
+ ):
411
+ """
412
+ 本插件维度的消费汇总统计。
413
+
414
+ 返回:总扣款 / 总退款 / 总笔数 / 净消费 + 按 period 分桶的明细。
415
+ 安全:仅统计 source_plugin == 当前 plugin_id 的记录。
416
+ """
417
+ plugin_info = await _run_open_sql(_require_plugin, x_plugin_key, "verify")
418
+ return await _run_open_sql(_read_open_usage, plugin_info, account, period, start_time, end_time)
419
+
420
+
421
+ def _read_open_balance(account, plugin_info, db):
422
  wallet = db.query(Wallet).filter(Wallet.account == account).first()
423
 
424
  if not wallet:
 
444
  return result
445
 
446
 
447
+ def _deduct_open_balance(req, account, plugin_info, x_plugin_key, db):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
448
  amount = req.amount
449
  if isinstance(amount, bool) or not isinstance(amount, int) or amount <= 0:
450
  raise HTTPException(status_code=422, detail="扣款金额必须为正整数")
451
+ operation = open_operation_identity(req.request_id, account, plugin_info['id'], 'OPEN_DEDUCT',
452
+ [amount, req.reason, req.reference_id])
453
  try:
454
  wallet, plugin_row = lock_open_wallet(db, account, plugin_info["id"], x_plugin_key, "deduct")
455
+ replay = read_open_operation(db, operation, account, plugin_info['id'], 'OPEN_DEDUCT', -amount, req.reference_id)
456
+ if replay is not None:
457
+ return replay
458
  max_per_tx = positive_integer(plugin_row.max_deduct_per_tx)
459
  max_daily = positive_integer(plugin_row.max_deduct_per_day)
460
  if amount > max_per_tx:
 
479
  if wallet.balance < amount:
480
  raise HTTPException(status_code=402, detail="余额不足")
481
  wallet.balance -= amount
482
+ tx_id = f"OPEN_DEDUCT_{operation[0] if operation is not None else uuid.uuid4().hex}"
483
  last_tx = db.query(Transaction).filter(Transaction.account == account).order_by(
484
  Transaction.created_at.desc(), Transaction.tx_id.desc()).with_for_update().first()
485
  prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
 
488
  tx_hash=_calc_tx_hash(tx_id, account, "OPEN_DEDUCT", -amount, prev_hash),
489
  description=f"[{plugin_row.name}] {req.reason}", source_plugin=plugin_info["id"]))
490
  response = {"success": True, "remaining_balance": wallet.balance, "transaction_id": tx_id}
491
+ save_open_operation(db, operation, response)
492
  db.commit()
493
  except HTTPException:
494
  db.rollback()
 
501
  return response
502
 
503
 
504
+ def _refund_open_balance(req, account, plugin_info, x_plugin_key, db):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
505
  refund_amount = req.amount
506
  if isinstance(refund_amount, bool) or not isinstance(refund_amount, int) or refund_amount <= 0:
507
  raise HTTPException(status_code=422, detail="退款金额必须为正整数")
508
+ operation = open_operation_identity(req.request_id, account, plugin_info['id'], 'OPEN_REFUND',
509
+ [refund_amount, req.transaction_id])
510
  try:
511
  wallet, plugin_row = lock_open_wallet(db, account, plugin_info["id"], x_plugin_key, "refund")
512
+ replay = read_open_operation(db, operation, account, plugin_info['id'], 'OPEN_REFUND', refund_amount, req.transaction_id)
513
+ if replay is not None:
514
+ return replay
515
  original_tx = db.query(Transaction).filter(Transaction.tx_id == req.transaction_id).populate_existing().with_for_update().first()
516
  if original_tx is None:
517
  raise HTTPException(status_code=404, detail="原交易不存在")
 
539
  if isinstance(wallet.balance, bool) or not isinstance(wallet.balance, int):
540
  raise ValueError("invalid wallet balance")
541
  wallet.balance += refund_amount
542
+ tx_id = f"OPEN_REFUND_{operation[0] if operation is not None else uuid.uuid4().hex}"
543
  last_tx = db.query(Transaction).filter(Transaction.account == account).order_by(
544
  Transaction.created_at.desc(), Transaction.tx_id.desc()).with_for_update().first()
545
  prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
 
548
  tx_hash=_calc_tx_hash(tx_id, account, "OPEN_REFUND", refund_amount, prev_hash),
549
  description=f"[{plugin_row.name}] 退款 {req.transaction_id}", source_plugin=plugin_info["id"]))
550
  response = {"success": True, "remaining_balance": wallet.balance, "transaction_id": tx_id}
551
+ save_open_operation(db, operation, response)
552
  db.commit()
553
  except HTTPException:
554
  db.rollback()
 
561
  return response
562
 
563
 
564
+ def _read_open_transactions(account, plugin_info, page, page_size, start_time, end_time, tx_type, db):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
565
  plugin_id = plugin_info["id"]
566
 
567
  # 构造查询:按 source_plugin 过滤;兼容历史 NULL 数据走 related_account 兜底
 
611
  }
612
 
613
 
614
+ def _read_open_usage(plugin_info, account, period, start_time, end_time, db):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
615
  plugin_id = plugin_info["id"]
616
 
617
  period = (period or "daily").lower()
 
685
  },
686
  "breakdown": breakdown,
687
  }
688
+
router_users_auth.py CHANGED
@@ -12,7 +12,6 @@
12
 
13
  from fastapi import APIRouter, HTTPException, BackgroundTasks, Request
14
  from rate_limit import shared_limit, store_verification_code, consume_verification_code
15
- import asyncio
16
  import time
17
  import re
18
  import secrets
@@ -21,6 +20,8 @@ import logging
21
  import 数据库连接 as db
22
  from models import UserRegister, UserLogin, SendCodeRequest
23
  from verify_code_engine import send_email_code, send_sms_code, cleanup_expired_codes, check_send_cooldown
 
 
24
 
25
  # 🔒 P0安全增强:导入密码哈希和 JWT 工具
26
  from 安全认证 import hash_password, verify_password, create_token, require_password_match, require_auth
@@ -59,7 +60,7 @@ async def _check_code_contact(req, request=None):
59
  account = await require_auth(request.headers.get("Authorization"))
60
  if req.account is not None and req.account != account:
61
  raise HTTPException(status_code=403, detail="无权为其他账号请求提现验证码")
62
- user = await asyncio.to_thread(read_user, account)
63
  contact = user.get("email") or user.get("phone", "")
64
  contact_type = "email" if user.get("email") else "phone"
65
  if (not isinstance(contact, str) or not contact.strip() or
@@ -67,12 +68,12 @@ async def _check_code_contact(req, request=None):
67
  raise HTTPException(status_code=400, detail="联系方式与当前账号绑定信息不匹配")
68
  return True
69
  if req.action_type == "recover":
70
- account, _ = await asyncio.to_thread(_find_contact_account, req.contact, req.contact_type)
71
  return account is not None
72
  if req.action_type == "reset":
73
  if not req.account:
74
  raise HTTPException(status_code=400, detail="找回密码需先填写当前账号")
75
- user = await asyncio.to_thread(read_user, req.account)
76
  if user.get(req.contact_type) != req.contact:
77
  raise HTTPException(status_code=400, detail="填写的联系方式与该账号绑定信息不匹配")
78
  return True
@@ -100,7 +101,7 @@ async def send_verify_code(request: Request, req: SendCodeRequest, bg_tasks: Bac
100
  return {"status": "success", "message": "如果该联系方式已绑定账号,验证码已发送"}
101
 
102
  # 🔒 检查发送频率限制(同一联系方式60秒内只能发送1条)
103
- check_send_cooldown(req.contact)
104
 
105
  # 生成6位随机验证码
106
  code = str(secrets.randbelow(900000) + 100000)
@@ -112,13 +113,13 @@ async def send_verify_code(request: Request, req: SendCodeRequest, bg_tasks: Bac
112
  cache_key = f"{req.contact}:{req.action_type}"
113
 
114
  # 将验证码安全摘要写入 SQL,有效期5分钟
115
- await asyncio.to_thread(store_verification_code, cache_key, code, int(time.time()) + 300)
116
 
117
  # 根据联系方式类型,添加后台发送任务
118
  if req.contact_type == "email":
119
- bg_tasks.add_task(send_email_code, req.contact, code, req.action_type)
120
  elif req.contact_type == "phone":
121
- bg_tasks.add_task(send_sms_code, req.contact, code, req.action_type)
122
  else:
123
  raise HTTPException(status_code=400, detail="不支持的验证方式")
124
 
@@ -138,7 +139,7 @@ async def send_code_api(request: Request, req: SendCodeRequest):
138
  return {"status": "success", "message": "如果该联系方式已绑定账号,验证码已发送"}
139
 
140
  # 🔒 检查发送频率限制(同一联系方式60秒内只能发送1条)
141
- check_send_cooldown(req.contact)
142
 
143
  # 生成6位随机验证码
144
  code = str(secrets.randbelow(900000) + 100000)
@@ -146,11 +147,11 @@ async def send_code_api(request: Request, req: SendCodeRequest):
146
  key = f"{req.contact}:{req.action_type}"
147
 
148
  # 存入缓存,有效期5分钟
149
- await asyncio.to_thread(store_verification_code, key, code, int(time.time()) + 300)
150
 
151
  if req.contact_type == "email":
152
  try:
153
- await asyncio.to_thread(send_email_code, req.contact, code, req.action_type)
154
  return {"status": "success", "message": "验证码已成功发送至邮箱"}
155
  except Exception as e:
156
  # 🔒 P0安全修复:仅服务端记录详细错误,不暴露给前端
@@ -159,7 +160,7 @@ async def send_code_api(request: Request, req: SendCodeRequest):
159
 
160
  elif req.contact_type == "phone":
161
  try:
162
- await asyncio.to_thread(send_sms_code, req.contact, code, req.action_type)
163
  return {"status": "success", "message": "验证码已成功发送至手机"}
164
  except Exception as error:
165
  logger.error("验证码短信发送失败: action=%s, error_type=%s", req.action_type, type(error).__name__)
@@ -209,11 +210,11 @@ async def recover_account(request: Request):
209
  # ========== 第二步:验证码校验(原子性一次性消费,并发安全) ==========
210
  cache_key = f"{email}:recover"
211
  # 🛡️ 防枚举:验证码错误与邮箱未注册统一报错,不暴露账号注册状态
212
- if not await asyncio.to_thread(consume_verification_code, cache_key, code):
213
  raise HTTPException(status_code=400, detail="验证码不正确或已过期")
214
 
215
  # ========== 第三步:按邮箱反查账号 ==========
216
- matched_account, matched_user = await asyncio.to_thread(_find_contact_account, email, "email")
217
 
218
  if not matched_account:
219
  # 理论上验证码已验证邮箱存在,此处为防御性分支
@@ -250,7 +251,7 @@ async def register_user(request: Request, user: UserRegister):
250
  - code: 验证码
251
  - intro: 个人介绍(可选,最多100字)
252
  """
253
- await asyncio.to_thread(check_registration, user.account, user.email, user.phone)
254
  RESERVED_ACCOUNTS = {"admin", "system", "root", "test", "anonymous", "administrator"}
255
  if user.account.lower() in RESERVED_ACCOUNTS:
256
  raise HTTPException(status_code=400, detail="该账号名不可用")
@@ -260,7 +261,7 @@ async def register_user(request: Request, user: UserRegister):
260
  cache_key = f"{user.email}:register" if user.email else f"{user.phone}:register"
261
  # 🔒 P0安全修复:验证码一次性使用,并发安全键防止并发重用
262
  # 校验验证码是否正确且未过期
263
- if not await asyncio.to_thread(consume_verification_code, cache_key, user.code):
264
  raise HTTPException(status_code=400, detail="验证码不正确或已过期")
265
 
266
  # ========== 第三步:格式校验 ==========
@@ -286,7 +287,7 @@ async def register_user(request: Request, user: UserRegister):
286
  new_user["avatarDataUrl"] = ""
287
 
288
  # 🔒 P0安全增强:密码哈希化存储(不再存储明文密码)
289
- new_user["password"] = await asyncio.to_thread(hash_password, new_user["password"])
290
 
291
  new_user.update({
292
  "created_at": int(time.time()), # 注册时间戳
@@ -296,7 +297,7 @@ async def register_user(request: Request, user: UserRegister):
296
  })
297
 
298
  # 保存到数据库
299
- public_user = await asyncio.to_thread(create_user, new_user)
300
  return {"status": "success", "message": "注册成功", "data": public_user}
301
 
302
 
@@ -317,18 +318,18 @@ async def login_user(request: Request, user: UserLogin):
317
  - account: 账号
318
  - password: 密码
319
  """
320
- user_data = await asyncio.to_thread(read_user, user.account)
321
  stored_password = user_data.get("password") or ""
322
  if not isinstance(stored_password, str):
323
  raise HTTPException(status_code=503, detail="用户认证数据暂不可用")
324
  password_version = get_password_version(user_data)
325
- if not await asyncio.to_thread(require_password_match, stored_password, user.password):
326
  raise HTTPException(status_code=401, detail="密码错误")
327
  migrated = None
328
  if stored_password and not stored_password.startswith(('$2b$', '$2a$')):
329
- migrated = await asyncio.to_thread(hash_password, user.password)
330
  # bcrypt 运行期间可能发生密码重置,不能用旧密码升级覆盖新密码。
331
- user_data = await asyncio.to_thread(finish_login, user.account, stored_password, password_version, migrated)
332
 
333
  # 🔒 P0安全增强:生成 JWT Token(替代 mock_token)
334
  # 获取password_version用于Token生成(如不存在则默认为0)
@@ -399,13 +400,13 @@ async def reset_password(request: Request):
399
  raise HTTPException(status_code=400, detail="请求参数格式不正确")
400
  if verify_type not in ("email", "phone"):
401
  raise HTTPException(status_code=400, detail="联系方式类型不正确")
402
- user = await asyncio.to_thread(read_user, account)
403
  if user.get(verify_type) != verify_contact:
404
  raise HTTPException(status_code=400, detail="填写的联系方式与该账号绑定信息不匹配")
405
 
406
  # 校验验证码(原子性获取+删除)
407
  cache_key = f"{verify_contact}:reset"
408
- if not await asyncio.to_thread(consume_verification_code, cache_key, code):
409
  raise HTTPException(status_code=400, detail="验证码不正确或已过期")
410
 
411
  # 校验新密码格式
@@ -414,9 +415,9 @@ async def reset_password(request: Request):
414
  if not re.match(r'^[a-zA-Z0-9!@#$%^&*()_+\-=\[\]{};\':"\\|,.<>\/?]{6,}$', new_password):
415
  raise HTTPException(status_code=400, detail="新密码包含不支持的特殊字符")
416
 
417
- password_hash = await asyncio.to_thread(hash_password, new_password)
418
  # 保存最新记录时再次检查绑定信息,版本严格递增(同秒重置也撤销旧凭证)。
419
- version = await asyncio.to_thread(reset_user_password, account, verify_type, verify_contact, password_hash)
420
  new_token = create_token(account, extra_data={"pwd_ver": version})
421
 
422
  return {"status": "success", "message": "密码修改成功", "token": new_token}
 
12
 
13
  from fastapi import APIRouter, HTTPException, BackgroundTasks, Request
14
  from rate_limit import shared_limit, store_verification_code, consume_verification_code
 
15
  import time
16
  import re
17
  import secrets
 
20
  import 数据库连接 as db
21
  from models import UserRegister, UserLogin, SendCodeRequest
22
  from verify_code_engine import send_email_code, send_sms_code, cleanup_expired_codes, check_send_cooldown
23
+ from verification_io import run_verification_io
24
+ from auth_io import run_auth_io
25
 
26
  # 🔒 P0安全增强:导入密码哈希和 JWT 工具
27
  from 安全认证 import hash_password, verify_password, create_token, require_password_match, require_auth
 
60
  account = await require_auth(request.headers.get("Authorization"))
61
  if req.account is not None and req.account != account:
62
  raise HTTPException(status_code=403, detail="无权为其他账号请求提现验证码")
63
+ user = await run_verification_io(read_user, account)
64
  contact = user.get("email") or user.get("phone", "")
65
  contact_type = "email" if user.get("email") else "phone"
66
  if (not isinstance(contact, str) or not contact.strip() or
 
68
  raise HTTPException(status_code=400, detail="联系方式与当前账号绑定信息不匹配")
69
  return True
70
  if req.action_type == "recover":
71
+ account, _ = await run_verification_io(_find_contact_account, req.contact, req.contact_type)
72
  return account is not None
73
  if req.action_type == "reset":
74
  if not req.account:
75
  raise HTTPException(status_code=400, detail="找回密码需先填写当前账号")
76
+ user = await run_verification_io(read_user, req.account)
77
  if user.get(req.contact_type) != req.contact:
78
  raise HTTPException(status_code=400, detail="填写的联系方式与该账号绑定信息不匹配")
79
  return True
 
101
  return {"status": "success", "message": "如果该联系方式已绑定账号,验证码已发送"}
102
 
103
  # 🔒 检查发送频率限制(同一联系方式60秒内只能发送1条)
104
+ await run_verification_io(check_send_cooldown, req.contact)
105
 
106
  # 生成6位随机验证码
107
  code = str(secrets.randbelow(900000) + 100000)
 
113
  cache_key = f"{req.contact}:{req.action_type}"
114
 
115
  # 将验证码安全摘要写入 SQL,有效期5分钟
116
+ await run_verification_io(store_verification_code, cache_key, code, int(time.time()) + 300)
117
 
118
  # 根据联系方式类型,添加后台发送任务
119
  if req.contact_type == "email":
120
+ bg_tasks.add_task(run_verification_io, send_email_code, req.contact, code, req.action_type)
121
  elif req.contact_type == "phone":
122
+ bg_tasks.add_task(run_verification_io, send_sms_code, req.contact, code, req.action_type)
123
  else:
124
  raise HTTPException(status_code=400, detail="不支持的验证方式")
125
 
 
139
  return {"status": "success", "message": "如果该联系方式已绑定账号,验证码已发送"}
140
 
141
  # 🔒 检查发送频率限制(同一联系方式60秒内只能发送1条)
142
+ await run_verification_io(check_send_cooldown, req.contact)
143
 
144
  # 生成6位随机验证码
145
  code = str(secrets.randbelow(900000) + 100000)
 
147
  key = f"{req.contact}:{req.action_type}"
148
 
149
  # 存入缓存,有效期5分钟
150
+ await run_verification_io(store_verification_code, key, code, int(time.time()) + 300)
151
 
152
  if req.contact_type == "email":
153
  try:
154
+ await run_verification_io(send_email_code, req.contact, code, req.action_type)
155
  return {"status": "success", "message": "验证码已成功发送至邮箱"}
156
  except Exception as e:
157
  # 🔒 P0安全修复:仅服务端记录详细错误,不暴露给前端
 
160
 
161
  elif req.contact_type == "phone":
162
  try:
163
+ await run_verification_io(send_sms_code, req.contact, code, req.action_type)
164
  return {"status": "success", "message": "验证码已成功发送至手机"}
165
  except Exception as error:
166
  logger.error("验证码短信发送失败: action=%s, error_type=%s", req.action_type, type(error).__name__)
 
210
  # ========== 第二步:验证码校验(原子性一次性消费,并发安全) ==========
211
  cache_key = f"{email}:recover"
212
  # 🛡️ 防枚举:验证码错误与邮箱未注册统一报错,不暴露账号注册状态
213
+ if not await run_auth_io(consume_verification_code, cache_key, code):
214
  raise HTTPException(status_code=400, detail="验证码不正确或已过期")
215
 
216
  # ========== 第三步:按邮箱反查账号 ==========
217
+ matched_account, matched_user = await run_auth_io(_find_contact_account, email, "email")
218
 
219
  if not matched_account:
220
  # 理论上验证码已验证邮箱存在,此处为防御性分支
 
251
  - code: 验证码
252
  - intro: 个人介绍(可选,最多100字)
253
  """
254
+ await run_auth_io(check_registration, user.account, user.email, user.phone)
255
  RESERVED_ACCOUNTS = {"admin", "system", "root", "test", "anonymous", "administrator"}
256
  if user.account.lower() in RESERVED_ACCOUNTS:
257
  raise HTTPException(status_code=400, detail="该账号名不可用")
 
261
  cache_key = f"{user.email}:register" if user.email else f"{user.phone}:register"
262
  # 🔒 P0安全修复:验证码一次性使用,并发安全键防止并发重用
263
  # 校验验证码是否正确且未过期
264
+ if not await run_auth_io(consume_verification_code, cache_key, user.code):
265
  raise HTTPException(status_code=400, detail="验证码不正确或已过期")
266
 
267
  # ========== 第三步:格式校验 ==========
 
287
  new_user["avatarDataUrl"] = ""
288
 
289
  # 🔒 P0安全增强:密码哈希化存储(不再存储明文密码)
290
+ new_user["password"] = await run_auth_io(hash_password, new_user["password"])
291
 
292
  new_user.update({
293
  "created_at": int(time.time()), # 注册时间戳
 
297
  })
298
 
299
  # 保存到数据库
300
+ public_user = await run_auth_io(create_user, new_user)
301
  return {"status": "success", "message": "注册成功", "data": public_user}
302
 
303
 
 
318
  - account: 账号
319
  - password: 密码
320
  """
321
+ user_data = await run_auth_io(read_user, user.account)
322
  stored_password = user_data.get("password") or ""
323
  if not isinstance(stored_password, str):
324
  raise HTTPException(status_code=503, detail="用户认证数据暂不可用")
325
  password_version = get_password_version(user_data)
326
+ if not await run_auth_io(require_password_match, stored_password, user.password):
327
  raise HTTPException(status_code=401, detail="密码错误")
328
  migrated = None
329
  if stored_password and not stored_password.startswith(('$2b$', '$2a$')):
330
+ migrated = await run_auth_io(hash_password, user.password)
331
  # bcrypt 运行期间可能发生密码重置,不能用旧密码升级覆盖新密码。
332
+ user_data = await run_auth_io(finish_login, user.account, stored_password, password_version, migrated)
333
 
334
  # 🔒 P0安全增强:生成 JWT Token(替代 mock_token)
335
  # 获取password_version用于Token生成(如不存在则默认为0)
 
400
  raise HTTPException(status_code=400, detail="请求参数格式不正确")
401
  if verify_type not in ("email", "phone"):
402
  raise HTTPException(status_code=400, detail="联系方式类型不正确")
403
+ user = await run_auth_io(read_user, account)
404
  if user.get(verify_type) != verify_contact:
405
  raise HTTPException(status_code=400, detail="填写的联系方式与该账号绑定信息不匹配")
406
 
407
  # 校验验证码(原子性获取+删除)
408
  cache_key = f"{verify_contact}:reset"
409
+ if not await run_auth_io(consume_verification_code, cache_key, code):
410
  raise HTTPException(status_code=400, detail="验证码不正确或已过期")
411
 
412
  # 校验新密码格式
 
415
  if not re.match(r'^[a-zA-Z0-9!@#$%^&*()_+\-=\[\]{};\':"\\|,.<>\/?]{6,}$', new_password):
416
  raise HTTPException(status_code=400, detail="新密码包含不支持的特殊字符")
417
 
418
+ password_hash = await run_auth_io(hash_password, new_password)
419
  # 保存最新记录时再次检查绑定信息,版本严格递增(同秒重置也撤销旧凭证)。
420
+ version = await run_auth_io(reset_user_password, account, verify_type, verify_contact, password_hash)
421
  new_token = create_token(account, extra_data={"pwd_ver": version})
422
 
423
  return {"status": "success", "message": "密码修改成功", "token": new_token}
shutdown_lifecycle.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """同一应用代次共用一个关闭拥有者和真实终态。"""
2
+ import asyncio
3
+ import threading
4
+
5
+
6
+ def prepare_application_shutdown(state):
7
+ previous = getattr(state, '_ranking_shutdown_future', None)
8
+ if previous is not None and not previous.done():
9
+ raise RuntimeError('application shutdown is still pending')
10
+ if getattr(state, '_ranking_shutdown_incomplete', False):
11
+ raise RuntimeError('application cleanup is incomplete')
12
+ state._ranking_shutdown_future = None
13
+ state._ranking_shutdown_owner = None
14
+ state._ranking_shutdown_cancelled = False
15
+ state._ranking_shutdown_stop = threading.Event()
16
+
17
+
18
+ def shutdown_cancel_requested(state):
19
+ if getattr(state, '_ranking_shutdown_cancelled', False):
20
+ return True
21
+ owner = getattr(state, '_ranking_shutdown_owner', None)
22
+ return owner is not None and owner.cancelling() > state._ranking_shutdown_initial_cancels
23
+
24
+
25
+ async def _wait_shutdown(state, future):
26
+ caller = asyncio.current_task()
27
+ initial_cancels = caller.cancelling()
28
+ cancelled = None
29
+ while not future.done():
30
+ try:
31
+ await asyncio.shield(future)
32
+ except asyncio.CancelledError as error:
33
+ if caller.cancelling() > initial_cancels:
34
+ if getattr(state, '_ranking_shutdown_future', None) is future:
35
+ state._ranking_shutdown_cancelled = True
36
+ cancelled = error
37
+ except BaseException:
38
+ break
39
+ if cancelled is not None:
40
+ raise cancelled
41
+ return future.result()
42
+
43
+
44
+ async def run_application_shutdown(state, operation):
45
+ owner = asyncio.current_task()
46
+ previous = getattr(state, '_ranking_shutdown_future', None)
47
+ retry = (previous is not None and previous.done()
48
+ and (previous.cancelled() or previous.exception() is not None))
49
+ if previous is not None and not retry:
50
+ if not previous.done() and getattr(state, '_ranking_shutdown_owner', None) is owner:
51
+ raise RuntimeError('application shutdown reentry')
52
+ return await _wait_shutdown(state, previous)
53
+ future = asyncio.get_running_loop().create_future()
54
+ stop = getattr(state, '_ranking_shutdown_stop', None)
55
+ if stop is None:
56
+ stop = threading.Event()
57
+ stop.set()
58
+ state._ranking_shutdown_stop = stop
59
+ state._ranking_shutdown_future = future
60
+ state._ranking_shutdown_owner = owner
61
+ state._ranking_shutdown_initial_cancels = owner.cancelling()
62
+ state._ranking_shutdown_cancelled = False
63
+ state._ranking_closers = getattr(state, '_ranking_closers', 0) + 1
64
+ try:
65
+ result = await operation()
66
+ if shutdown_cancel_requested(state):
67
+ raise asyncio.CancelledError
68
+ except BaseException as error:
69
+ if not future.done():
70
+ if isinstance(error, asyncio.CancelledError):
71
+ future.cancel()
72
+ else:
73
+ future.set_exception(error)
74
+ future.exception()
75
+ raise
76
+ else:
77
+ if not future.done():
78
+ future.set_result(result)
79
+ return result
80
+ finally:
81
+ state._ranking_shutdown_owner = None
82
+ state._ranking_closers -= 1
83
+
84
+
85
+ class ShutdownFailures:
86
+ def __init__(self, logger):
87
+ self.logger = logger
88
+ self.error = None
89
+ self.cancelled = None
90
+ self.incomplete = False
91
+
92
+ def _record(self, stage, error, *, incomplete):
93
+ self.incomplete |= incomplete
94
+ if isinstance(error, asyncio.CancelledError):
95
+ if self.cancelled is None:
96
+ self.cancelled = error
97
+ return
98
+ if self.error is None:
99
+ self.error = error
100
+ try:
101
+ self.logger.error('SHUTDOWN_STEP_FAILED | stage=%s | error_type=%s', stage, type(error).__name__)
102
+ except Exception:
103
+ pass
104
+
105
+ def call(self, stage, function, *args):
106
+ try:
107
+ return function(*args)
108
+ except asyncio.CancelledError as error:
109
+ self._record(stage, error, incomplete=True)
110
+ except Exception as error:
111
+ self._record(stage, error, incomplete=True)
112
+
113
+ async def wait(self, stage, function, *args):
114
+ try:
115
+ return await function(*args)
116
+ except asyncio.CancelledError as error:
117
+ self._record(stage, error, incomplete=False)
118
+ except Exception as error:
119
+ self._record(stage, error, incomplete=True)
120
+
121
+ def raise_if_failed(self, *, cancel_requested):
122
+ if self.cancelled is not None:
123
+ raise self.cancelled
124
+ if cancel_requested:
125
+ raise asyncio.CancelledError
126
+ if self.error is not None:
127
+ raise self.error
startup_warmup.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """启动预热的线程归属、停止信号与关闭等待。"""
2
+ import asyncio
3
+ import threading
4
+
5
+ from owned_thread import run_owned_thread
6
+
7
+
8
+ def prepare_startup_warmup(state):
9
+ previous = getattr(state, '_ranking_warmup_task', None)
10
+ if previous is not None and not previous.done():
11
+ raise RuntimeError('startup work is still pending')
12
+ state._ranking_warmup_stop = threading.Event()
13
+ state._ranking_warmup_shutdown = False
14
+ state._ranking_warmup_task = None
15
+
16
+
17
+ def startup_warmup_stopped(state):
18
+ stop = getattr(state, '_ranking_warmup_stop', None)
19
+ shutdown = getattr(state, '_ranking_shutdown_stop', None)
20
+ return (stop is not None and stop.is_set()) or (shutdown is not None and shutdown.is_set())
21
+
22
+
23
+ def stop_startup_warmup(state):
24
+ state._ranking_warmup_shutdown = True
25
+ stop = getattr(state, '_ranking_warmup_stop', None)
26
+ if stop is None:
27
+ stop = threading.Event()
28
+ state._ranking_warmup_stop = stop
29
+ stop.set()
30
+
31
+
32
+ def startup_shutdown_requested(state):
33
+ shutdown = getattr(state, '_ranking_shutdown_stop', None)
34
+ return getattr(state, '_ranking_warmup_shutdown', False) or (shutdown is not None and shutdown.is_set())
35
+
36
+
37
+ async def _wait_task(task, *, result, on_cancel=None):
38
+ owner = asyncio.current_task()
39
+ initial_cancels = owner.cancelling()
40
+ cancelled = None
41
+ while not task.done():
42
+ try:
43
+ await asyncio.shield(task)
44
+ except asyncio.CancelledError as error:
45
+ if owner.cancelling() > initial_cancels:
46
+ cancelled = error
47
+ if on_cancel is not None:
48
+ on_cancel()
49
+ except BaseException:
50
+ break
51
+ if not task.cancelled():
52
+ task.exception()
53
+ if cancelled is not None:
54
+ raise cancelled
55
+ if result:
56
+ return task.result()
57
+
58
+
59
+ async def run_startup_warmup(state, function):
60
+ if startup_warmup_stopped(state):
61
+ raise asyncio.CancelledError
62
+ previous = getattr(state, '_ranking_warmup_task', None)
63
+ if previous is not None and not previous.done():
64
+ raise RuntimeError('startup work is still pending')
65
+ coroutine = run_owned_thread(function)
66
+ try:
67
+ task = asyncio.create_task(coroutine)
68
+ except BaseException:
69
+ coroutine.close()
70
+ raise
71
+ state._ranking_warmup_task = task
72
+ try:
73
+ return await _wait_task(task, result=True, on_cancel=state._ranking_warmup_stop.set)
74
+ finally:
75
+ if startup_warmup_stopped(state):
76
+ raise asyncio.CancelledError
77
+
78
+
79
+ async def wait_startup_warmup(state):
80
+ task = getattr(state, '_ranking_warmup_task', None)
81
+ if task is not None:
82
+ await _wait_task(task, result=False)
verification_io.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """发码同步工作的有界准入与关闭等待。"""
2
+ from fastapi import HTTPException
3
+ from owned_thread import OwnedThreadGroup
4
+
5
+ MAX_VERIFICATION_IO = 8
6
+
7
+
8
+ class VerificationIO(OwnedThreadGroup):
9
+ def __init__(self, capacity=MAX_VERIFICATION_IO):
10
+ super().__init__(capacity, lambda: HTTPException(status_code=503, detail='验证码服务暂不可用'))
11
+
12
+
13
+ _verification_io = VerificationIO()
14
+ run_verification_io = _verification_io.run
15
+ start_verification_io = _verification_io.start
16
+ stop_verification_io = _verification_io.stop
17
+ close_verification_io = _verification_io.close
verify_code_engine.py CHANGED
@@ -32,21 +32,8 @@ _CLEANUP_INTERVAL = 300 # 5分钟清理一次
32
  # 作用:HF Spaces 的运行日志对外可见,验证码与联系方式明文入日志等同于泄露账号
33
 
34
 
35
- def _mask_contact(contact: str) -> str:
36
- """
37
- 🔒 联系方式脱敏(邮箱 / 手机号统一处理),仅用于日志输出
38
- 参数:contact - 原始邮箱或手机号
39
- 返回:脱敏后的字符串,保留足够辨识度以便排障,但不足以定位到具体用户
40
- """
41
- if not contact:
42
- return ""
43
- contact = str(contact)
44
- if "@" in contact:
45
- name, _, domain = contact.partition("@")
46
- return f"{name[:1]}***@{domain}" if len(name) > 1 else f"***@{domain}"
47
- if len(contact) >= 7:
48
- return contact[:3] + "****" + contact[-2:]
49
- return "***"
50
 
51
 
52
  def check_send_cooldown(contact: str):
@@ -91,6 +78,8 @@ def send_email_code(to_email: str, code: str, action: str):
91
  if not webhook_url:
92
  raise RuntimeError("verification_delivery_unavailable")
93
 
 
 
94
  # 根据动作类型生成不同的邮件标题
95
  if action == "register":
96
  action_str = "注册账号"
@@ -132,14 +121,13 @@ def send_email_code(to_email: str, code: str, action: str):
132
  with urllib.request.urlopen(req, timeout=10) as response:
133
  if not 200 <= response.status < 300:
134
  raise RuntimeError("verification_delivery_unconfirmed")
135
- # 🔒 安全修复:不再打印验证码与邮箱明文(日志对外可见),仅留脱敏标识与动作类型
136
- print(f"✅ 成功触发 Webhook 发送验证码邮件至 {_mask_contact(to_email)}(动作: {action})")
137
  except Exception as e:
138
  status = getattr(getattr(e, "response", None), "status_code", None)
139
  if not isinstance(status, int):
140
  status = getattr(e, "code", None)
141
  status_text = f", status={status}" if isinstance(status, int) and 100 <= status <= 599 else ""
142
- print(f"❌ Webhook 触发失败: action={action}, target={_mask_contact(to_email)}, error_type={type(e).__name__}{status_text}")
143
  raise RuntimeError("verification_delivery_unconfirmed") from None
144
 
145
 
@@ -157,6 +145,7 @@ def send_sms_code(phone: str, code: str, action: str):
157
  - 环境变量 ALIYUN_AK, ALIYUN_SK, ALIYUN_SIGN_NAME (阿里云配置)
158
  - router_users_auth.py 的 send_verify_code() 异步调用此函数
159
  """
 
160
  if action == "register":
161
  action_str = "注册账号"
162
  elif action == "withdraw":
@@ -192,13 +181,13 @@ def send_sms_code(phone: str, code: str, action: str):
192
  with urllib.request.urlopen(req, timeout=10) as response:
193
  if not 200 <= response.status < 300:
194
  raise RuntimeError("verification_delivery_unconfirmed")
195
- print(f"✅ Twilio 短信已成功下发至 {_mask_contact(phone)}")
196
  except Exception as e:
197
  status = getattr(getattr(e, "response", None), "status_code", None)
198
  if not isinstance(status, int):
199
  status = getattr(e, "code", None)
200
  status_text = f", status={status}" if isinstance(status, int) and 100 <= status <= 599 else ""
201
- print(f"❌ Twilio 发送失败: target={_mask_contact(phone)}, error_type={type(e).__name__}{status_text}")
202
  raise RuntimeError("verification_delivery_unconfirmed") from None
203
  return
204
 
@@ -240,12 +229,12 @@ def send_sms_code(phone: str, code: str, action: str):
240
  try:
241
  response = client.send_sms(send_req)
242
  if response.body.code == "OK":
243
- print(f"✅ 阿里云短信已成功下发至 {_mask_contact(phone)}")
244
  else:
245
- print(f"❌ 阿里云下发失败: target={_mask_contact(phone)}")
246
  raise RuntimeError("verification_delivery_unconfirmed")
247
  except Exception as e:
248
- print(f"❌ 阿里云请求异常: target={_mask_contact(phone)}, error_type={type(e).__name__}")
249
  raise RuntimeError("verification_delivery_unconfirmed") from None
250
  return
251
 
 
32
  # 作用:HF Spaces 的运行日志对外可见,验证码与联系方式明文入日志等同于泄露账号
33
 
34
 
35
+ def _delivery_log_action(action: str) -> str:
36
+ return action if type(action) is str and action in ("register", "reset", "recover", "withdraw") else "unknown"
 
 
 
 
 
 
 
 
 
 
 
 
 
37
 
38
 
39
  def check_send_cooldown(contact: str):
 
78
  if not webhook_url:
79
  raise RuntimeError("verification_delivery_unavailable")
80
 
81
+ log_action = _delivery_log_action(action)
82
+
83
  # 根据动作类型生成不同的邮件标题
84
  if action == "register":
85
  action_str = "注册账号"
 
121
  with urllib.request.urlopen(req, timeout=10) as response:
122
  if not 200 <= response.status < 300:
123
  raise RuntimeError("verification_delivery_unconfirmed")
124
+ print(f"✅ Webhook 验证码邮件发送成功: action={log_action}")
 
125
  except Exception as e:
126
  status = getattr(getattr(e, "response", None), "status_code", None)
127
  if not isinstance(status, int):
128
  status = getattr(e, "code", None)
129
  status_text = f", status={status}" if isinstance(status, int) and 100 <= status <= 599 else ""
130
+ print(f"❌ Webhook 触发失败: action={log_action}, error_type={type(e).__name__}{status_text}")
131
  raise RuntimeError("verification_delivery_unconfirmed") from None
132
 
133
 
 
145
  - 环境变量 ALIYUN_AK, ALIYUN_SK, ALIYUN_SIGN_NAME (阿里云配置)
146
  - router_users_auth.py 的 send_verify_code() 异步调用此函数
147
  """
148
+ log_action = _delivery_log_action(action)
149
  if action == "register":
150
  action_str = "注册账号"
151
  elif action == "withdraw":
 
181
  with urllib.request.urlopen(req, timeout=10) as response:
182
  if not 200 <= response.status < 300:
183
  raise RuntimeError("verification_delivery_unconfirmed")
184
+ print(f"✅ Twilio 短信发送成功: action={log_action}")
185
  except Exception as e:
186
  status = getattr(getattr(e, "response", None), "status_code", None)
187
  if not isinstance(status, int):
188
  status = getattr(e, "code", None)
189
  status_text = f", status={status}" if isinstance(status, int) and 100 <= status <= 599 else ""
190
+ print(f"❌ Twilio 发送失败: action={log_action}, error_type={type(e).__name__}{status_text}")
191
  raise RuntimeError("verification_delivery_unconfirmed") from None
192
  return
193
 
 
229
  try:
230
  response = client.send_sms(send_req)
231
  if response.body.code == "OK":
232
+ print(f"✅ 阿里云短信发送成功: action={log_action}")
233
  else:
234
+ print(f"❌ 阿里云下发失败: action={log_action}")
235
  raise RuntimeError("verification_delivery_unconfirmed")
236
  except Exception as e:
237
+ print(f"❌ 阿里云请求异常: action={log_action}, error_type={type(e).__name__}")
238
  raise RuntimeError("verification_delivery_unconfirmed") from None
239
  return
240
 
安全认证.py CHANGED
@@ -10,7 +10,6 @@
10
  # ==========================================
11
 
12
  import os
13
- import asyncio
14
  import math
15
  import hashlib
16
  import hmac
@@ -20,6 +19,7 @@ import time
20
  from typing import Optional, Tuple
21
  from fastapi import HTTPException, Header
22
  from user_store import read_user, get_password_version
 
23
 
24
  # 🔒 P0安全增强:引入bcrypt进行密码哈希
25
  import bcrypt
@@ -335,7 +335,7 @@ async def require_auth(authorization: str = Header(None, alias="Authorization"))
335
  token = parts[1]
336
 
337
  # 验证 Token(严格 JWT 验证,不再有降级信任)
338
- is_valid, account, error_msg = await asyncio.to_thread(verify_token_with_fallback, token)
339
 
340
  if not is_valid:
341
  raise HTTPException(status_code=401, detail=error_msg)
 
10
  # ==========================================
11
 
12
  import os
 
13
  import math
14
  import hashlib
15
  import hmac
 
19
  from typing import Optional, Tuple
20
  from fastapi import HTTPException, Header
21
  from user_store import read_user, get_password_version
22
+ from auth_io import run_identity_io
23
 
24
  # 🔒 P0安全增强:引入bcrypt进行密码哈希
25
  import bcrypt
 
335
  token = parts[1]
336
 
337
  # 验证 Token(严格 JWT 验证,不再有降级信任)
338
+ is_valid, account, error_msg = await run_identity_io(verify_token_with_fallback, token)
339
 
340
  if not is_valid:
341
  raise HTTPException(status_code=401, detail=error_msg)