ZHIWEI666 commited on
Commit
c8e5eaa
·
verified ·
1 Parent(s): ae7f0c4

3.0更新

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Dockerfile CHANGED
@@ -1,5 +1,5 @@
1
- # 🚀 P2优化:使用轻量级基镜像,从 900MB 降到 ~150MB
2
- FROM python:3.9-slim
3
 
4
  # 设置工作目录
5
  WORKDIR /code
@@ -13,8 +13,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
13
  && rm -rf /var/lib/apt/lists/*
14
 
15
  # 先复制依赖文件,利用 Docker 缓存层
16
- COPY ./requirements.txt /code/requirements.txt
17
- RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
 
 
18
 
19
  # 复制应用代码
20
  COPY . .
@@ -29,4 +31,4 @@ HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD python -c "import urllib
29
  # force rebuild: 2026-08-16
30
 
31
  # 启动命令
32
- CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
 
1
+ # 使用Python 3.11 slim基镜像;包含FFmpeg后的实际镜像体积需测量
2
+ FROM python:3.11-slim
3
 
4
  # 设置工作目录
5
  WORKDIR /code
 
13
  && rm -rf /var/lib/apt/lists/*
14
 
15
  # 先复制依赖文件,利用 Docker 缓存层
16
+ COPY ./requirements.txt ./requirements.lock ./requirements-build.lock /code/
17
+ RUN python -m pip install --no-cache-dir --require-hashes --only-binary=:all: -r /code/requirements-build.lock \
18
+ && python -m pip install --no-cache-dir --require-hashes --no-build-isolation -r /code/requirements.lock \
19
+ && python -m pip check
20
 
21
  # 复制应用代码
22
  COPY . .
 
31
  # force rebuild: 2026-08-16
32
 
33
  # 启动命令
34
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
app.py CHANGED
@@ -1,22 +1,22 @@
1
  # ⚙️ 后端逻辑/核心服务端.py (Hugging Face Spaces app.py)
2
  from fastapi import FastAPI, File, UploadFile, Form, Depends, HTTPException, Request
 
 
3
  from fastapi.middleware.cors import CORSMiddleware
4
  from fastapi.responses import Response, JSONResponse, FileResponse
5
  from sqlalchemy.orm import Session
6
  from pydantic import BaseModel
7
  from huggingface_hub import hf_hub_download, HfApi
8
- import hashlib
9
  import uuid
10
  import urllib.parse
11
  import urllib.request
12
  import urllib.error
13
  import os
14
  import json
15
- import mimetypes
16
  import logging
17
  import time
18
- import subprocess
19
  import datetime
 
20
  import 数据库连接 as db
21
  import asyncio
22
  from 安全认证 import require_auth
@@ -31,6 +31,8 @@ logging.basicConfig(
31
  datefmt='%Y-%m-%d %H:%M:%S'
32
  )
33
  logger = logging.getLogger("ComfyUI-Ranking")
 
 
34
 
35
 
36
  from 云端_定时版本检测引擎 import daily_version_check_task, reset_daily_views_task
@@ -62,15 +64,9 @@ from router_admin_plugins import router as admin_plugins_router # 🔒 管理
62
  from database_sql import init_sql_db, get_db, SessionLocal
63
  from models_sql import SystemConfig # 🧹 P2清理:Ownership 在本文件无引用
64
 
65
- # 🚀 P2优化:速率限制 (防止暴力攻击)
66
- from slowapi import Limiter, _rate_limit_exceeded_handler
67
- from slowapi.util import get_remote_address
68
- from slowapi.errors import RateLimitExceeded
69
  from fastapi.exceptions import RequestValidationError
70
  from starlette.exceptions import HTTPException as StarletteHTTPException
71
- import traceback
72
 
73
- limiter = Limiter(key_func=get_remote_address)
74
  app = FastAPI(
75
  title="ComfyUI Ranking API",
76
  description="ComfyUI 社区排名系统 API",
@@ -78,8 +74,6 @@ app = FastAPI(
78
  docs_url="/api/docs",
79
  redoc_url="/api/redoc"
80
  )
81
- app.state.limiter = limiter
82
- app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
83
 
84
 
85
  # ==========================================
@@ -107,7 +101,7 @@ def create_error_response(status_code: int, detail: str, error_type: str = None,
107
  @app.exception_handler(StarletteHTTPException)
108
  async def http_exception_handler(request, exc):
109
  """🚀 P3优化:HTTP 异常处理,返回统一格式"""
110
- logger.warning(f"HTTP {exc.status_code} | {request.url.path} | {exc.detail}")
111
 
112
  # 根据状态码确定错误类型
113
  error_type_map = {
@@ -117,6 +111,7 @@ async def http_exception_handler(request, exc):
117
  404: "not_found",
118
  409: "conflict",
119
  429: "rate_limited",
 
120
  }
121
  error_type = error_type_map.get(exc.status_code, "http_error")
122
 
@@ -125,16 +120,40 @@ async def http_exception_handler(request, exc):
125
  content=create_error_response(exc.status_code, str(exc.detail), error_type)
126
  )
127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  @app.exception_handler(RequestValidationError)
129
  async def validation_exception_handler(request, exc):
130
  """🚀 P3优化:请求参数校验失败处理"""
131
- logger.warning(f"Validation Error | {request.url.path} | {exc.errors()}")
 
 
 
 
 
 
132
 
133
  # 提取简洁的错误信息
134
  errors = []
135
  for err in exc.errors():
136
  field = ".".join(str(loc) for loc in err["loc"][1:]) # 跳过 'body'
137
- errors.append({"field": field, "message": err["msg"], "type": err["type"]})
138
 
139
  return JSONResponse(
140
  status_code=422,
@@ -150,8 +169,7 @@ async def validation_exception_handler(request, exc):
150
  async def global_exception_handler(request, exc):
151
  """🚀 P3优化:全局异常处理,捕获所有未预期异常"""
152
  error_id = f"ERR_{int(time.time())}_{id(exc) % 10000}"
153
- logger.error(f"Unhandled Exception [{error_id}] | {request.url.path} | {type(exc).__name__}: {exc}")
154
- logger.error(traceback.format_exc())
155
  return JSONResponse(
156
  status_code=500,
157
  content=create_error_response(
@@ -187,7 +205,8 @@ def detailed_health_check():
187
  health_status["components"]["sql_database"] = "error: connection failed"
188
  health_status["status"] = "degraded"
189
  except Exception as e:
190
- health_status["components"]["sql_database"] = f"error: {str(e)}"
 
191
  health_status["status"] = "degraded"
192
 
193
  # 检查 JSON 文件访问
@@ -196,7 +215,8 @@ def detailed_health_check():
196
  json_db.load_data("users.json", default_data={})
197
  health_status["components"]["json_storage"] = "ok"
198
  except Exception as e:
199
- health_status["components"]["json_storage"] = f"error: {str(e)}"
 
200
  health_status["status"] = "degraded"
201
 
202
  return health_status
@@ -214,7 +234,7 @@ async def on_startup():
214
  init_sql_db()
215
  logger.info("✅ SQL 数据库初始化完成")
216
  except Exception as e:
217
- logger.error(f"❌ SQL 数据库初始化失败: {e}")
218
  # 不抛出异常,允许降级运行
219
 
220
  # ========== 加载第三方插件注册表(依赖 SQL 数据库) ==========
@@ -223,7 +243,7 @@ async def on_startup():
223
  load_plugins_from_db()
224
  logger.info("✅ 第三方插件注册表已加载")
225
  except Exception as e:
226
- logger.warning(f"⚠️ 第三方插件注册表加载失败: {e}")
227
 
228
  # ========== 预热检查:JSON 数据库 ==========
229
  try:
@@ -231,8 +251,26 @@ async def on_startup():
231
  db.load_data("items.json", default_data=[])
232
  logger.info("✅ JSON 数据库访问正常")
233
  except Exception as e:
234
- logger.warning(f"⚠️ JSON 数据库访问异常: {e}")
235
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  # ========== 启动后台任务 ==========
237
  asyncio.create_task(daily_version_check_task())
238
  logger.info("✅ 定时版本检测任务已挂载")
@@ -242,7 +280,7 @@ async def on_startup():
242
 
243
  # ========== 启动 HF 批量同步定时器 ==========
244
  db.start_batch_sync()
245
- logger.info("✅ HF 批量同步定时器已启动")
246
 
247
  logger.info("🎉 ComfyUI-Ranking API 启动完成!")
248
 
@@ -252,9 +290,30 @@ async def on_shutdown():
252
  """优雅关闭,清理资源"""
253
  logger.info("🛑 ComfyUI-Ranking API 正在关闭...")
254
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
  # ========== 关闭前同步所有脏文件 ==========
256
- db.flush_sync()
257
- logger.info("✅ HF 批量同步已完成")
258
 
259
  # 这里可以添加其他清理逻辑(如关闭连接池等)
260
  logger.info("✅ 关闭完成")
@@ -264,7 +323,10 @@ async def on_shutdown():
264
  # 传递 Token 且 allow_credentials=False 不依赖 Cookie,通配风险可控)。
265
  # 如需收紧可在环境变量 ALLOWED_ORIGINS 中逗号分隔指定域名。
266
  ALLOWED_ORIGINS = os.environ.get("ALLOWED_ORIGINS", "*").split(",")
267
- logger.info(f"CORS 允许域名: {ALLOWED_ORIGINS}")
 
 
 
268
 
269
  app.add_middleware(
270
  CORSMiddleware,
@@ -272,6 +334,7 @@ app.add_middleware(
272
  allow_credentials=False,
273
  allow_methods=["*"],
274
  allow_headers=["*"],
 
275
  )
276
 
277
  # ==========================================
@@ -298,46 +361,12 @@ app.include_router(admin_plugins_router) # 🔒 管理后台 - 第三方插件
298
  # 🟢 私有图床代理中心 (Image Proxy)
299
  # 解决 Private 仓库下,本地客户端报 401 Unauthorized 的终极方案
300
  # ==========================================
 
 
301
  @app.get("/api/image_proxy")
302
  def proxy_hf_image(url: str = None, path: str = None):
303
- """云端图片代理:使用云端的 HF_TOKEN 提取私有图床图片并返回给没有任何权限的本地端"""
304
-
305
- # 兼容处理:如果本地发来的是已经被污染的老版本 HF 直链,我们自动将其转换为相对路径
306
- if url and url.startswith("https://huggingface.co/datasets/"):
307
- try:
308
- path = url.split("resolve/main/")[-1]
309
- path = urllib.parse.unquote(path)
310
- except Exception:
311
- raise HTTPException(status_code=400, detail="无效的 HF 原链接格式")
312
-
313
- if not path:
314
- raise HTTPException(status_code=400, detail="缺少路径参数")
315
-
316
- # 🛡️ 路径穿越防护:禁止包含 .. 和绝对路径
317
- if ".." in path or path.startswith("/"):
318
- raise HTTPException(status_code=400, detail="非法路径")
319
-
320
- # 🛡️ 绝对的安全红线:限制只能代理下载图片目录,严禁黑客通过此接口下载 users.json 或账本数据!
321
- allowed_dirs = ["uploads/", "avatars/", "covers/"]
322
- if not any(path.startswith(d) for d in allowed_dirs):
323
- raise HTTPException(status_code=403, detail="非法访问:该接口仅允许代理图片资源")
324
-
325
- hf_token = os.environ.get("HF_TOKEN")
326
- dataset_repo_id = db.DATASET_REPO_ID
327
-
328
- try:
329
- # hf_hub_download 会自动利用云端容器的缓存,只有第一次会去真实请求 Dataset
330
- cached_file_path = hf_hub_download(
331
- repo_id=dataset_repo_id,
332
- repo_type="dataset",
333
- filename=path,
334
- token=hf_token
335
- )
336
- # 智能识别文件类型 (image/jpeg, image/png 等)
337
- content_type, _ = mimetypes.guess_type(cached_file_path)
338
- return FileResponse(cached_file_path, media_type=content_type or "application/octet-stream")
339
- except Exception as e:
340
- return JSONResponse(content={"error": f"代理获取图片失败: {str(e)}"}, status_code=404)
341
 
342
  # ==========================================
343
  # 上传接口 (将返回的 URL 替换为 Proxy 代理链接)
@@ -348,148 +377,18 @@ from image_moderation import moderate_image_sync, ModerationResult
348
  # ==========================================
349
  # 🎵 音频安全净化:重编码压缩,剥离元数据与隐藏附加信息
350
  # ==========================================
351
- AUDIO_TRANSCODE_ARGS = {
352
- "mp3": ["-acodec", "libmp3lame", "-b:a", "192k"],
353
- "wav": ["-acodec", "pcm_s16le", "-ar", "44100"],
354
- "m4a": ["-acodec", "aac", "-b:a", "192k"],
355
- "ogg": ["-acodec", "libvorbis", "-b:a", "192k"],
356
- }
357
-
358
- def sanitize_audio_content(content: bytes, ext: str) -> bytes:
359
- """🔒 用 ffmpeg 重编码压缩音频并剥离元数据/隐藏附加信息;处理失败一律拒绝上传,保障平台安全"""
360
- src_path = f"/tmp/audio_src_{uuid.uuid4().hex[:8]}.{ext}"
361
- dst_path = f"/tmp/audio_dst_{uuid.uuid4().hex[:8]}.{ext}"
362
- try:
363
- with open(src_path, "wb") as f:
364
- f.write(content)
365
- cmd = ["ffmpeg", "-y", "-i", src_path, "-vn", "-map_metadata", "-1", "-fflags", "+bitexact"] \
366
- + AUDIO_TRANSCODE_ARGS[ext] + [dst_path]
367
- try:
368
- result = subprocess.run(cmd, capture_output=True, timeout=120)
369
- except subprocess.TimeoutExpired:
370
- logger.error(f"AUDIO_SANITIZE_TIMEOUT | ext={ext}")
371
- raise HTTPException(status_code=400, detail="音频处理超时,请上传更短的音频")
372
- if result.returncode != 0:
373
- logger.error(f"AUDIO_SANITIZE_FAIL | ext={ext} | {result.stderr[-500:]}")
374
- raise HTTPException(status_code=400, detail="音频文件解析失败,请转换格式后重新上传")
375
- with open(dst_path, "rb") as f:
376
- sanitized = f.read()
377
- if not sanitized:
378
- raise HTTPException(status_code=400, detail="音频处理结果为空,请重新上传")
379
- logger.info(f"AUDIO_SANITIZE_OK | ext={ext} | {len(content)} -> {len(sanitized)} bytes")
380
- return sanitized
381
- finally:
382
- for p in (src_path, dst_path):
383
- if os.path.exists(p):
384
- os.remove(p)
385
-
386
 
387
  @app.post("/api/upload")
388
- @limiter.limit("30/minute") # 🔒 安全限流:防止刷存储(单次发布最多 9 图 + 1 JSON,阈值留足余量)
389
  def upload_file(request: Request, file: UploadFile = File(...), file_type: str = Form(...), current_user: str = Depends(require_auth)):
390
- # 🟢 动态文件大小风控
391
- # 🔒 安全修复:上限必须先算出来。原实现是先把整个文件 read() 进内存再判大小,
392
- # 超大文件会直接吃满容器内存(可被用于打挂服务),现改为分块读取、超限立即中止。
393
- max_size = 10 * 1024 * 1024
394
- if file_type == "avatar":
395
- max_size = 2 * 1024 * 1024
396
- elif file_type == "cover":
397
- # GIF 动图允许更大文件(前端不压缩,保留动画)
398
- if file.filename and file.filename.lower().endswith('.gif'):
399
- max_size = 50 * 1024 * 1024 # GIF: 50MB
400
- else:
401
- max_size = 5 * 1024 * 1024 # 其他图片: 5MB
402
- elif file_type == "banner":
403
- # 广告横幅图片:支持GIF动图
404
- if file.filename and file.filename.lower().endswith('.gif'):
405
- max_size = 50 * 1024 * 1024 # GIF: 50MB
406
- else:
407
- max_size = 10 * 1024 * 1024 # 其他图片: 10MB
408
- elif file_type == "post_video":
409
- max_size = 100 * 1024 * 1024
410
- elif file_type == "post_audio":
411
- max_size = 30 * 1024 * 1024 # 音乐提示词音频:30MB
412
-
413
- # 分块读取(1MB / 块),累计超限则马上报错,不再继续消耗内存与带宽
414
- _CHUNK_SIZE = 1024 * 1024
415
- _chunks = []
416
- _received = 0
417
- while True:
418
- _chunk = file.file.read(_CHUNK_SIZE)
419
- if not _chunk:
420
- break
421
- _received += len(_chunk)
422
- if _received > max_size:
423
- raise HTTPException(status_code=400, detail=f"文件过大,{file_type} 类型请限制在 {max_size // (1024*1024)}MB 以内")
424
- _chunks.append(_chunk)
425
- content = b"".join(_chunks)
426
-
427
- if not file.filename:
428
- raise HTTPException(status_code=400, detail="文件名不能为空")
429
- ext = file.filename.split(".")[-1].lower()
430
- allowed_exts = ["jpg", "jpeg", "png", "gif", "webp", "json", "mp4"]
431
- if file_type == "post_video":
432
- allowed_exts = ["mp4", "webm", "mov"]
433
- elif file_type == "post_audio":
434
- allowed_exts = ["mp3", "wav", "m4a", "ogg"]
435
- if ext not in allowed_exts:
436
- raise HTTPException(status_code=400, detail=f"不支持的文件格式,{file_type} 类型仅支持 {', '.join(allowed_exts)}")
437
-
438
- # 🔒 音频安全净化:重编码压缩,剥离可能附带的元数据与隐藏附加信息
439
- if file_type == "post_audio":
440
- content = sanitize_audio_content(content, ext)
441
- if len(content) > max_size:
442
- raise HTTPException(status_code=400, detail=f"音频处理后仍超过 {max_size // (1024*1024)}MB 限制,请上传更短的音频")
443
-
444
- # 📝 审计日志:记录上传操作
445
- logger.info(f"UPLOAD | account={current_user} | filename={file.filename} | file_type={file_type}")
446
-
447
- # 🔒 P0安全优化:图片内容审核
448
- if ext in ["jpg", "jpeg", "png", "gif", "webp"]:
449
- moderation_result = moderate_image_sync(content, ext)
450
- if not moderation_result.passed:
451
- raise HTTPException(
452
- status_code=400,
453
- detail=f"图片内容审核未通过:{moderation_result.label or '内容不合规'}"
454
- )
455
-
456
- if file_type in ("post_video", "post_audio"):
457
- safe_filename = f"{file_type}_{uuid.uuid4().hex[:8]}.{ext}"
458
- else:
459
- file_hash = hashlib.md5(content).hexdigest()[:10]
460
- safe_filename = f"{file_type}_{file_hash}.{ext}"
461
-
462
- local_tmp_path = f"/tmp/{safe_filename}"
463
- with open(local_tmp_path, "wb") as f:
464
- f.write(content)
465
-
466
- hf_token = os.environ.get("HF_TOKEN")
467
- dataset_repo_id = "ZHIWEI666/ComfyUI-Ranking"
468
-
469
- try:
470
- api = HfApi()
471
- api.upload_file(
472
- path_or_fileobj=local_tmp_path,
473
- path_in_repo=f"uploads/{file_type}/{safe_filename}",
474
- repo_id=dataset_repo_id,
475
- repo_type="dataset",
476
- token=hf_token,
477
- commit_message=f"Upload media: {safe_filename}"
478
- )
479
- except Exception as e:
480
- raise HTTPException(status_code=500, detail=f"图床同步失败: {str(e)}")
481
- finally:
482
- if os.path.exists(local_tmp_path):
483
- os.remove(local_tmp_path)
484
-
485
- # 🚀 核心修复:不再返回暴露隐私且报 401 的 HF 直链,而是返回我们刚写好的 Proxy 代理链接
486
- if file_type == "post_video":
487
- # 视频文件返回相对路径,由前端 proxyImages 统一处理
488
- # 前端会将其转换为 /community_hub/video 本地视频代理(支持 Range 请求和流式传输)
489
- permanent_url = f"uploads/{file_type}/{safe_filename}"
490
- else:
491
- permanent_url = f"https://zhiwei666-comfyui-ranking-api.hf.space/api/image_proxy?path=uploads/{file_type}/{safe_filename}"
492
- return {"status": "success", "url": permanent_url}
493
 
494
 
495
  class ValidateResourceRequest(BaseModel):
@@ -497,75 +396,14 @@ class ValidateResourceRequest(BaseModel):
497
  item_id: str
498
  account: str
499
 
500
- @app.post("/api/validate_resource")
501
- def validate_resource(req_data: ValidateResourceRequest, sql_db: Session = Depends(get_db)):
502
- target_url = req_data.url
503
-
504
- # 允许的链接前缀:HuggingFace数据集、GitHub仓库、内部代理URL
505
- ALLOWED_URL_PREFIXES = (
506
- "https://huggingface.co/datasets/",
507
- "https://github.com/",
508
- "https://zhiwei666-comfyui-ranking-api.hf.space/api/image_proxy",
509
- )
510
-
511
- if not any(target_url.startswith(prefix) for prefix in ALLOWED_URL_PREFIXES):
512
- return JSONResponse(content={"error": "无效的下载链接"}, status_code=400)
513
-
514
- items_db = db.load_data("items.json", default_data=[])
515
- item = next((i for i in items_db if i["id"] == req_data.item_id), None)
516
-
517
- if not item: return JSONResponse(content={"error": "资源不存在或已被删除"}, status_code=404)
518
-
519
- # 内部代理URL直接返回成功(文件已存在于服务器本地)
520
- if target_url.startswith("https://zhiwei666-comfyui-ranking-api.hf.space/api/image_proxy"):
521
- return {"status": "success", "message": "资源有效"}
522
-
523
- hf_token = os.environ.get("HF_TOKEN")
524
- if not hf_token: return JSONResponse(content={"error": "云端环境变量未配置 HF_TOKEN"}, status_code=401)
525
-
526
- try:
527
- if target_url.startswith("https://github.com/"):
528
- creator_token = item.get("github_token")
529
- fallback_token = os.environ.get("GITHUB_PAT")
530
- active_token = creator_token if creator_token else fallback_token
531
-
532
- headers = {"User-Agent": "ComfyUI-Ranking-SaaS"}
533
- if active_token:
534
- headers["Authorization"] = f"Bearer {active_token}"
535
-
536
- repo_parts = target_url.rstrip("/").split("/")
537
- if len(repo_parts) < 2: return JSONResponse(content={"error": "无效的仓库地址格式"}, status_code=400)
538
- owner, repo = repo_parts[-2], repo_parts[-1]
539
- api_url = f"https://api.github.com/repos/{owner}/{repo}"
540
-
541
- req = urllib.request.Request(api_url, headers=headers)
542
- with urllib.request.urlopen(req) as response:
543
- if response.status != 200:
544
- return JSONResponse(content={"error": "资源仓库不可访问,可能已被作者删除或设为私有"}, status_code=404)
545
- return {"status": "success", "message": "资源有效"}
546
-
547
- elif target_url.startswith("https://huggingface.co/datasets/"):
548
- repo_path_encoded = target_url.split("resolve/main/")[-1]
549
- repo_path = urllib.parse.unquote(repo_path_encoded)
550
-
551
- cached_file_path = hf_hub_download(
552
- repo_id=db.DATASET_REPO_ID,
553
- repo_type="dataset",
554
- filename=repo_path,
555
- token=hf_token
556
- )
557
- if not os.path.exists(cached_file_path):
558
- return JSONResponse(content={"error": "云端文件不存在,可能已被作者删除"}, status_code=404)
559
-
560
- return {"status": "success", "message": "资源有效"}
561
-
562
- except urllib.error.HTTPError as e:
563
- return JSONResponse(content={"error": f"资源探测失败,源站返回: {e.code}。请联系作者处理。"}, status_code=400)
564
- except Exception as e:
565
- return JSONResponse(content={"error": f"资源探测异常: {str(e)}"}, status_code=500)
566
-
567
- return {"status": "success"}
568
 
 
 
 
 
 
569
 
570
  # ==========================================
571
  # 🔒 管理员:系统配置 API
@@ -592,7 +430,7 @@ def _load_system_config_from_db(db: Session) -> dict:
592
  result[cfg.config_key] = cfg.config_value
593
  return result
594
  except Exception as e:
595
- logger.warning(f"从数据库加载系统配置失败,降级到文件: {e}")
596
  return _load_system_config_from_file()
597
 
598
  def _load_system_config_from_file() -> dict:
@@ -602,7 +440,7 @@ def _load_system_config_from_file() -> dict:
602
  with open(SYSTEM_CONFIG_FILE, "r") as f:
603
  return json.load(f)
604
  except Exception as e:
605
- logger.debug(f"加载系统配置文件失败: {e}")
606
  return {}
607
 
608
  def _save_system_config_to_db(db: Session, config_key: str, value, updated_by: str = None):
@@ -618,7 +456,7 @@ def _save_system_config_to_db(db: Session, config_key: str, value, updated_by: s
618
  db.commit()
619
  except Exception as e:
620
  db.rollback()
621
- logger.warning(f"保存系统配置到数据库失败,降级到文件: {e}")
622
  # 降级:保存到文件
623
  config = _load_system_config_from_file()
624
  config[config_key] = value
@@ -626,7 +464,7 @@ def _save_system_config_to_db(db: Session, config_key: str, value, updated_by: s
626
  with open(SYSTEM_CONFIG_FILE, "w") as f:
627
  json.dump(config, f, ensure_ascii=False)
628
  except Exception as fe:
629
- logger.warning(f"降级保存系统配置文件也失败: {fe}")
630
 
631
  # 兼容旧调用的包装函数
632
  def _load_system_config(db: Session = None) -> dict:
 
1
  # ⚙️ 后端逻辑/核心服务端.py (Hugging Face Spaces app.py)
2
  from fastapi import FastAPI, File, UploadFile, Form, Depends, HTTPException, Request
3
+ from rate_limit import shared_limit
4
+ from response_privacy import requires_no_store
5
  from fastapi.middleware.cors import CORSMiddleware
6
  from fastapi.responses import Response, JSONResponse, FileResponse
7
  from sqlalchemy.orm import Session
8
  from pydantic import BaseModel
9
  from huggingface_hub import hf_hub_download, HfApi
 
10
  import uuid
11
  import urllib.parse
12
  import urllib.request
13
  import urllib.error
14
  import os
15
  import json
 
16
  import logging
17
  import time
 
18
  import datetime
19
+ import threading
20
  import 数据库连接 as db
21
  import asyncio
22
  from 安全认证 import require_auth
 
31
  datefmt='%Y-%m-%d %H:%M:%S'
32
  )
33
  logger = logging.getLogger("ComfyUI-Ranking")
34
+ _UPLOAD_CONCURRENCY = max(1, min(int(os.environ.get("MAX_CONCURRENT_UPLOADS", "4")), 16))
35
+ _UPLOAD_SEMAPHORE = threading.BoundedSemaphore(_UPLOAD_CONCURRENCY)
36
 
37
 
38
  from 云端_定时版本检测引擎 import daily_version_check_task, reset_daily_views_task
 
64
  from database_sql import init_sql_db, get_db, SessionLocal
65
  from models_sql import SystemConfig # 🧹 P2清理:Ownership 在本文件无引用
66
 
 
 
 
 
67
  from fastapi.exceptions import RequestValidationError
68
  from starlette.exceptions import HTTPException as StarletteHTTPException
 
69
 
 
70
  app = FastAPI(
71
  title="ComfyUI Ranking API",
72
  description="ComfyUI 社区排名系统 API",
 
74
  docs_url="/api/docs",
75
  redoc_url="/api/redoc"
76
  )
 
 
77
 
78
 
79
  # ==========================================
 
101
  @app.exception_handler(StarletteHTTPException)
102
  async def http_exception_handler(request, exc):
103
  """🚀 P3优化:HTTP 异常处理,返回统一格式"""
104
+ logger.warning(f"HTTP {exc.status_code} | {request.url.path} | detail_type={type(exc.detail).__name__}")
105
 
106
  # 根据状态码确定错误类型
107
  error_type_map = {
 
111
  404: "not_found",
112
  409: "conflict",
113
  429: "rate_limited",
114
+ 502: "upstream_error",
115
  }
116
  error_type = error_type_map.get(exc.status_code, "http_error")
117
 
 
120
  content=create_error_response(exc.status_code, str(exc.detail), error_type)
121
  )
122
 
123
+ from upload_body_guard import UploadBodyGuard
124
+ app.add_middleware(UploadBodyGuard, capacity=_UPLOAD_CONCURRENCY, error_response=create_error_response)
125
+
126
+ from public_list_etag import PublicListETag
127
+ app.add_middleware(PublicListETag)
128
+
129
+ @app.middleware("http")
130
+ async def private_response_cache_control(request: Request, call_next):
131
+ response = await call_next(request)
132
+ if requires_no_store(request.url.path, request.headers.get("Authorization")):
133
+ response.headers["Cache-Control"] = "no-store"
134
+ vary = [name.strip() for name in response.headers.get("Vary", "").split(",") if name.strip()]
135
+ if not any(name.lower() in {"authorization", "*"} for name in vary):
136
+ vary.append("Authorization")
137
+ response.headers["Vary"] = ", ".join(vary)
138
+ return response
139
+
140
+
141
  @app.exception_handler(RequestValidationError)
142
  async def validation_exception_handler(request, exc):
143
  """🚀 P3优化:请求参数校验失败处理"""
144
+ # Pydantic errors may include rejected input values (passwords, tokens, private text).
145
+ # Keep only field paths and machine error codes in public runtime logs.
146
+ error_fields = [
147
+ {"field": ".".join(str(loc) for loc in err.get("loc", ())[1:]), "type": err.get("type", "unknown")}
148
+ for err in exc.errors()
149
+ ]
150
+ logger.warning(f"Validation Error | {request.url.path} | fields={error_fields}")
151
 
152
  # 提取简洁的错误信息
153
  errors = []
154
  for err in exc.errors():
155
  field = ".".join(str(loc) for loc in err["loc"][1:]) # 跳过 'body'
156
+ errors.append({"field": field, "message": "该字段格式不正确", "type": err["type"]})
157
 
158
  return JSONResponse(
159
  status_code=422,
 
169
  async def global_exception_handler(request, exc):
170
  """🚀 P3优化:全局异常处理,捕获所有未预期异常"""
171
  error_id = f"ERR_{int(time.time())}_{id(exc) % 10000}"
172
+ logger.error(f"Unhandled Exception [{error_id}] | {request.url.path} | error_type={type(exc).__name__}")
 
173
  return JSONResponse(
174
  status_code=500,
175
  content=create_error_response(
 
205
  health_status["components"]["sql_database"] = "error: connection failed"
206
  health_status["status"] = "degraded"
207
  except Exception as e:
208
+ logger.warning(f"健康检查 SQL 数据库异常: error_type={type(e).__name__}")
209
+ health_status["components"]["sql_database"] = "error: connection failed"
210
  health_status["status"] = "degraded"
211
 
212
  # 检查 JSON 文件访问
 
215
  json_db.load_data("users.json", default_data={})
216
  health_status["components"]["json_storage"] = "ok"
217
  except Exception as e:
218
+ logger.warning(f"健康检查 JSON 存储异常: error_type={type(e).__name__}")
219
+ health_status["components"]["json_storage"] = "error: storage unavailable"
220
  health_status["status"] = "degraded"
221
 
222
  return health_status
 
234
  init_sql_db()
235
  logger.info("✅ SQL 数据库初始化完成")
236
  except Exception as e:
237
+ logger.error(f"❌ SQL 数据库初始化失败: error_type={type(e).__name__}")
238
  # 不抛出异常,允许降级运行
239
 
240
  # ========== 加载第三方插件注册表(依赖 SQL 数据库) ==========
 
243
  load_plugins_from_db()
244
  logger.info("✅ 第三方插件注册表已加载")
245
  except Exception as e:
246
+ logger.warning(f"⚠️ 第三方插件注册表加载失败: error_type={type(e).__name__}")
247
 
248
  # ========== 预热检查:JSON 数据库 ==========
249
  try:
 
251
  db.load_data("items.json", default_data=[])
252
  logger.info("✅ JSON 数据库访问正常")
253
  except Exception as e:
254
+ logger.warning(f"⚠️ JSON 数据库访问异常: error_type={type(e).__name__}")
255
 
256
+ # SQL资金已冻结但JSON发布失败的任务,重启后继续投影;持有句柄供停机收尾。
257
+ from task_publication import publication_worker
258
+ from database_sql import engine
259
+ from db_utils import sort_cache
260
+ app.state.task_publication_stop = asyncio.Event()
261
+ app.state.task_publication_worker = asyncio.create_task(publication_worker(
262
+ engine, db, sort_cache.invalidate, app.state.task_publication_stop))
263
+
264
+ from resource_publication import publication_worker as resource_publication_worker
265
+ app.state.resource_publication_stop = asyncio.Event()
266
+ app.state.resource_publication_worker = asyncio.create_task(resource_publication_worker(
267
+ engine, db, sort_cache.invalidate, app.state.resource_publication_stop))
268
+
269
+ from resource_deletion import resource_deletion_worker
270
+ app.state.resource_deletion_stop = asyncio.Event()
271
+ app.state.resource_deletion_worker = asyncio.create_task(resource_deletion_worker(
272
+ db, app.state.resource_deletion_stop, sort_cache.invalidate))
273
+
274
  # ========== 启动后台任务 ==========
275
  asyncio.create_task(daily_version_check_task())
276
  logger.info("✅ 定时版本检测任务已挂载")
 
280
 
281
  # ========== 启动 HF 批量同步定时器 ==========
282
  db.start_batch_sync()
283
+ logger.info("✅ HF 同步调度初始化已完成")
284
 
285
  logger.info("🎉 ComfyUI-Ranking API 启动完成!")
286
 
 
290
  """优雅关闭,清理资源"""
291
  logger.info("🛑 ComfyUI-Ranking API 正在关闭...")
292
 
293
+ publication_stop = getattr(app.state, "task_publication_stop", None)
294
+ publication_task = getattr(app.state, "task_publication_worker", None)
295
+ if publication_stop is not None:
296
+ publication_stop.set()
297
+ if publication_task is not None:
298
+ await publication_task
299
+
300
+ resource_stop = getattr(app.state, "resource_publication_stop", None)
301
+ resource_task = getattr(app.state, "resource_publication_worker", None)
302
+ if resource_stop is not None:
303
+ resource_stop.set()
304
+ if resource_task is not None:
305
+ await resource_task
306
+
307
+ deletion_stop = getattr(app.state, "resource_deletion_stop", None)
308
+ deletion_task = getattr(app.state, "resource_deletion_worker", None)
309
+ if deletion_stop is not None:
310
+ deletion_stop.set()
311
+ if deletion_task is not None:
312
+ await deletion_task
313
+
314
  # ========== 关闭前同步所有脏文件 ==========
315
+ await asyncio.to_thread(db.flush_sync)
316
+ logger.info("✅ HF 关闭前同步尝试已结束")
317
 
318
  # 这里可以添加其他清理逻辑(如关闭连接池等)
319
  logger.info("✅ 关闭完成")
 
323
  # 传递 Token 且 allow_credentials=False 不依赖 Cookie,通配风险可控)。
324
  # 如需收紧可在环境变量 ALLOWED_ORIGINS 中逗号分隔指定域名。
325
  ALLOWED_ORIGINS = os.environ.get("ALLOWED_ORIGINS", "*").split(",")
326
+ logger.info(
327
+ f"CORS 配置已加载: origin_count={len(ALLOWED_ORIGINS)} | "
328
+ f"wildcard={len(ALLOWED_ORIGINS) == 1 and ALLOWED_ORIGINS[0] == '*'}"
329
+ )
330
 
331
  app.add_middleware(
332
  CORSMiddleware,
 
334
  allow_credentials=False,
335
  allow_methods=["*"],
336
  allow_headers=["*"],
337
+ expose_headers=["ETag"],
338
  )
339
 
340
  # ==========================================
 
361
  # 🟢 私有图床代理中心 (Image Proxy)
362
  # 解决 Private 仓库下,本地客户端报 401 Unauthorized 的终极方案
363
  # ==========================================
364
+ from media_proxy_service import read_public_media
365
+
366
  @app.get("/api/image_proxy")
367
  def proxy_hf_image(url: str = None, path: str = None):
368
+ return read_public_media(url, path, store=db, download=hf_hub_download,
369
+ file_response=FileResponse, error_response=create_error_response)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
370
 
371
  # ==========================================
372
  # 上传接口 (将返回的 URL 替换为 Proxy 代理链接)
 
377
  # ==========================================
378
  # 🎵 音频安全净化:重编码压缩,剥离元数据与隐藏附加信息
379
  # ==========================================
380
+ from upload_service import process_upload
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
 
382
  @app.post("/api/upload")
383
+ @shared_limit(30)
384
  def upload_file(request: Request, file: UploadFile = File(...), file_type: str = Form(...), current_user: str = Depends(require_auth)):
385
+ return process_upload(file, file_type, current_user, store=db, storage_factory=HfApi,
386
+ moderate=moderate_image_sync, limiter=_UPLOAD_SEMAPHORE,
387
+ error_response=create_error_response)
388
+
389
+
390
+
391
+ from github_source import select_github_token
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
392
 
393
 
394
  class ValidateResourceRequest(BaseModel):
 
396
  item_id: str
397
  account: str
398
 
399
+ from resource_probe_service import probe_resource
400
+ from github_probe_transport import open_github_probe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
401
 
402
+ @app.post("/api/validate_resource")
403
+ def validate_resource(req_data: ValidateResourceRequest, sql_db: Session = Depends(get_db), current_user: str = Depends(require_auth)):
404
+ return probe_resource(req_data.item_id, store=db, download=hf_hub_download,
405
+ open_url=open_github_probe, token_selector=select_github_token,
406
+ error_response=create_error_response)
407
 
408
  # ==========================================
409
  # 🔒 管理员:系统配置 API
 
430
  result[cfg.config_key] = cfg.config_value
431
  return result
432
  except Exception as e:
433
+ logger.warning(f"从数据库加载系统配置失败,降级到文件: error_type={type(e).__name__}")
434
  return _load_system_config_from_file()
435
 
436
  def _load_system_config_from_file() -> dict:
 
440
  with open(SYSTEM_CONFIG_FILE, "r") as f:
441
  return json.load(f)
442
  except Exception as e:
443
+ logger.debug(f"加载系统配置文件失败: error_type={type(e).__name__}")
444
  return {}
445
 
446
  def _save_system_config_to_db(db: Session, config_key: str, value, updated_by: str = None):
 
456
  db.commit()
457
  except Exception as e:
458
  db.rollback()
459
+ logger.warning(f"保存系统配置到数据库失败,降级到文件: error_type={type(e).__name__}")
460
  # 降级:保存到文件
461
  config = _load_system_config_from_file()
462
  config[config_key] = value
 
464
  with open(SYSTEM_CONFIG_FILE, "w") as f:
465
  json.dump(config, f, ensure_ascii=False)
466
  except Exception as fe:
467
+ logger.warning(f"降级保存系统配置文件也失败: error_type={type(fe).__name__}")
468
 
469
  # 兼容旧调用的包装函数
470
  def _load_system_config(db: Session = None) -> dict:
chat_store.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """私信按真实参与者读取,所有消息修改使用锁内最新记录。"""
2
+ import copy
3
+ import logging
4
+ import math
5
+ import time
6
+ import uuid
7
+
8
+ from fastapi import HTTPException
9
+ import 数据库连接 as db
10
+
11
+ logger = logging.getLogger(__name__)
12
+ HISTORY_TTL = 7 * 24 * 3600
13
+ MAX_MESSAGE_LENGTH = 1000
14
+ MESSAGE_FIELDS = frozenset({"id", "sender", "receiver", "content", "created_at", "is_read"})
15
+
16
+
17
+ def _unavailable():
18
+ return HTTPException(status_code=503, detail="聊天数据暂不可用")
19
+
20
+
21
+ def _timestamp(message):
22
+ value = message["created_at"]
23
+ return value / 1000 if value >= 1e12 else value
24
+
25
+
26
+ def _validate(data):
27
+ if not isinstance(data, dict):
28
+ raise _unavailable()
29
+ for key, messages in data.items():
30
+ if not isinstance(key, str) or not isinstance(messages, list):
31
+ raise _unavailable()
32
+ for message in messages:
33
+ if not isinstance(message, dict):
34
+ raise _unavailable()
35
+ if any(not isinstance(message.get(field), str) or not message[field]
36
+ for field in ("id", "sender", "receiver")):
37
+ raise _unavailable()
38
+ timestamp = message.get("created_at")
39
+ if not isinstance(message.get("content"), str) or type(timestamp) not in (int, float):
40
+ raise _unavailable()
41
+ try:
42
+ valid_timestamp = math.isfinite(timestamp) and timestamp >= 0
43
+ except (OverflowError, ValueError):
44
+ valid_timestamp = False
45
+ if not valid_timestamp or type(message.get("is_read", False)) is not bool:
46
+ raise _unavailable()
47
+ return data
48
+
49
+
50
+ def _read(name):
51
+ try:
52
+ return db.load_data(name, default_data={}, skip_cache=True, strict=True)
53
+ except Exception as error:
54
+ logger.warning("聊天读取失败: %s", type(error).__name__)
55
+ raise _unavailable() from None
56
+
57
+
58
+ def _mutate(updater):
59
+ try:
60
+ return db.atomic_update("chats.json", lambda data: updater(_validate(data)), default_data={})
61
+ except HTTPException:
62
+ raise
63
+ except Exception as error:
64
+ logger.warning("聊天写入失败: %s", type(error).__name__)
65
+ raise _unavailable() from None
66
+
67
+
68
+ def _pair(message, account, target):
69
+ return ((message["sender"] == account and message["receiver"] == target)
70
+ or (message["sender"] == target and message["receiver"] == account))
71
+
72
+
73
+ def _project(message):
74
+ result = copy.deepcopy({key: value for key, value in message.items() if key in MESSAGE_FIELDS})
75
+ result.setdefault("is_read", False)
76
+ return result
77
+
78
+
79
+ def send_message(sender, receiver, content):
80
+ if not isinstance(receiver, str) or not receiver or not isinstance(content, str) or not content.strip() or len(content) > MAX_MESSAGE_LENGTH:
81
+ raise HTTPException(status_code=400, detail="私信内容或接收者不正确")
82
+ users = _read("users.json")
83
+ if not isinstance(users, dict):
84
+ raise _unavailable()
85
+ if receiver not in users:
86
+ raise HTTPException(status_code=404, detail="接收者不存在")
87
+ if not isinstance(users[receiver], dict) or users[receiver].get("account", receiver) != receiver:
88
+ raise _unavailable()
89
+ message = {"id": f"chat_{int(time.time())}_{uuid.uuid4().hex}", "sender": sender,
90
+ "receiver": receiver, "content": content, "created_at": int(time.time()), "is_read": False}
91
+ key = f"{min(sender, receiver)}_{max(sender, receiver)}"
92
+ _mutate(lambda data: data.setdefault(key, []).append(message))
93
+
94
+
95
+ def chat_list(account):
96
+ data = _validate(_read("chats.json"))
97
+ users = _read("users.json")
98
+ if not isinstance(users, dict):
99
+ raise _unavailable()
100
+ conversations = {}
101
+ # 旧键的下划线编码可能相撞,账号子串也不能代表参与者。
102
+ for messages in data.values():
103
+ for message in messages:
104
+ if message["sender"] == account:
105
+ target = message["receiver"]
106
+ elif message["receiver"] == account:
107
+ target = message["sender"]
108
+ else:
109
+ continue
110
+ conversations.setdefault(target, []).append(message)
111
+ result = []
112
+ for target, messages in conversations.items():
113
+ user = users.get(target, {})
114
+ if not isinstance(user, dict):
115
+ raise _unavailable()
116
+ last = max(messages, key=_timestamp)
117
+ result.append({"account": target, "name": user.get("name", target), "avatar": user.get("avatarDataUrl", ""),
118
+ "last_message": last["content"], "last_time": last["created_at"],
119
+ "unread_count": sum(message["receiver"] == account and not message.get("is_read", False)
120
+ for message in messages)})
121
+ result.sort(key=lambda item: item["last_time"] / 1000 if item["last_time"] >= 1e12 else item["last_time"], reverse=True)
122
+ return result
123
+
124
+
125
+ def chat_history(account, target):
126
+ now = time.time()
127
+ data = _validate(_read("chats.json"))
128
+
129
+ def expires(message):
130
+ return message.get("is_read", False) and now - _timestamp(message) >= HISTORY_TTL
131
+
132
+ def matching(message):
133
+ return _pair(message, account, target)
134
+
135
+ def needs_update(message):
136
+ return matching(message) and (expires(message) or (message["receiver"] == account and not message.get("is_read", False)))
137
+
138
+ def snapshot(data):
139
+ return sorted((_project(message) for messages in data.values() for message in messages
140
+ if matching(message)), key=_timestamp)
141
+
142
+ if not any(needs_update(message) for messages in data.values() for message in messages):
143
+ return snapshot(data)
144
+
145
+ def update(data):
146
+ for key, messages in data.items():
147
+ kept = []
148
+ for message in messages:
149
+ if matching(message):
150
+ if expires(message):
151
+ continue
152
+ if message["receiver"] == account:
153
+ message["is_read"] = True
154
+ kept.append(message)
155
+ data[key] = kept
156
+ return snapshot(data)
157
+ return _mutate(update)
comment_store.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """评论记录的锁内修改;路由负责认证、内容存在性及响应契约。"""
2
+ import copy
3
+ import logging
4
+ from fastapi import HTTPException
5
+ import 数据库连接 as db
6
+ from resource_deletion import resource_mutation_lock, assert_comment_target_active
7
+ from resource_store import ResourceStoreError
8
+
9
+
10
+ def _item_comments(data, item_id):
11
+ if not isinstance(data, dict):
12
+ raise HTTPException(status_code=503, detail="评论数据暂不可用")
13
+ records = data.get(item_id, [])
14
+ if not isinstance(records, list):
15
+ raise HTTPException(status_code=503, detail="评论数据暂不可用")
16
+ return records
17
+
18
+
19
+ def append_comment(item_id, record, parent_id=None, prepend=False):
20
+ def updater(data):
21
+ records = _item_comments(data, item_id)
22
+ if parent_id:
23
+ parent = next((entry for entry in records if isinstance(entry, dict)
24
+ and entry.get("id") == parent_id), None)
25
+ if parent is None:
26
+ raise HTTPException(status_code=404, detail="找不到要回复的评论")
27
+ replies = parent.setdefault("replies", [])
28
+ if not isinstance(replies, list):
29
+ raise HTTPException(status_code=503, detail="评论数据暂不可用")
30
+ replies.append(copy.deepcopy(record))
31
+ elif prepend:
32
+ records.insert(0, copy.deepcopy(record))
33
+ else:
34
+ records.append(copy.deepcopy(record))
35
+ data[item_id] = records
36
+ try:
37
+ with resource_mutation_lock:
38
+ assert_comment_target_active(db, item_id)
39
+ db.atomic_update("comments.json", updater, default_data={})
40
+ except ResourceStoreError as error:
41
+ raise HTTPException(status_code=error.status_code, detail=error.message) from None
42
+
43
+
44
+ def _find_comment(records, comment_id, depth=0):
45
+ if depth > 20 or not isinstance(records, list):
46
+ return None
47
+ for record in records:
48
+ if not isinstance(record, dict):
49
+ continue
50
+ if record.get("id") == comment_id:
51
+ return record
52
+ found = _find_comment(record.get("replies", []), comment_id, depth + 1)
53
+ if found is not None:
54
+ return found
55
+ return None
56
+
57
+
58
+ def delete_comment(item_id, comment_id, account, can_moderate=False):
59
+ def updater(data):
60
+ target = _find_comment(_item_comments(data, item_id), comment_id)
61
+ if target is None:
62
+ raise HTTPException(status_code=404, detail="找不到该评论")
63
+ # 在同一锁内检查最新目标并执行软删除,不能使用先前读取的快照。
64
+ if target.get("author") != account and not can_moderate:
65
+ raise HTTPException(status_code=403, detail="无权删除此评论")
66
+ target["isDeleted"] = True
67
+ target["content"] = ""
68
+ db.atomic_update("comments.json", updater, default_data={})
69
+
70
+
71
+ def increment_comment_count(file_name, item_id):
72
+ """派生计数独立更新;失败不能把已提交评论误报为失败或重放评论。"""
73
+ def updater(records):
74
+ for record in records:
75
+ if record.get("id") == item_id:
76
+ old_count = record.get("comments", 0)
77
+ record["comments"] = (old_count if type(old_count) is int and old_count >= 0 else 0) + 1
78
+ break
79
+ try:
80
+ db.atomic_update(file_name, updater, default_data=[])
81
+ return True
82
+ except Exception:
83
+ logging.getLogger(__name__).warning("评论已提交,派生计数暂未更新")
84
+ return False
85
+
86
+
87
+ def _visible_count(records, depth=0):
88
+ if not isinstance(records, list) or depth > 20:
89
+ raise ValueError("Invalid comment thread")
90
+ count = 0
91
+ for record in records:
92
+ if not isinstance(record, dict) or not isinstance(record.get("id"), str) or not record["id"].strip():
93
+ raise ValueError("Invalid comment record")
94
+ deleted = record.get("isDeleted", False)
95
+ if type(deleted) is not bool:
96
+ raise ValueError("Invalid comment state")
97
+ if not deleted:
98
+ count += 1
99
+ count += _visible_count(record.get("replies", []), depth + 1)
100
+ return count
101
+
102
+
103
+ def attach_comment_counts(records):
104
+ """只校对响应副本,以评论主记录恢复展示计数,不写内容或资金数据。"""
105
+ try:
106
+ snapshot = db.load_data("comments.json", default_data={}, skip_cache=True, strict=True)
107
+ if not isinstance(snapshot, dict):
108
+ raise ValueError("Invalid comment data")
109
+ except Exception:
110
+ logging.getLogger(__name__).warning("评论计数无法校对,保留已有计数")
111
+ return records
112
+ for record in records:
113
+ try:
114
+ count = _visible_count(snapshot.get(record["id"], []))
115
+ except (KeyError, TypeError, ValueError):
116
+ continue
117
+ record["comments"] = count
118
+ return records
database_sql.py CHANGED
@@ -77,10 +77,10 @@ def db_retry(max_retries: int = 3, delay: float = 0.5):
77
  last_error = e
78
  if attempt < max_retries - 1:
79
  wait_time = delay * (2 ** attempt) # 指数退避
80
- logger.warning(f"DB 操作失败 (第{attempt+1}次):{e}, {wait_time}秒后重试")
81
  time.sleep(wait_time)
82
  else:
83
- logger.error(f"DB 操作失败 (重试{max_retries}次后放弃): {e}")
84
  raise last_error
85
  return wrapper
86
  return decorator
@@ -99,10 +99,10 @@ def init_sql_db():
99
  return
100
  except Exception as e:
101
  if attempt < 2:
102
- logger.warning(f"数据库初始化失败 (第{attempt+1}次): {e}")
103
  time.sleep(2 ** attempt)
104
  else:
105
- logger.error(f"数据库初始化失败 (已重试3次): {e}")
106
  raise
107
 
108
 
@@ -130,7 +130,7 @@ def _auto_migrate_p7_fields():
130
  conn.execute(text("ALTER TABLE ownerships ADD COLUMN IF NOT EXISTS price_paid INTEGER DEFAULT 0"))
131
  logger.info("迁移完成: 添加 ownerships.price_paid 字段")
132
  except Exception as e:
133
- logger.debug(f"跳过 ownerships.price_paid (可能已存在): {e}")
134
 
135
  if 'is_refunded' not in columns:
136
  try:
@@ -140,7 +140,7 @@ def _auto_migrate_p7_fields():
140
  conn.execute(text("ALTER TABLE ownerships ADD COLUMN IF NOT EXISTS is_refunded BOOLEAN DEFAULT FALSE"))
141
  logger.info("迁移完成: 添加 ownerships.is_refunded 字段")
142
  except Exception as e:
143
- logger.debug(f"跳过 ownerships.is_refunded (可能已存在): {e}")
144
 
145
  if 'refunded_at' not in columns:
146
  try:
@@ -150,7 +150,7 @@ def _auto_migrate_p7_fields():
150
  conn.execute(text("ALTER TABLE ownerships ADD COLUMN IF NOT EXISTS refunded_at TIMESTAMP"))
151
  logger.info("迁移完成: 添加 ownerships.refunded_at 字段")
152
  except Exception as e:
153
- logger.debug(f"跳过 ownerships.refunded_at (可能已存在): {e}")
154
 
155
  conn.commit()
156
 
@@ -167,7 +167,7 @@ def _auto_migrate_p7_fields():
167
  conn.execute(text("ALTER TABLE wallets ADD COLUMN IF NOT EXISTS task_balance INTEGER DEFAULT 0"))
168
  logger.info("[DB Migration] 添加列 wallets.task_balance")
169
  except Exception as e:
170
- logger.debug(f"跳过 wallets.task_balance (可能已存在): {e}")
171
 
172
  conn.commit()
173
 
@@ -206,7 +206,7 @@ def _auto_migrate_p7_fields():
206
  conn.execute(text(f"ALTER TABLE transactions ADD COLUMN IF NOT EXISTS {col_name} {col_type}"))
207
  logger.info(f"[DB Migration] 添加列 transactions.{col_name}")
208
  except Exception as e:
209
- logger.debug(f"跳过 transactions.{col_name} (可能已存在): {e}")
210
 
211
  conn.commit()
212
 
@@ -235,7 +235,7 @@ def _auto_migrate_p7_fields():
235
  logger.info("✅ 已创建 registered_plugins 表")
236
 
237
  except Exception as e:
238
- logger.warning(f"字段迁移跳过 (可能已存在): {e}")
239
 
240
 
241
  def get_db():
@@ -244,7 +244,7 @@ def get_db():
244
  try:
245
  yield db
246
  except (OperationalError, InterfaceError) as e:
247
- logger.error(f"数据库连接错误: {e}")
248
  db.rollback()
249
  raise
250
  finally:
@@ -262,5 +262,5 @@ def check_db_connection() -> bool:
262
  db.close()
263
  return True
264
  except Exception as e:
265
- logger.error(f"数据库连接检查失败: {e}")
266
- return False
 
77
  last_error = e
78
  if attempt < max_retries - 1:
79
  wait_time = delay * (2 ** attempt) # 指数退避
80
+ logger.warning(f"DB 操作失败 (第{attempt+1}次): error_type={type(e).__name__}, {wait_time}秒后重试")
81
  time.sleep(wait_time)
82
  else:
83
+ logger.error(f"DB 操作失败 (重试{max_retries}次后放弃): error_type={type(e).__name__}")
84
  raise last_error
85
  return wrapper
86
  return decorator
 
99
  return
100
  except Exception as e:
101
  if attempt < 2:
102
+ logger.warning(f"数据库初始化失败 (第{attempt+1}次): error_type={type(e).__name__}")
103
  time.sleep(2 ** attempt)
104
  else:
105
+ logger.error(f"数据库初始化失败 (已重试3次): error_type={type(e).__name__}")
106
  raise
107
 
108
 
 
130
  conn.execute(text("ALTER TABLE ownerships ADD COLUMN IF NOT EXISTS price_paid INTEGER DEFAULT 0"))
131
  logger.info("迁移完成: 添加 ownerships.price_paid 字段")
132
  except Exception as e:
133
+ logger.debug(f"跳过 ownerships.price_paid (可能已存在): error_type={type(e).__name__}")
134
 
135
  if 'is_refunded' not in columns:
136
  try:
 
140
  conn.execute(text("ALTER TABLE ownerships ADD COLUMN IF NOT EXISTS is_refunded BOOLEAN DEFAULT FALSE"))
141
  logger.info("迁移完成: 添加 ownerships.is_refunded 字段")
142
  except Exception as e:
143
+ logger.debug(f"跳过 ownerships.is_refunded (可能已存在): error_type={type(e).__name__}")
144
 
145
  if 'refunded_at' not in columns:
146
  try:
 
150
  conn.execute(text("ALTER TABLE ownerships ADD COLUMN IF NOT EXISTS refunded_at TIMESTAMP"))
151
  logger.info("迁移完成: 添加 ownerships.refunded_at 字段")
152
  except Exception as e:
153
+ logger.debug(f"跳过 ownerships.refunded_at (可能已存在): error_type={type(e).__name__}")
154
 
155
  conn.commit()
156
 
 
167
  conn.execute(text("ALTER TABLE wallets ADD COLUMN IF NOT EXISTS task_balance INTEGER DEFAULT 0"))
168
  logger.info("[DB Migration] 添加列 wallets.task_balance")
169
  except Exception as e:
170
+ logger.debug(f"跳过 wallets.task_balance (可能已存在): error_type={type(e).__name__}")
171
 
172
  conn.commit()
173
 
 
206
  conn.execute(text(f"ALTER TABLE transactions ADD COLUMN IF NOT EXISTS {col_name} {col_type}"))
207
  logger.info(f"[DB Migration] 添加列 transactions.{col_name}")
208
  except Exception as e:
209
+ logger.debug(f"跳过 transactions.{col_name} (可能已存在): error_type={type(e).__name__}")
210
 
211
  conn.commit()
212
 
 
235
  logger.info("✅ 已创建 registered_plugins 表")
236
 
237
  except Exception as e:
238
+ logger.warning(f"字段迁移跳过 (可能已存在): error_type={type(e).__name__}")
239
 
240
 
241
  def get_db():
 
244
  try:
245
  yield db
246
  except (OperationalError, InterfaceError) as e:
247
+ logger.error(f"数据库连接错误: error_type={type(e).__name__}")
248
  db.rollback()
249
  raise
250
  finally:
 
262
  db.close()
263
  return True
264
  except Exception as e:
265
+ logger.error(f"数据库连接检查失败: error_type={type(e).__name__}")
266
+ return False
db_utils.py CHANGED
@@ -11,6 +11,7 @@
11
  # ==========================================
12
 
13
  from typing import Any, Dict, List, Optional, Callable, Union
 
14
  import 数据库连接 as db
15
 
16
 
@@ -111,6 +112,34 @@ def count_by(file_name: str, **conditions) -> int:
111
  # ✏️ 更新工具函数
112
  # ==========================================
113
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  def update_by_id(file_name: str, item_id: str, updates: Dict, id_field: str = "id") -> bool:
115
  """
116
  根据 ID 更新记录
@@ -127,22 +156,13 @@ def update_by_id(file_name: str, item_id: str, updates: Dict, id_field: str = "i
127
  示例:
128
  update_by_id("tasks.json", "task_123", {"status": "completed"})
129
  """
130
- data = db.load_data(file_name, default_data=[])
131
-
132
- if isinstance(data, dict):
133
- if item_id in data:
134
- data[item_id].update(updates)
135
- db.save_data(file_name, data)
136
- return True
137
- return False
138
-
139
- for item in data:
140
- if item.get(id_field) == item_id:
141
- item.update(updates)
142
- db.save_data(file_name, data)
143
- return True
144
-
145
- return False
146
 
147
 
148
  def update_with_fn(file_name: str, item_id: str, update_fn: Callable[[Dict], None], id_field: str = "id") -> bool:
@@ -164,22 +184,12 @@ def update_with_fn(file_name: str, item_id: str, update_fn: Callable[[Dict], Non
164
 
165
  update_with_fn("items.json", "item_123", increment_views)
166
  """
167
- data = db.load_data(file_name, default_data=[])
168
-
169
- if isinstance(data, dict):
170
- if item_id in data:
171
- update_fn(data[item_id])
172
- db.save_data(file_name, data)
173
- return True
174
- return False
175
-
176
- for item in data:
177
- if item.get(id_field) == item_id:
178
- update_fn(item)
179
- db.save_data(file_name, data)
180
- return True
181
-
182
- return False
183
 
184
 
185
  # ==========================================
@@ -201,23 +211,19 @@ def insert(file_name: str, item: Dict, prepend: bool = True) -> bool:
201
  示例:
202
  insert("tasks.json", {"id": "task_123", "title": "新任务"})
203
  """
204
- data = db.load_data(file_name, default_data=[])
205
-
206
- if isinstance(data, dict):
207
- item_id = item.get("id") or item.get("account")
208
- if item_id:
209
- data[item_id] = item
210
- db.save_data(file_name, data)
211
- return True
212
- return False
213
-
214
- if prepend:
215
- data.insert(0, item)
216
- else:
217
- data.append(item)
218
-
219
- db.save_data(file_name, data)
220
- return True
221
 
222
 
223
  def insert_if_not_exists(file_name: str, item: Dict, id_field: str = "id") -> bool:
@@ -232,15 +238,19 @@ def insert_if_not_exists(file_name: str, item: Dict, id_field: str = "id") -> bo
232
  返回:
233
  True 插入成功 / False 已存在
234
  """
235
- item_id = item.get(id_field)
236
- if not item_id:
237
- return False
238
-
239
- existing = get_by_id(file_name, item_id, id_field)
240
- if existing:
241
- return False
242
-
243
- return insert(file_name, item)
 
 
 
 
244
 
245
 
246
  # ==========================================
@@ -259,23 +269,7 @@ def delete_by_id(file_name: str, item_id: str, id_field: str = "id") -> bool:
259
  返回:
260
  True 删除成功 / False 记录不存在
261
  """
262
- data = db.load_data(file_name, default_data=[])
263
-
264
- if isinstance(data, dict):
265
- if item_id in data:
266
- del data[item_id]
267
- db.save_data(file_name, data)
268
- return True
269
- return False
270
-
271
- original_len = len(data)
272
- data = [item for item in data if item.get(id_field) != item_id]
273
-
274
- if len(data) < original_len:
275
- db.save_data(file_name, data)
276
- return True
277
-
278
- return False
279
 
280
 
281
  def soft_delete_by_id(file_name: str, item_id: str, id_field: str = "id") -> bool:
@@ -575,25 +569,12 @@ def batch_update(file_name: str, item_ids: List[str], updates: Dict, id_field: s
575
  返回:
576
  更新的记录数量
577
  """
578
- data = db.load_data(file_name, default_data=[])
579
- updated_count = 0
580
-
581
- if isinstance(data, dict):
582
- for item_id in item_ids:
583
- if item_id in data:
584
- data[item_id].update(updates)
585
- updated_count += 1
586
- else:
587
- id_set = set(item_ids)
588
- for item in data:
589
- if item.get(id_field) in id_set:
590
- item.update(updates)
591
- updated_count += 1
592
-
593
- if updated_count > 0:
594
- db.save_data(file_name, data)
595
-
596
- return updated_count
597
 
598
 
599
  def batch_delete(file_name: str, item_ids: List[str], id_field: str = "id") -> int:
@@ -608,20 +589,13 @@ def batch_delete(file_name: str, item_ids: List[str], id_field: str = "id") -> i
608
  返回:
609
  删除的记录数量
610
  """
611
- data = db.load_data(file_name, default_data=[])
612
- original_count = len(data) if isinstance(data, list) else len(data)
613
-
614
- if isinstance(data, dict):
615
- for item_id in item_ids:
616
- data.pop(item_id, None)
617
- else:
618
- id_set = set(item_ids)
619
- data = [item for item in data if item.get(id_field) not in id_set]
620
-
621
- new_count = len(data) if isinstance(data, list) else len(data)
622
- deleted_count = original_count - new_count
623
-
624
- if deleted_count > 0:
625
- db.save_data(file_name, data)
626
-
627
- return deleted_count
 
11
  # ==========================================
12
 
13
  from typing import Any, Dict, List, Optional, Callable, Union
14
+ import copy
15
  import 数据库连接 as db
16
 
17
 
 
112
  # ✏️ 更新工具函数
113
  # ==========================================
114
 
115
+ class _Unchanged(Exception):
116
+ def __init__(self, result):
117
+ self.result = result
118
+
119
+
120
+ def _mutate(file_name, callback):
121
+ def update(data):
122
+ if not isinstance(data, (dict, list)) or not all(isinstance(item, dict) for item in (data.values() if isinstance(data, dict) else data)):
123
+ raise ValueError('invalid mutable record source')
124
+ result = callback(data)
125
+ if result is False or (type(result) is int and result == 0):
126
+ raise _Unchanged(result)
127
+ return result
128
+ try:
129
+ return db.atomic_update(file_name, update, default_data=[])
130
+ except _Unchanged as unchanged:
131
+ return unchanged.result
132
+
133
+
134
+ def _find_record(data, item_id, id_field):
135
+ if isinstance(data, dict):
136
+ return data.get(item_id)
137
+ matches = [item for item in data if item.get(id_field) == item_id]
138
+ if len(matches) > 1:
139
+ raise ValueError('ambiguous mutable record identity')
140
+ return matches[0] if matches else None
141
+
142
+
143
  def update_by_id(file_name: str, item_id: str, updates: Dict, id_field: str = "id") -> bool:
144
  """
145
  根据 ID 更新记录
 
156
  示例:
157
  update_by_id("tasks.json", "task_123", {"status": "completed"})
158
  """
159
+ saved_updates = copy.deepcopy(updates)
160
+ def update(data):
161
+ item = _find_record(data, item_id, id_field)
162
+ if item is None: return False
163
+ item.update(copy.deepcopy(saved_updates))
164
+ return True
165
+ return _mutate(file_name, update)
 
 
 
 
 
 
 
 
 
166
 
167
 
168
  def update_with_fn(file_name: str, item_id: str, update_fn: Callable[[Dict], None], id_field: str = "id") -> bool:
 
184
 
185
  update_with_fn("items.json", "item_123", increment_views)
186
  """
187
+ def update(data):
188
+ item = _find_record(data, item_id, id_field)
189
+ if item is None: return False
190
+ update_fn(item)
191
+ return True
192
+ return _mutate(file_name, update)
 
 
 
 
 
 
 
 
 
 
193
 
194
 
195
  # ==========================================
 
211
  示例:
212
  insert("tasks.json", {"id": "task_123", "title": "新任务"})
213
  """
214
+ saved = copy.deepcopy(item)
215
+ if not isinstance(saved, dict): raise ValueError('invalid inserted record')
216
+ def append(data):
217
+ if isinstance(data, dict):
218
+ item_id = saved.get('id') or saved.get('account')
219
+ if not item_id: return False
220
+ data[item_id] = copy.deepcopy(saved)
221
+ elif prepend:
222
+ data.insert(0, copy.deepcopy(saved))
223
+ else:
224
+ data.append(copy.deepcopy(saved))
225
+ return True
226
+ return _mutate(file_name, append)
 
 
 
 
227
 
228
 
229
  def insert_if_not_exists(file_name: str, item: Dict, id_field: str = "id") -> bool:
 
238
  返回:
239
  True 插入成功 / False 已存在
240
  """
241
+ saved = copy.deepcopy(item)
242
+ if not isinstance(saved, dict): raise ValueError('invalid inserted record')
243
+ item_id = saved.get(id_field)
244
+ if not item_id: return False
245
+ def append(data):
246
+ if isinstance(data, dict):
247
+ if item_id in data: return False
248
+ data[item_id] = copy.deepcopy(saved)
249
+ else:
250
+ if _find_record(data, item_id, id_field) is not None: return False
251
+ data.insert(0, copy.deepcopy(saved))
252
+ return True
253
+ return _mutate(file_name, append)
254
 
255
 
256
  # ==========================================
 
269
  返回:
270
  True 删除成功 / False 记录不存在
271
  """
272
+ return bool(batch_delete(file_name, [item_id], id_field))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
 
274
 
275
  def soft_delete_by_id(file_name: str, item_id: str, id_field: str = "id") -> bool:
 
569
  返回:
570
  更新的记录数量
571
  """
572
+ identities, saved_updates = set(item_ids), copy.deepcopy(updates)
573
+ def update(data):
574
+ selected = [item for key, item in data.items() if key in identities] if isinstance(data, dict) else [item for item in data if item.get(id_field) in identities]
575
+ for item in selected: item.update(copy.deepcopy(saved_updates))
576
+ return len(selected)
577
+ return _mutate(file_name, update)
 
 
 
 
 
 
 
 
 
 
 
 
 
578
 
579
 
580
  def batch_delete(file_name: str, item_ids: List[str], id_field: str = "id") -> int:
 
589
  返回:
590
  删除的记录数量
591
  """
592
+ identities = set(item_ids)
593
+ def delete(data):
594
+ if isinstance(data, dict):
595
+ selected = [key for key in data if key in identities]
596
+ for key in selected: del data[key]
597
+ return len(selected)
598
+ old_count = len(data)
599
+ data[:] = [item for item in data if item.get(id_field) not in identities]
600
+ return old_count - len(data)
601
+ return _mutate(file_name, delete)
 
 
 
 
 
 
 
deployment_config.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """可信部署地址配置;不依据请求 Host 或转发头构造持久链接。"""
2
+ import ipaddress
3
+ import os
4
+ import re
5
+ from urllib.parse import urlsplit, urlunsplit
6
+
7
+ DEFAULT_PUBLIC_API_BASE_URL = "https://zhiwei666-comfyui-ranking-api.hf.space"
8
+ PUBLIC_API_BASE_URL_ENV = "RANKING_PUBLIC_API_BASE_URL"
9
+ _CONFIG_ERROR = "RANKING_PUBLIC_API_BASE_URL 配置无效:需要 HTTPS 服务基址,HTTP 仅允许本机回环地址"
10
+
11
+
12
+ def public_api_base_url(environ=None):
13
+ """读取受运维控制的基址;缺失兼容默认,显式错误拒绝初始化。"""
14
+ environ = os.environ if environ is None else environ
15
+ value = environ.get(PUBLIC_API_BASE_URL_ENV, DEFAULT_PUBLIC_API_BASE_URL)
16
+ if (not isinstance(value, str) or not value or len(value) > 2048
17
+ or any(ord(char) <= 32 or ord(char) >= 127 for char in value)
18
+ or "\\" in value):
19
+ raise RuntimeError(_CONFIG_ERROR)
20
+ try:
21
+ parsed = urlsplit(value)
22
+ host = parsed.hostname
23
+ port = parsed.port
24
+ if (parsed.scheme not in {"http", "https"} or not host
25
+ or parsed.username is not None or parsed.password is not None
26
+ or "?" in value or "#" in value
27
+ or (port is not None and not 1 <= port <= 65535)
28
+ or parsed.netloc.endswith(":")):
29
+ raise ValueError()
30
+ try:
31
+ address = ipaddress.ip_address(host)
32
+ except ValueError:
33
+ address = None
34
+ if (len(host) > 253 or not all(re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label)
35
+ for label in host.split(".")) or re.fullmatch(r"[0-9.]+", host)):
36
+ raise ValueError()
37
+ if parsed.scheme == "http" and not (host == "localhost" or address and address.is_loopback):
38
+ raise ValueError()
39
+ path = parsed.path.rstrip("/")
40
+ if (path and (not path.startswith("/") or "//" in path
41
+ or not re.fullmatch(r"/[A-Za-z0-9._~/-]+", path)
42
+ or any(segment in {".", ".."} for segment in path.split("/")))):
43
+ raise ValueError()
44
+ return urlunsplit((parsed.scheme, parsed.netloc.lower(), path, "", ""))
45
+ except ValueError:
46
+ raise RuntimeError(_CONFIG_ERROR) from None
47
+
48
+
49
+ PUBLIC_API_BASE_URL = public_api_base_url()
github_archive.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """固定Git commit归档与缓存来源证明;不把旧缓存当作已核实安装包。"""
2
+ import datetime
3
+ import hashlib
4
+ import json
5
+ import re
6
+ from github_source import canonical_repository
7
+
8
+ ARCHIVE_SCHEMA = 3
9
+ ARCHIVE_MAX_BYTES = 500 * 1024 * 1024
10
+
11
+
12
+ def archive_repository(value):
13
+ """仅接收明确的owner/name标识,不能用元数据构造额外URL路径。"""
14
+ if (not isinstance(value, str) or len(value) > 193
15
+ or not re.fullmatch(r'[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,94}[A-Za-z0-9])?/[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,94}[A-Za-z0-9])?', value)
16
+ or '..' in value or '--' in value):
17
+ raise ValueError('invalid archive storage identity')
18
+ return value
19
+
20
+
21
+ def commit_sha(value):
22
+ if not isinstance(value, str) or not re.fullmatch(r'[0-9a-fA-F]{40}', value):
23
+ raise ValueError('invalid commit identity')
24
+ return value.lower()
25
+
26
+
27
+ def commit_ref(value):
28
+ if not isinstance(value, str) or not re.fullmatch(r'(?:[0-9a-fA-F]{7}|[0-9a-fA-F]{40})', value):
29
+ raise ValueError('invalid commit reference')
30
+ return value.lower()
31
+
32
+
33
+ def source_signature(item, token):
34
+ author = item.get('author')
35
+ if not isinstance(author, str) or not author.strip():
36
+ raise ValueError('invalid archive author')
37
+ source = [canonical_repository(item.get('link')).lower(), author, token or '']
38
+ # 元数据只保存摘要,不保存或输出凭据。
39
+ return hashlib.sha256(json.dumps(source, ensure_ascii=True, separators=(',', ':')).encode()).hexdigest()
40
+
41
+
42
+ def archive_path(item_id, sha, signature):
43
+ if not isinstance(item_id, str) or not re.fullmatch(r'[A-Za-z0-9_-]{1,100}', item_id):
44
+ raise ValueError('invalid archive item')
45
+ if not isinstance(signature, str) or not re.fullmatch(r'[0-9a-f]{64}', signature):
46
+ raise ValueError('invalid archive source')
47
+ return f'zips/{item_id}_{commit_sha(sha)}_{signature[:16]}.zip'
48
+
49
+
50
+ def cached_archive(item, token, entry, *, repo_id, now=None):
51
+ """只有完整来源、commit、HF不可变提交及未过期元数据才允许命中。"""
52
+ try:
53
+ if not isinstance(entry, dict) or type(entry.get('archive_schema')) is not int or entry['archive_schema'] != ARCHIVE_SCHEMA:
54
+ return None
55
+ repository = archive_repository(repo_id)
56
+ if entry.get('archive_repo_id') != repository:
57
+ return None
58
+ if entry.get('archive_storage_private') is not True:
59
+ return None
60
+ sha = commit_sha(entry.get('archive_commit'))
61
+ if entry.get('version_commit') not in (None, sha):
62
+ return None
63
+ if entry.get('hash') != sha[:7]:
64
+ return None
65
+ signature = source_signature(item, token)
66
+ if entry.get('archive_source') != signature:
67
+ return None
68
+ path = archive_path(item.get('id'), sha, signature)
69
+ if entry.get('archive_path') != path:
70
+ return None
71
+ hf_commit = commit_sha(entry.get('archive_hf_commit'))
72
+ size = entry.get('zip_size')
73
+ if type(size) is not int or not 0 < size <= ARCHIVE_MAX_BYTES:
74
+ return None
75
+ cached_at = datetime.datetime.fromisoformat(entry['cached_at'].replace('Z', '+00:00'))
76
+ if cached_at.tzinfo is None:
77
+ return None
78
+ elapsed = (now or datetime.datetime.now(datetime.timezone.utc)) - cached_at
79
+ if not datetime.timedelta(minutes=-5) <= elapsed <= datetime.timedelta(days=30):
80
+ return None
81
+ return dict(commit=sha, path=path, hf_commit=hf_commit, size=size, repo_id=repository)
82
+ except (ValueError, TypeError, KeyError, AttributeError):
83
+ return None
84
+
85
+
86
+ def publish_archive_metadata(versions, item_id, expected_hash, metadata):
87
+ """锁内只更新目标缓存字段;晚到下载不能改变当前版本或其它资源。"""
88
+ if not isinstance(versions, dict):
89
+ raise ValueError('invalid version source')
90
+ entry = versions.get(item_id)
91
+ if isinstance(entry, str):
92
+ entry = {'hash': entry}
93
+ if not isinstance(entry, dict) or entry.get('hash') != commit_ref(expected_hash)[:7]:
94
+ return False
95
+ if (entry.get('version_commit') not in (None, metadata.get('archive_commit'))
96
+ or entry.get('version_source') not in (None, metadata.get('archive_source'))):
97
+ return False
98
+ keys = ('archive_schema', 'archive_commit', 'archive_source', 'archive_path',
99
+ 'archive_hf_commit', 'archive_storage_private', 'archive_repo_id', 'zip_size', 'cached_at')
100
+ archive_repository(metadata['archive_repo_id'])
101
+ entry = {**entry, **{key: metadata[key] for key in keys}}
102
+ versions[item_id] = entry
103
+ return True
github_probe_transport.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GitHub元数据预检传输:凭据仅留在HTTPS API源,不跟随外部跳转。"""
2
+ import re
3
+ import time
4
+ import urllib.error
5
+ import urllib.parse
6
+ import urllib.request
7
+ from github_source import repository_parts
8
+
9
+ MAX_PROBE_TIMEOUT = 10
10
+ MAX_PROBE_REDIRECTS = 3
11
+
12
+
13
+ def _metadata_target(url):
14
+ if (not isinstance(url, str) or len(url) > 2048
15
+ or any(ord(char) <= 32 or ord(char) >= 127 for char in url)
16
+ or "\\" in url or "?" in url or "#" in url):
17
+ raise ValueError("invalid GitHub metadata target")
18
+ parsed = urllib.parse.urlsplit(url)
19
+ if (parsed.scheme != "https" or parsed.hostname != "api.github.com"
20
+ or parsed.username is not None or parsed.password is not None
21
+ or parsed.port not in (None, 443) or parsed.netloc.endswith(":")):
22
+ raise ValueError("invalid GitHub metadata origin")
23
+ if parsed.path.startswith("/repos/"):
24
+ repository_parts("https://github.com/" + parsed.path[len("/repos/"):])
25
+ elif not re.fullmatch(r"/repositories/[0-9]{1,20}", parsed.path):
26
+ raise ValueError("invalid GitHub metadata path")
27
+ return url
28
+
29
+
30
+ class GithubMetadataRedirect(urllib.request.HTTPRedirectHandler):
31
+ max_redirections = MAX_PROBE_REDIRECTS
32
+ max_repeats = 1
33
+
34
+ def __init__(self, deadline):
35
+ self.deadline = deadline
36
+
37
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
38
+ try:
39
+ _metadata_target(req.full_url)
40
+ _metadata_target(newurl)
41
+ except ValueError:
42
+ raise urllib.error.HTTPError(req.full_url, code, "GitHub预检跳转不允许", {}, fp) from None
43
+ remaining = self.deadline - time.monotonic()
44
+ if remaining <= 0:
45
+ fp.close()
46
+ raise TimeoutError("GitHub metadata probe timed out")
47
+ # urllib的父处理器使用旧req.timeout传给下一跳;收紧到剩余预算。
48
+ req.timeout = min(getattr(req, "timeout", MAX_PROBE_TIMEOUT), remaining)
49
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
50
+
51
+
52
+ def http_error_302(self, req, fp, code, msg, headers):
53
+ location = headers.get("location") or headers.get("uri")
54
+ if not isinstance(location, str) or not location or len(location) > 2048:
55
+ raise urllib.error.HTTPError(req.full_url, code, "GitHub预检跳转无效", {}, fp)
56
+ try:
57
+ target = urllib.parse.urljoin(req.full_url, location)
58
+ except ValueError:
59
+ raise urllib.error.HTTPError(req.full_url, code, "GitHub预检跳转无效", {}, fp) from None
60
+ new_request = self.redirect_request(req, fp, code, msg, headers, target)
61
+ count = getattr(req, "_ranking_probe_redirects", 0)
62
+ seen = getattr(req, "_ranking_probe_seen", {req.full_url})
63
+ if new_request is None or count >= MAX_PROBE_REDIRECTS or target in seen:
64
+ raise urllib.error.HTTPError(req.full_url, code, "GitHub预检跳转次数过多", {}, fp)
65
+ new_request._ranking_probe_redirects = count + 1
66
+ new_request._ranking_probe_seen = seen | {target}
67
+ # 只看元数据状态:关闭跳转正文,不使用urllib默认的无界fp.read()。
68
+ fp.close()
69
+ return self.parent.open(new_request, timeout=req.timeout)
70
+
71
+ http_error_301 = http_error_303 = http_error_307 = http_error_308 = http_error_302
72
+
73
+
74
+ def open_github_probe(request, *, timeout=MAX_PROBE_TIMEOUT):
75
+ """创建本次请求专用opener,不修改全局;HTTP错误也关闭其响应。"""
76
+ _metadata_target(request.full_url)
77
+ if (request.get_method() != "GET" or isinstance(timeout, bool)
78
+ or not isinstance(timeout, (int, float)) or not 0 < timeout <= MAX_PROBE_TIMEOUT):
79
+ raise ValueError("invalid GitHub metadata request")
80
+ redirect = GithubMetadataRedirect(time.monotonic() + timeout)
81
+ opener = urllib.request.build_opener(redirect)
82
+ try:
83
+ return opener.open(request, timeout=timeout)
84
+ except urllib.error.HTTPError as error:
85
+ try:
86
+ error.close()
87
+ except Exception:
88
+ pass # 清理失败不覆盖原HTTP错误,不输出响应或地址。
89
+ raise
github_source.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GitHub资源地址与凭据选择;普通发布者不能借用服务器全局PAT。"""
2
+ import os
3
+ import re
4
+ from urllib.parse import urlsplit
5
+
6
+
7
+ def repository_parts(url):
8
+ if not isinstance(url, str):
9
+ raise ValueError('invalid repository')
10
+ parsed = urlsplit(url.strip())
11
+ if (parsed.scheme != 'https' or parsed.hostname != 'github.com' or parsed.username or parsed.password
12
+ or parsed.port not in (None, 443) or parsed.query or parsed.fragment):
13
+ raise ValueError('invalid repository origin')
14
+ parts = parsed.path.strip('/').split('/')
15
+ if len(parts) != 2:
16
+ raise ValueError('invalid repository path')
17
+ owner, repo = parts
18
+ if repo.endswith('.git'):
19
+ repo = repo[:-4]
20
+ if (not re.fullmatch(r'[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?', owner)
21
+ or not re.fullmatch(r'[A-Za-z0-9_.-]{1,100}', repo) or repo in {'.', '..'}):
22
+ raise ValueError('invalid repository identity')
23
+ return owner, repo
24
+
25
+
26
+ def canonical_repository(url):
27
+ owner, repo = repository_parts(url)
28
+ return f'https://github.com/{owner}/{repo}'
29
+
30
+
31
+ def _token(value):
32
+ if value is None or value == '':
33
+ return None
34
+ if not isinstance(value, str) or not re.fullmatch(r'[\x21-\x7e]+', value):
35
+ raise ValueError('invalid github credential')
36
+ return value
37
+
38
+
39
+ def select_github_token(item, *, admin_check=None):
40
+ repository_parts(item.get('link'))
41
+ author = item.get('author')
42
+ if not isinstance(author, str) or not author.strip():
43
+ raise ValueError('invalid resource author')
44
+ creator = _token(item.get('github_token'))
45
+ if creator is not None:
46
+ return creator
47
+ if admin_check is None:
48
+ from 安全认证 import is_admin
49
+ admin_check = is_admin
50
+ # 权限来自服务端管理员配置与已保存作者,不信任请求账号或前端管理员标记。
51
+ if admin_check(author) is True:
52
+ return _token(os.environ.get('GITHUB_PAT'))
53
+ return None
github_versions.py ADDED
@@ -0,0 +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)
github_zip_service.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """授权后的GitHub ZIP交付服务;认证/实时所有权仍由路由负责。"""
2
+ import asyncio
3
+ import os
4
+ from github_archive import cached_archive
5
+ from github_source import repository_parts
6
+ from proxy_resources import ProxyResources
7
+ from workflow_stream_response import OwnedProxyResponse
8
+ from zip_stream import zip_response_length, stream_zip_bytes
9
+
10
+
11
+ async def prepare_github_zip(item, item_id, *, store, token_selector, resolve_commit,
12
+ send, client_factory, transport_factory, error_response,
13
+ cache_max_bytes, direct_max_bytes):
14
+ # 按服务端资源解析仓库,普通作者只使用本人绑定凭据或匿名GitHub。
15
+ try:
16
+ repo_url = item.get("link", "")
17
+ owner, repo = repository_parts(repo_url)
18
+ active_token = token_selector(item)
19
+ except ValueError:
20
+ return error_response(content={"error": "资源仓库或凭据暂时无法核实"}, status_code=503)
21
+
22
+ # ===== ZIP 缓存优先机制(鉴权后、GitHub请求前) =====
23
+ cache_resources = None
24
+ cache_handed_off = False
25
+ try:
26
+ versions_db = store.load_data("versions.json", default_data={})
27
+ item_version = versions_db.get(item_id)
28
+ archive = cached_archive(item, active_token, item_version, repo_id=store.DATASET_REPO_ID)
29
+ hf_cache_token = os.environ.get("HF_TOKEN")
30
+ if archive and hf_cache_token:
31
+ hf_headers = {"Authorization": f"Bearer {hf_cache_token}"}
32
+ cache_filename = archive["path"]
33
+ cache_url = f"https://huggingface.co/datasets/{archive['repo_id']}/resolve/{archive['hf_commit']}/{cache_filename}"
34
+ cache_client = client_factory(
35
+ follow_redirects=False, trust_env=False, transport=transport_factory(),
36
+ )
37
+ cache_resources = ProxyResources(cache_client)
38
+ head_resp = cache_resources.track(await send(
39
+ cache_client, "HEAD", cache_url, headers=hf_headers, timeout=10))
40
+ if head_resp.status_code == 200:
41
+ await cache_resources.close_response(head_resp)
42
+ stream_resp = cache_resources.track(await send(
43
+ cache_client, "GET", cache_url, headers=hf_headers, timeout=120))
44
+ if stream_resp.status_code == 200:
45
+ # identity响应应与已上传归档大小一致;编码响应仍保持原始透传。
46
+ encoding = (stream_resp.headers.get('content-encoding') or 'identity').strip().lower()
47
+ expected_size = archive['size'] if encoding == 'identity' else None
48
+ cache_length = zip_response_length(stream_resp.headers, max_bytes=cache_max_bytes,
49
+ expected_size=expected_size)
50
+ cache_headers = {}
51
+ content_length = stream_resp.headers.get('content-length')
52
+ if content_length:
53
+ cache_headers["Content-Length"] = content_length
54
+ content_encoding = stream_resp.headers.get('content-encoding')
55
+ if content_encoding:
56
+ cache_headers["Content-Encoding"] = content_encoding
57
+ if item_version.get('hash'):
58
+ cache_headers["X-Installed-Version"] = archive["commit"]
59
+
60
+ async def cache_stream_generator():
61
+ try:
62
+ async for chunk in stream_zip_bytes(stream_resp, max_bytes=cache_max_bytes,
63
+ length=cache_length, expected_size=expected_size):
64
+ yield chunk
65
+ except Exception as error:
66
+ print(f"[ZIP缓存] 响应流中断: error={type(error).__name__}")
67
+ raise
68
+ finally:
69
+ await cache_resources.close()
70
+
71
+ prepared = OwnedProxyResponse(cache_stream_generator(), close_callback=cache_resources.close,
72
+ media_type="application/zip", headers=cache_headers)
73
+ cache_handed_off = True
74
+ return prepared
75
+ print(f"[ZIP缓存] GET失败,回退GitHub: status={stream_resp.status_code}")
76
+ except asyncio.CancelledError:
77
+ raise
78
+ except Exception as error:
79
+ print(f"[ZIP缓存] 查询失败,回退GitHub: error={type(error).__name__}")
80
+ finally:
81
+ if cache_resources is not None and not cache_handed_off:
82
+ await cache_resources.close()
83
+
84
+ # 先取得完整commit,再按它下载;不在下载后另取“最新版本”冒充实际内容。
85
+ live_commit = await resolve_commit(repo_url, active_token)
86
+ if not live_commit:
87
+ return error_response(content={"error": "资源版本���时无法核实,请稍后重试"}, status_code=502)
88
+ github_zip_api = f"https://api.github.com/repos/{owner}/{repo}/zipball/{live_commit}"
89
+
90
+ headers = {
91
+ "Accept": "application/vnd.github.v3+json",
92
+ "User-Agent": "ComfyUI-Ranking-SaaS"
93
+ }
94
+ if active_token:
95
+ headers["Authorization"] = f"Bearer {active_token}"
96
+
97
+ # 异步请求固定GitHub commit,原始块保持长度与Content-Encoding契约。
98
+ client = client_factory(
99
+ follow_redirects=False, trust_env=False, transport=transport_factory(),
100
+ )
101
+ resources = ProxyResources(client)
102
+ handed_off = False
103
+ try:
104
+ try:
105
+ response = resources.track(await send(
106
+ client, "GET", github_zip_api, headers=headers, timeout=120))
107
+ except Exception as error:
108
+ print(f"[proxy_github_zip] 请求失败: error={type(error).__name__}")
109
+ return error_response(content={"error": "代理下载时发生网络异常,请稍后重试"}, status_code=502)
110
+ if response.status_code != 200:
111
+ status_map = {404: "仓库不存在或无权访问", 401: "Token 无效或已过期", 403: "访问被拒绝(可能触发速率限制)"}
112
+ err_msg = status_map.get(response.status_code, f"GitHub API 返回 HTTP {response.status_code}")
113
+ return error_response(content={"error": f"GITHUB_DOWNLOAD_FAILED: {err_msg}"}, status_code=502)
114
+ declared_length = zip_response_length(response.headers, max_bytes=direct_max_bytes)
115
+ resp_headers = {}
116
+ content_length = response.headers.get('content-length', '')
117
+ if content_length:
118
+ resp_headers["Content-Length"] = content_length
119
+ content_encoding = response.headers.get('content-encoding')
120
+ if content_encoding:
121
+ resp_headers["Content-Encoding"] = content_encoding
122
+ resp_headers["X-Installed-Version"] = live_commit
123
+
124
+ async def stream_generator():
125
+ try:
126
+ async for chunk in stream_zip_bytes(response, max_bytes=direct_max_bytes, length=declared_length):
127
+ yield chunk
128
+ except Exception as error:
129
+ print(f"[proxy_github_zip] 响应流中断: error={type(error).__name__}")
130
+ raise
131
+ finally:
132
+ await resources.close()
133
+
134
+ prepared = OwnedProxyResponse(stream_generator(), close_callback=resources.close,
135
+ media_type="application/zip", headers=resp_headers)
136
+ handed_off = True
137
+ return prepared
138
+ except asyncio.CancelledError:
139
+ raise
140
+ except Exception as error:
141
+ print(f"[proxy_github_zip] 响应准备失败: error={type(error).__name__}")
142
+ return error_response(content={"error": "代理下载时发生网络异常,请稍后重试"}, status_code=502)
143
+ finally:
144
+ if not handed_off:
145
+ await resources.close()
hf_snapshot_store.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HF私有临时快照生命周期;创建/回收必须持同目录公共提交锁。"""
2
+ from contextlib import contextmanager
3
+ import json
4
+ import logging
5
+ import os
6
+ from pathlib import Path
7
+ import re
8
+ import stat
9
+ import uuid
10
+
11
+
12
+ logger=logging.getLogger('ComfyUI-Ranking.DB')
13
+
14
+
15
+ class SnapshotStore:
16
+ MANIFEST='snapshot-owner.json'
17
+ NAME=re.compile(r'\.hf-sync-[0-9a-f]{32}\Z')
18
+ PAYLOAD=re.compile(r'(0|[1-9][0-9]*)\.json\Z')
19
+
20
+ def __init__(self,directory):
21
+ self.root=Path(os.path.abspath(directory))
22
+
23
+ @staticmethod
24
+ def _check(path,directory=False):
25
+ info=os.lstat(path)
26
+ if (getattr(info,'st_file_attributes',0)&1024
27
+ or not (stat.S_ISDIR(info.st_mode) if directory else stat.S_ISREG(info.st_mode))
28
+ or not info.st_ino):
29
+ raise ValueError('invalid snapshot path')
30
+ return (info.st_dev,info.st_ino)
31
+
32
+ def _owner(self,path):
33
+ if path.parent!=self.root or not self.NAME.fullmatch(path.name):
34
+ raise ValueError('unknown snapshot directory')
35
+ self._check(self.root,directory=True)
36
+ directory_id=self._check(path,directory=True)
37
+ marker=path/self.MANIFEST
38
+ marker_id=self._check(marker)
39
+ descriptor=os.open(marker,os.O_RDONLY|getattr(os,'O_NOFOLLOW',0)
40
+ |getattr(os,'O_NONBLOCK',0)|getattr(os,'O_BINARY',0))
41
+ with os.fdopen(descriptor,'rb') as stream:
42
+ info=os.fstat(stream.fileno())
43
+ if not stat.S_ISREG(info.st_mode) or (info.st_dev,info.st_ino)!=marker_id:
44
+ raise ValueError('snapshot owner identity changed')
45
+ data=stream.read(4097)
46
+ if len(data)>4096:raise ValueError('snapshot owner too large')
47
+ owner=json.loads(data)
48
+ if (not isinstance(owner,dict) or set(owner)!={'schema','kind','directory'}
49
+ or type(owner['schema']) is not int or owner['schema']!=1
50
+ or owner['kind']!='ranking-hf-snapshot' or owner['directory']!=path.name):
51
+ raise ValueError('invalid snapshot owner')
52
+ if self._check(path,directory=True)!=directory_id or self._check(marker)!=marker_id:
53
+ raise ValueError('snapshot identity changed')
54
+ return directory_id
55
+
56
+ def remove(self,path):
57
+ """只删除验证归属且内容全为普通快照文件的本层目录,不递归删除。"""
58
+ path=Path(path)
59
+ directory_id=self._owner(path)
60
+ entries=[]
61
+ for entry in path.iterdir():
62
+ if len(entries)>=4096:raise ValueError('too many snapshot files')
63
+ if entry.name!=self.MANIFEST and not self.PAYLOAD.fullmatch(entry.name):
64
+ raise ValueError('unknown snapshot content')
65
+ self._check(entry)
66
+ entries.append(entry)
67
+ # 先完整校验,再删除数据,归属记录最后删除,失败可在后续接手。
68
+ for entry in sorted(entries,key=lambda entry:entry.name==self.MANIFEST):
69
+ self._check(self.root,directory=True)
70
+ if self._check(path,directory=True)!=directory_id:
71
+ raise ValueError('snapshot directory changed')
72
+ entry.unlink()
73
+ path.rmdir()
74
+
75
+ def cleanup_stale(self):
76
+ self._check(self.root,directory=True)
77
+ removed=retained=0
78
+ for path in self.root.glob('.hf-sync-*'):
79
+ try:self.remove(path);removed+=1
80
+ except FileNotFoundError:
81
+ if path.exists() or path.is_symlink():retained+=1
82
+ except (OSError,ValueError):retained+=1
83
+ return removed,retained
84
+
85
+ @contextmanager
86
+ def temporary(self):
87
+ self._check(self.root,directory=True)
88
+ path=self.root/('.hf-sync-'+uuid.uuid4().hex)
89
+ path.mkdir(mode=0o700)
90
+ try:
91
+ with (path/self.MANIFEST).open('xb') as stream:
92
+ data=json.dumps({'schema':1,'kind':'ranking-hf-snapshot','directory':path.name}).encode('utf-8')
93
+ stream.write(data);stream.flush();os.fsync(stream.fileno())
94
+ yield str(path)
95
+ finally:
96
+ try:self.remove(path)
97
+ except FileNotFoundError:
98
+ if path.exists() or path.is_symlink():
99
+ logger.warning('HF snapshot cleanup unavailable; retained for recovery')
100
+ except (OSError,ValueError):
101
+ logger.warning('HF snapshot cleanup unavailable; retained for recovery')
hf_sync_journal.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """同目录HF待同步写前记录;调用方持业务文件锁,提交方另持公共提交锁。"""
2
+ import hashlib
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import stat
7
+ import tempfile
8
+ import uuid
9
+
10
+
11
+ class SyncJournal:
12
+ def __init__(self,directory):
13
+ self.directory=Path(directory)/'.hf-pending'
14
+
15
+ @staticmethod
16
+ def validate_name(name):
17
+ if (not isinstance(name,str) or not name or name in {'.','..'}
18
+ or os.path.normcase(name)==os.path.normcase('__hf_dataset_sync__')
19
+ or any(char in name for char in '/\\:') or any(ord(char)<32 for char in name)):
20
+ raise ValueError('invalid sync file name')
21
+
22
+ def path(self,name):
23
+ self.validate_name(name)
24
+ key=hashlib.sha256(os.path.normcase(name).encode('utf-8')).hexdigest()
25
+ return self.directory/(key+'.json')
26
+
27
+ @staticmethod
28
+ def check_regular(path,directory=False):
29
+ info=os.lstat(path)
30
+ if (stat.S_ISLNK(info.st_mode) or getattr(info,'st_file_attributes',0)&1024
31
+ or not (stat.S_ISDIR(info.st_mode) if directory else stat.S_ISREG(info.st_mode))):
32
+ raise ValueError('invalid sync journal path')
33
+
34
+ def mark(self,name):
35
+ target=self.path(name)
36
+ self.directory.mkdir(exist_ok=True)
37
+ self.check_regular(self.directory,directory=True)
38
+ if target.exists() or target.is_symlink(): self.check_regular(target)
39
+ generation=uuid.uuid4().hex
40
+ data=json.dumps({'schema':1,'file':name,'generation':generation},ensure_ascii=False).encode('utf-8')
41
+ if len(data)>4096: raise ValueError('sync journal record too large')
42
+ descriptor,temporary=tempfile.mkstemp(prefix='.pending-',suffix='.tmp',dir=self.directory)
43
+ try:
44
+ with os.fdopen(descriptor,'wb') as stream:
45
+ stream.write(data); stream.flush(); os.fsync(stream.fileno())
46
+ os.replace(temporary,target)
47
+ finally:
48
+ try: os.unlink(temporary)
49
+ except FileNotFoundError: pass
50
+ return generation
51
+
52
+ def read_record(self,path):
53
+ self.check_regular(self.directory,directory=True)
54
+ self.check_regular(path)
55
+ descriptor=os.open(path,os.O_RDONLY|getattr(os,'O_NOFOLLOW',0))
56
+ with os.fdopen(descriptor,'rb') as stream:
57
+ if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode): raise ValueError('invalid sync journal file')
58
+ content=stream.read(4097)
59
+ if len(content)>4096: raise ValueError('sync journal record too large')
60
+ data=json.loads(content)
61
+ if (not isinstance(data,dict) or set(data)!={'schema','file','generation'}
62
+ or type(data['schema']) is not int or data['schema']!=1):
63
+ raise ValueError('invalid sync journal schema')
64
+ self.validate_name(data['file'])
65
+ generation=data['generation']
66
+ if (not isinstance(generation,str) or len(generation)!=32
67
+ or any(char not in '0123456789abcdef' for char in generation)
68
+ or self.path(data['file'])!=path):
69
+ raise ValueError('invalid sync journal identity')
70
+ return data
71
+
72
+ def read(self,name):
73
+ try: return self.read_record(self.path(name))['generation']
74
+ except FileNotFoundError: return None
75
+
76
+ def pending(self,on_invalid=None):
77
+ """默认严格读取;同步方可隔离坏记录,目录本身故障仍整体拒绝。"""
78
+ if not self.directory.exists():
79
+ if self.directory.is_symlink(): raise ValueError('invalid sync journal directory')
80
+ return set()
81
+ self.check_regular(self.directory,directory=True)
82
+ names=set()
83
+ for path in self.directory.glob('*.json'):
84
+ try: names.add(self.read_record(path)['file'])
85
+ except FileNotFoundError: pass # 其他提交方已确认该记录。
86
+ except (OSError,ValueError) as error:
87
+ if on_invalid is None: raise
88
+ on_invalid(error)
89
+ return names
90
+
91
+ def acknowledge(self,name,generation):
92
+ if (not isinstance(generation,str) or len(generation)!=32
93
+ or any(char not in '0123456789abcdef' for char in generation)):
94
+ return False
95
+ if self.read(name)!=generation: return False
96
+ os.unlink(self.path(name))
97
+ return True
hf_sync_scheduler.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """单工作线程同步调度;持久待同步记录由调用方管理。"""
2
+ import math
3
+ import threading
4
+ import time
5
+
6
+
7
+ class SyncScheduler:
8
+ MAX_PENDING_NAMES = 128
9
+
10
+ def __init__(self, sync, interval, on_error, cooldown=None):
11
+ self._sync = sync
12
+ self._interval = interval
13
+ self._on_error = on_error
14
+ self._cooldown = cooldown or (lambda: 0)
15
+ self._condition = threading.Condition()
16
+ self._state = 'new'
17
+ self._thread = None
18
+ self._pending = set()
19
+ self._all_pending = False
20
+
21
+ def _delay(self):
22
+ delay = float(self._interval())
23
+ if not math.isfinite(delay) or delay <= 0:
24
+ raise ValueError('invalid sync interval')
25
+ return delay
26
+
27
+ def _start_locked(self, restart):
28
+ if self._state == 'running':
29
+ return False
30
+ if self._state == 'stopping' or (self._state == 'stopped' and not restart):
31
+ return False
32
+ self._delay()
33
+ self._state = 'running'
34
+ self._thread = threading.Thread(target=self._run, name='ranking-hf-sync', daemon=True)
35
+ try:
36
+ self._thread.start()
37
+ except Exception:
38
+ self._state = 'new'
39
+ self._thread = None
40
+ raise
41
+ return True
42
+
43
+ def start(self, restart=False):
44
+ with self._condition:
45
+ return self._start_locked(restart)
46
+
47
+ def request(self, file_name):
48
+ with self._condition:
49
+ if self._state in {'stopping', 'stopped'}:
50
+ return False
51
+ if not self._all_pending:
52
+ self._pending.add(file_name)
53
+ if len(self._pending) > self.MAX_PENDING_NAMES:
54
+ self._pending.clear()
55
+ self._all_pending = True
56
+ self._start_locked(False)
57
+ self._condition.notify_all()
58
+ return True
59
+
60
+ def close(self):
61
+ with self._condition:
62
+ thread = self._thread
63
+ if thread is threading.current_thread():
64
+ raise RuntimeError('sync worker cannot join itself')
65
+ self._state = 'stopping'
66
+ self._pending.clear()
67
+ self._all_pending = False
68
+ self._condition.notify_all()
69
+ if thread is not None:
70
+ thread.join()
71
+ with self._condition:
72
+ if self._thread is thread:
73
+ self._state = 'stopped'
74
+ self._thread = None
75
+
76
+ def _run(self):
77
+ try:
78
+ deadline = time.monotonic() + self._delay()
79
+ while True:
80
+ with self._condition:
81
+ while self._state == 'running':
82
+ periodic = time.monotonic() >= deadline
83
+ if periodic or self._pending or self._all_pending:
84
+ cooldown = float(self._cooldown())
85
+ if not math.isfinite(cooldown) or cooldown < 0:
86
+ raise ValueError('invalid sync cooldown')
87
+ if cooldown > 0:
88
+ # 远期服务端日期也不能超过平台锁等待上限。
89
+ self._condition.wait(min(cooldown, 60))
90
+ continue
91
+ names = None if periodic or self._all_pending else sorted(self._pending)
92
+ self._pending.clear()
93
+ self._all_pending = False
94
+ break
95
+ self._condition.wait(min(60, max(0, deadline - time.monotonic())))
96
+ else:
97
+ return
98
+ try:
99
+ self._sync(names)
100
+ except Exception as error:
101
+ self._on_error(error)
102
+ # 即时请求不能无限推迟正常批量窗口。
103
+ if periodic:
104
+ deadline = time.monotonic() + self._delay()
105
+ finally:
106
+ with self._condition:
107
+ if self._state == 'running':
108
+ self._state = 'stopped'
109
+ self._condition.notify_all()
image_file_budget.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bounded image container inspection; never decompress pixels or retain image payloads."""
2
+ import os
3
+ import time
4
+
5
+ MAX_EDGE = 16_000
6
+ MAX_PIXELS = 40_000_000
7
+ MAX_FRAMES = 240
8
+ MAX_ANIMATION_PIXELS = 400_000_000
9
+ MAX_FILE_BYTES = 50 * 1024 * 1024
10
+ MAX_STEPS = 500_000
11
+ SCAN_SECONDS = 3
12
+ JPEG_HEADER_BYTES = 256 * 1024
13
+
14
+ class ImageBudgetError(ValueError):
15
+ pass
16
+
17
+ def _bad():
18
+ raise ImageBudgetError('invalid or over-budget image container')
19
+
20
+ def _budget(width, height, frames=1):
21
+ if not 0 < width <= MAX_EDGE or not 0 < height <= MAX_EDGE or width * height > MAX_PIXELS:
22
+ _bad()
23
+ if not 0 < frames <= MAX_FRAMES or width * height * frames > MAX_ANIMATION_PIXELS:
24
+ _bad()
25
+
26
+ class _Reader:
27
+ def __init__(self, file):
28
+ self.file = file
29
+ self.size = os.fstat(file.fileno()).st_size
30
+ self.deadline = time.monotonic() + SCAN_SECONDS
31
+ self.steps = 0
32
+ if not 0 < self.size <= MAX_FILE_BYTES: _bad()
33
+ def check(self, length):
34
+ self.steps += 1
35
+ if self.steps > MAX_STEPS or time.monotonic() > self.deadline or length < 0 or self.file.tell() + length > self.size:
36
+ _bad()
37
+ def read(self, length):
38
+ self.check(length)
39
+ if length > 32: _bad()
40
+ result = self.file.read(length)
41
+ if len(result) != length: _bad()
42
+ return result
43
+ def skip(self, length):
44
+ self.check(length)
45
+ self.file.seek(length, 1)
46
+ def pos(self): return self.file.tell()
47
+
48
+ def _png(r):
49
+ if r.read(8) != b'\x89PNG\r\n\x1a\n': _bad()
50
+ if r.read(8) != b'\x00\x00\x00\rIHDR': _bad()
51
+ header = r.read(13)
52
+ width, height = int.from_bytes(header[:4], 'big'), int.from_bytes(header[4:8], 'big')
53
+ _budget(width, height)
54
+ r.skip(4)
55
+ declared, frames, data, first_control_before_data = None, 0, False, False
56
+ while r.pos() < r.size:
57
+ head = r.read(8); length, kind = int.from_bytes(head[:4], 'big'), head[4:]
58
+ if kind == b'acTL':
59
+ if declared is not None or data or length != 8: _bad()
60
+ declared = int.from_bytes(r.read(8)[:4], 'big'); _budget(width, height, declared)
61
+ elif kind == b'fcTL':
62
+ if declared is None or length != 26: _bad()
63
+ control = r.read(26)
64
+ w, h, x, y = [int.from_bytes(control[n:n+4], 'big') for n in (4,8,12,16)]
65
+ if not w or not h or x + w > width or y + h > height: _bad()
66
+ if frames == 0: first_control_before_data = not data
67
+ frames += 1; _budget(width, height, frames)
68
+ elif kind == b'IDAT':
69
+ data = True; r.skip(length)
70
+ elif kind == b'IEND':
71
+ if length != 0 or not data or (declared is not None and frames != declared): _bad()
72
+ r.skip(4)
73
+ if r.pos() != r.size: _bad()
74
+ total = frames + (0 if first_control_before_data else 1) if declared is not None else 1
75
+ _budget(width, height, total)
76
+ return width, height, total
77
+ else:
78
+ r.skip(length)
79
+ r.skip(4)
80
+ _bad()
81
+
82
+ def _gif(r):
83
+ if r.read(6) not in (b'GIF87a', b'GIF89a'): _bad()
84
+ head = r.read(7); width, height = int.from_bytes(head[:2],'little'), int.from_bytes(head[2:4],'little')
85
+ _budget(width,height)
86
+ if head[4] & 128: r.skip(3 * (1 << ((head[4] & 7) + 1)))
87
+ def blocks():
88
+ while True:
89
+ size = r.read(1)[0]
90
+ if not size: return
91
+ r.skip(size)
92
+ frames = 0
93
+ while r.pos() < r.size:
94
+ kind = r.read(1)[0]
95
+ if kind == 0x21:
96
+ r.read(1); blocks()
97
+ elif kind == 0x2c:
98
+ image = r.read(9)
99
+ x,y,w,h = [int.from_bytes(image[n:n+2],'little') for n in (0,2,4,6)]
100
+ if not w or not h or x+w > width or y+h > height: _bad()
101
+ frames += 1; _budget(width,height,frames)
102
+ if image[8] & 128: r.skip(3 * (1 << ((image[8] & 7) + 1)))
103
+ if not 2 <= r.read(1)[0] <= 8: _bad()
104
+ blocks()
105
+ elif kind == 0x3b:
106
+ if not frames or r.pos() != r.size: _bad()
107
+ return width,height,frames
108
+ else: _bad()
109
+ _bad()
110
+
111
+ def _jpeg(r):
112
+ if r.read(2) != b'\xff\xd8': _bad()
113
+ while r.pos() < min(r.size, JPEG_HEADER_BYTES):
114
+ if r.read(1) != b'\xff': _bad()
115
+ marker = r.read(1)[0]
116
+ while marker == 0xff: marker = r.read(1)[0]
117
+ if marker in (0,0xd8,0xd9,0xda) or 0xd0 <= marker <= 0xd7: _bad()
118
+ length = int.from_bytes(r.read(2),'big')
119
+ if length < 2 or r.pos() + length - 2 > min(r.size, JPEG_HEADER_BYTES): _bad()
120
+ if 0xc0 <= marker <= 0xcf and marker not in (0xc4,0xc8,0xcc):
121
+ if length < 8: _bad()
122
+ head = r.read(6); height, width = int.from_bytes(head[1:3],'big'), int.from_bytes(head[3:5],'big')
123
+ _budget(width,height)
124
+ return width,height,1
125
+ r.skip(length-2)
126
+ _bad()
127
+
128
+ def _webp(r):
129
+ head = r.read(12)
130
+ if head[:4] != b'RIFF' or head[8:] != b'WEBP' or int.from_bytes(head[4:8],'little')+8 != r.size: _bad()
131
+ canvas, frames, animated, animation_header, static = None, 0, False, False, False
132
+ def codec(kind,length):
133
+ if kind == b'VP8 ':
134
+ if length < 10: _bad()
135
+ h = r.read(10)
136
+ if h[3:6] != b'\x9d\x01\x2a' or h[0] & 1: _bad()
137
+ result = int.from_bytes(h[6:8],'little') & 0x3fff, int.from_bytes(h[8:10],'little') & 0x3fff
138
+ r.skip(length-10)
139
+ elif kind == b'VP8L':
140
+ if length < 5: _bad()
141
+ h = r.read(5)
142
+ if h[0] != 0x2f: _bad()
143
+ bits = int.from_bytes(h[1:],'little')
144
+ if bits >> 29: _bad()
145
+ result = (bits & 0x3fff)+1, ((bits >> 14) & 0x3fff)+1
146
+ r.skip(length-5)
147
+ else: _bad()
148
+ _budget(*result)
149
+ return result
150
+ while r.pos() < r.size:
151
+ head = r.read(8); kind, length = head[:4], int.from_bytes(head[4:],'little')
152
+ r.check(length+(length & 1)); end = r.pos()+length
153
+ if kind == b'VP8X':
154
+ if canvas is not None or length != 10: _bad()
155
+ h=r.read(10); canvas=(int.from_bytes(h[4:7],'little')+1,int.from_bytes(h[7:10],'little')+1)
156
+ _budget(*canvas); animated=bool(h[0] & 2)
157
+ elif kind == b'ANIM':
158
+ if not animated or length != 6 or animation_header: _bad()
159
+ animation_header=True; r.skip(length)
160
+ elif kind == b'ANMF':
161
+ if not animated or not animation_header or canvas is None or length < 16: _bad()
162
+ h=r.read(16); x,y=int.from_bytes(h[:3],'little')*2,int.from_bytes(h[3:6],'little')*2
163
+ w,hh=int.from_bytes(h[6:9],'little')+1,int.from_bytes(h[9:12],'little')+1
164
+ if x+w > canvas[0] or y+hh > canvas[1]: _bad()
165
+ frames+=1; _budget(*canvas,frames)
166
+ found=False
167
+ while r.pos()<end:
168
+ chunk=r.read(8); sub,size=chunk[:4],int.from_bytes(chunk[4:],'little')
169
+ if r.pos()+size+(size & 1)>end: _bad()
170
+ if sub in (b'VP8 ',b'VP8L'):
171
+ if found or codec(sub,size)!=(w,hh): _bad()
172
+ found=True
173
+ else: r.skip(size)
174
+ if size & 1: r.skip(1)
175
+ if not found: _bad()
176
+ elif kind in (b'VP8 ',b'VP8L'):
177
+ if animated or static: _bad()
178
+ size=codec(kind,length)
179
+ if canvas is not None and size!=canvas: _bad()
180
+ canvas=size;static=True
181
+ else: r.skip(length)
182
+ if r.pos()!=end: _bad()
183
+ if length & 1: r.skip(1)
184
+ if canvas is None or (animated and not frames) or (not animated and not static): _bad()
185
+ return canvas[0], canvas[1], frames if animated else 1
186
+
187
+ def inspect_image_file(path, ext):
188
+ parser={'png':_png,'gif':_gif,'jpg':_jpeg,'jpeg':_jpeg,'webp':_webp}.get(ext)
189
+ if parser is None: _bad()
190
+ with open(path,'rb') as file:
191
+ width,height,frames=parser(_Reader(file))
192
+ return {'width':width,'height':height,'frames':frames}
image_moderation.py CHANGED
@@ -58,7 +58,7 @@ def _load_quota() -> dict:
58
  return {"month": current_month, "aliyun": 0, "tencent": 0}
59
  return data
60
  except Exception as e:
61
- logger.debug(f"加载额度记录失败: {e}")
62
  return {"month": current_month, "aliyun": 0, "tencent": 0}
63
 
64
  def _save_quota(quota: dict):
@@ -67,7 +67,7 @@ def _save_quota(quota: dict):
67
  with open(QUOTA_FILE, "w") as f:
68
  json.dump(quota, f)
69
  except Exception as e:
70
- logger.warning(f"保存额度记录失败: {e}")
71
 
72
  def _increment_quota(provider: str):
73
  """增加使用次数"""
@@ -196,31 +196,39 @@ async def moderate_image_tencent(image_content: bytes) -> ModerationResult:
196
  result = resp.json()
197
 
198
  response = result.get("Response", {})
 
 
 
 
199
 
200
  if "Error" in response:
201
- logger.error(f"腾讯云审核API错误: {response.get('Error', 'unknown')!r}")
202
- return ModerationResult(passed=True, suggestion="pass",
203
- details={"error": response["Error"]})
204
 
205
  # 解析审核结果
206
- suggestion = response.get("Suggestion", "Pass").lower()
 
 
 
 
207
  label = response.get("Label", "")
208
  score = response.get("Score", 0)
209
 
210
  passed = suggestion == "pass"
211
 
212
- logger.info(f"腾讯云审核结果: suggestion={suggestion}, label={label}, score={score}")
213
 
214
  return ModerationResult(
215
  passed=passed,
216
  label=label,
217
  confidence=score,
218
  suggestion=suggestion,
219
- details=response
220
  )
221
 
222
  except Exception as e:
223
- logger.error(f"腾讯云审核异常: {e!r}")
224
  # 审核服务异常时拒绝,需要人工审核
225
  return ModerationResult(passed=False, suggestion="review",
226
  details={"error": "审核服务暂时不可用,需人工审核"})
@@ -321,15 +329,21 @@ async def moderate_image_aliyun(image_content: bytes) -> ModerationResult:
321
 
322
  # 解析审核结果
323
  if result.get("code") != 200:
324
- logger.error(f"阿里云审核API错误: {result}")
325
- return ModerationResult(passed=True, suggestion="pass",
326
- details={"error": result})
327
 
328
  data = result.get("data", [])
329
  if not data:
330
- return ModerationResult(passed=True, suggestion="pass")
 
 
331
 
332
  task_result = data[0].get("results", [])
 
 
 
 
333
 
334
  # 检查所有场景的审核结果
335
  max_rate = 0
@@ -338,7 +352,11 @@ async def moderate_image_aliyun(image_content: bytes) -> ModerationResult:
338
 
339
  for scene_result in task_result:
340
  scene = scene_result.get("scene", "")
341
- suggestion = scene_result.get("suggestion", "pass")
 
 
 
 
342
  rate = scene_result.get("rate", 0)
343
  label = scene_result.get("label", "")
344
 
@@ -355,18 +373,18 @@ async def moderate_image_aliyun(image_content: bytes) -> ModerationResult:
355
 
356
  passed = overall_suggestion == "pass"
357
 
358
- logger.info(f"阿里云审核结果: suggestion={overall_suggestion}, label={block_label}, rate={max_rate}")
359
 
360
  return ModerationResult(
361
  passed=passed,
362
  label=block_label,
363
  confidence=max_rate * 100,
364
  suggestion=overall_suggestion,
365
- details=result
366
  )
367
 
368
  except Exception as e:
369
- logger.error(f"阿里云审核异常: {str(e)}")
370
  # 审核服务异常时拒绝,需要人工审核
371
  return ModerationResult(passed=False, suggestion="review",
372
  details={"error": "审核服务暂时不可用,需人工审核"})
@@ -397,7 +415,7 @@ def _is_moderation_enabled() -> bool:
397
  finally:
398
  db.close()
399
  except Exception as e:
400
- logger.debug(f"从数据库读取审核配置失败,降级到文件: {e}")
401
 
402
  # 2️⃣ 降级到文件
403
  try:
@@ -407,7 +425,7 @@ def _is_moderation_enabled() -> bool:
407
  if "image_moderation_enabled" in config:
408
  return config["image_moderation_enabled"] == True
409
  except Exception as e:
410
- logger.debug(f"读取系统配置文件失败: {e}")
411
 
412
  # 3️⃣ 回退到环境变量
413
  return MODERATION_ENABLED
 
58
  return {"month": current_month, "aliyun": 0, "tencent": 0}
59
  return data
60
  except Exception as e:
61
+ logger.debug(f"加载额度记录失败: error_type={type(e).__name__}")
62
  return {"month": current_month, "aliyun": 0, "tencent": 0}
63
 
64
  def _save_quota(quota: dict):
 
67
  with open(QUOTA_FILE, "w") as f:
68
  json.dump(quota, f)
69
  except Exception as e:
70
+ logger.warning(f"保存额度记录失败: error_type={type(e).__name__}")
71
 
72
  def _increment_quota(provider: str):
73
  """增加使用次数"""
 
196
  result = resp.json()
197
 
198
  response = result.get("Response", {})
199
+ if not isinstance(response, dict) or not response:
200
+ logger.error("IMAGE_MODERATION_API_FAIL | provider=tencent | category=empty_result")
201
+ return ModerationResult(passed=False, suggestion="review",
202
+ details={"error": "审核服务暂时不可用,需人工审核"})
203
 
204
  if "Error" in response:
205
+ logger.error("IMAGE_MODERATION_API_FAIL | provider=tencent | category=provider_error")
206
+ return ModerationResult(passed=False, suggestion="review",
207
+ details={"error": "审核服务暂时不可用,需人工审核"})
208
 
209
  # 解析审核结果
210
+ suggestion = str(response.get("Suggestion", "")).lower()
211
+ if suggestion not in {"pass", "review", "block"}:
212
+ logger.error("IMAGE_MODERATION_API_FAIL | provider=tencent | category=invalid_result")
213
+ return ModerationResult(passed=False, suggestion="review",
214
+ details={"error": "审核服务暂时不可用,需人工审核"})
215
  label = response.get("Label", "")
216
  score = response.get("Score", 0)
217
 
218
  passed = suggestion == "pass"
219
 
220
+ logger.info(f"IMAGE_MODERATION_RESULT | provider=tencent | suggestion={suggestion}")
221
 
222
  return ModerationResult(
223
  passed=passed,
224
  label=label,
225
  confidence=score,
226
  suggestion=suggestion,
227
+ details={"provider": "tencent"}
228
  )
229
 
230
  except Exception as e:
231
+ logger.error(f"IMAGE_MODERATION_FAIL | provider=tencent | error_type={type(e).__name__}")
232
  # 审核服务异常时拒绝,需要人工审核
233
  return ModerationResult(passed=False, suggestion="review",
234
  details={"error": "审核服务暂时不可用,需人工审核"})
 
329
 
330
  # 解析审核结果
331
  if result.get("code") != 200:
332
+ logger.error("IMAGE_MODERATION_API_FAIL | provider=aliyun | category=provider_error")
333
+ return ModerationResult(passed=False, suggestion="review",
334
+ details={"error": "审核服务暂时不可用,需人工审核"})
335
 
336
  data = result.get("data", [])
337
  if not data:
338
+ logger.error("IMAGE_MODERATION_API_FAIL | provider=aliyun | category=empty_result")
339
+ return ModerationResult(passed=False, suggestion="review",
340
+ details={"error": "审核服务暂时不可用,需人工审核"})
341
 
342
  task_result = data[0].get("results", [])
343
+ if not task_result:
344
+ logger.error("IMAGE_MODERATION_API_FAIL | provider=aliyun | category=empty_result")
345
+ return ModerationResult(passed=False, suggestion="review",
346
+ details={"error": "审核服务暂时不可用,需人工审核"})
347
 
348
  # 检查所有场景的审核结果
349
  max_rate = 0
 
352
 
353
  for scene_result in task_result:
354
  scene = scene_result.get("scene", "")
355
+ suggestion = str(scene_result.get("suggestion", "")).lower()
356
+ if suggestion not in {"pass", "review", "block"}:
357
+ logger.error("IMAGE_MODERATION_API_FAIL | provider=aliyun | category=invalid_result")
358
+ return ModerationResult(passed=False, suggestion="review",
359
+ details={"error": "审核服务暂时不可用,需人工审核"})
360
  rate = scene_result.get("rate", 0)
361
  label = scene_result.get("label", "")
362
 
 
373
 
374
  passed = overall_suggestion == "pass"
375
 
376
+ logger.info(f"IMAGE_MODERATION_RESULT | provider=aliyun | suggestion={overall_suggestion}")
377
 
378
  return ModerationResult(
379
  passed=passed,
380
  label=block_label,
381
  confidence=max_rate * 100,
382
  suggestion=overall_suggestion,
383
+ details={"provider": "aliyun"}
384
  )
385
 
386
  except Exception as e:
387
+ logger.error(f"阿里云审核异常: error_type={type(e).__name__}")
388
  # 审核服务异常时拒绝,需要人工审核
389
  return ModerationResult(passed=False, suggestion="review",
390
  details={"error": "审核服务暂时不可用,需人工审核"})
 
415
  finally:
416
  db.close()
417
  except Exception as e:
418
+ logger.debug(f"从数据库读取审核配置失败,降级到文件: error_type={type(e).__name__}")
419
 
420
  # 2️⃣ 降级到文件
421
  try:
 
425
  if "image_moderation_enabled" in config:
426
  return config["image_moderation_enabled"] == True
427
  except Exception as e:
428
+ logger.debug(f"读取系统配置文件失败: error_type={type(e).__name__}")
429
 
430
  # 3️⃣ 回退到环境变量
431
  return MODERATION_ENABLED
json_file_lock.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """同一数据目录内的稳定协调锁,不锁会被原子替换的JSON inode。"""
2
+ import hashlib
3
+ import os
4
+ import sys
5
+ import threading
6
+ import time
7
+
8
+
9
+ class DataFileLock:
10
+ def __init__(self, directory, file_name, timeout=30):
11
+ if (not isinstance(file_name, str) or not file_name or file_name in ('.', '..')
12
+ or any(char in file_name for char in '/\\:') or any(ord(char) < 32 for char in file_name)):
13
+ raise ValueError('invalid data file name')
14
+ self.directory = os.path.abspath(directory)
15
+ self.path = os.path.join(self.directory, '.locks', hashlib.sha256(os.path.normcase(file_name).encode()).hexdigest() + '.lock')
16
+ self.timeout = timeout
17
+ self.thread_lock = threading.Lock()
18
+ self.handle = None
19
+
20
+ def __enter__(self):
21
+ self.thread_lock.acquire()
22
+ handle = None
23
+ try:
24
+ os.makedirs(os.path.dirname(self.path), exist_ok=True)
25
+ handle = open(self.path, 'a+b')
26
+ # Windows允许锁定EOF之外的字节;不初始化写入,避免首次并发争用锁区。
27
+ deadline = time.monotonic() + self.timeout
28
+ while True:
29
+ try:
30
+ if sys.platform == 'win32':
31
+ import msvcrt
32
+ handle.seek(0)
33
+ msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
34
+ else:
35
+ import fcntl
36
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
37
+ break
38
+ except OSError as error:
39
+ import errno
40
+ if error.errno not in (errno.EACCES, errno.EAGAIN, errno.EDEADLK):
41
+ raise
42
+ if time.monotonic() >= deadline:
43
+ raise TimeoutError('data coordination lock timed out') from None
44
+ time.sleep(.05)
45
+ self.handle = handle
46
+ return self
47
+ except BaseException:
48
+ if handle is not None:
49
+ handle.close()
50
+ self.thread_lock.release()
51
+ raise
52
+
53
+ def __exit__(self, exc_type, exc_value, traceback):
54
+ handle, self.handle = self.handle, None
55
+ try:
56
+ if sys.platform == 'win32':
57
+ import msvcrt
58
+ handle.seek(0)
59
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
60
+ else:
61
+ import fcntl
62
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
63
+ finally:
64
+ try:
65
+ handle.close()
66
+ finally:
67
+ self.thread_lock.release()
68
+ return False
media_proxy_service.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """公开媒体读取服务;仅代理既有允许目录与扩展名,不处理工作流下载。"""
2
+ import logging
3
+ import mimetypes
4
+ import os
5
+ import time
6
+ import urllib.parse
7
+ import uuid
8
+ from fastapi import HTTPException
9
+ from fastapi.responses import JSONResponse
10
+
11
+ logger = logging.getLogger("ComfyUI-Ranking")
12
+
13
+
14
+ def read_public_media(url=None, path=None, *, store, download, file_response, error_response):
15
+ """公开媒体服务:使用服务凭据读取既有允许目录中的媒体"""
16
+
17
+ # 兼容处理:如果本地发来的是已经被污染的老版本 HF 直链,我们自动将其转换为相对路径
18
+ if url and url.startswith("https://huggingface.co/datasets/"):
19
+ try:
20
+ path = url.split("resolve/main/")[-1]
21
+ path = urllib.parse.unquote(path)
22
+ except Exception:
23
+ raise HTTPException(status_code=400, detail="无效的 HF 原链接格式")
24
+
25
+ if not path:
26
+ raise HTTPException(status_code=400, detail="缺少路径参数")
27
+
28
+ # 🛡️ 路径穿越防护:禁止包含 .. 和绝对路径
29
+ if ".." in path or path.startswith("/"):
30
+ raise HTTPException(status_code=400, detail="非法路径")
31
+
32
+ # 🛡️ 绝对的安全红线:限制只能代理下载图片目录,严禁黑客通过此接口下载 users.json 或账本数据!
33
+ allowed_dirs = ["uploads/", "avatars/", "covers/"]
34
+ if not any(path.startswith(d) for d in allowed_dirs):
35
+ raise HTTPException(status_code=403, detail="非法访问:该接口仅允许代理图片资源")
36
+
37
+ media_extensions = {"jpg", "jpeg", "png", "gif", "webp", "mp4", "webm", "mov", "wav", "mp3", "m4a", "ogg"}
38
+ if path.rsplit(".", 1)[-1].lower() not in media_extensions:
39
+ raise HTTPException(status_code=403, detail="该接口不允许访问非媒体文件")
40
+
41
+ hf_token = os.environ.get("HF_TOKEN")
42
+ dataset_repo_id = store.DATASET_REPO_ID
43
+
44
+ try:
45
+ # hf_hub_download 会自动利用云端容器的缓存,只有第一次会去真实请求 Dataset
46
+ cached_file_path = download(
47
+ repo_id=dataset_repo_id,
48
+ repo_type="dataset",
49
+ filename=path,
50
+ token=hf_token
51
+ )
52
+ # 智能识别文件类型 (image/jpeg, image/png 等)
53
+ content_type, _ = mimetypes.guess_type(cached_file_path)
54
+ return file_response(cached_file_path, media_type=content_type or "application/octet-stream")
55
+ except Exception as e:
56
+ error_id = f"ERR_{int(time.time())}_{uuid.uuid4().hex[:8]}"
57
+ upstream_status = getattr(getattr(e, "response", None), "status_code", None)
58
+ if not isinstance(upstream_status, int):
59
+ upstream_status = getattr(e, "code", None)
60
+ status_text = f" status={upstream_status}" if isinstance(upstream_status, int) and 100 <= upstream_status <= 599 else ""
61
+ logger.warning(f"IMAGE_PROXY_FAIL | error_id={error_id} | error_type={type(e).__name__}{status_text}")
62
+ return JSONResponse(
63
+ status_code=502,
64
+ content=error_response(502, "图片暂时无法获取,请稍后重试", "upstream_error", error_id=error_id),
65
+ )
models.py CHANGED
@@ -79,6 +79,7 @@ class CommentCreate(BaseModel):
79
  parent_id: Optional[str] = None
80
 
81
  class ItemCreate(BaseModel):
 
82
  type: str
83
  title: str
84
  shortDesc: str
@@ -87,7 +88,7 @@ class ItemCreate(BaseModel):
87
  coverUrl: Optional[str] = None
88
  imageUrls: Optional[List[str]] = []
89
  author: str
90
- price: int = 0
91
  github_token: Optional[str] = None
92
  netdisk_password: Optional[str] = None # ☁️ 网盘提取码(加密存储,购买后解密)
93
  is_netdisk: Optional[bool] = False # ☁️ 是否为网盘资源
@@ -95,13 +96,14 @@ class ItemCreate(BaseModel):
95
  allow_refund: Optional[bool] = True # 💸 是否支持退款(默认支持)
96
 
97
  class ItemUpdate(BaseModel):
 
98
  title: Optional[str] = None
99
  shortDesc: Optional[str] = None
100
  fullDesc: Optional[str] = None
101
  link: Optional[str] = None
102
  coverUrl: Optional[str] = None
103
- imageUrls: Optional[List[str]] = []
104
- price: Optional[int] = None
105
  github_token: Optional[str] = None
106
  netdisk_password: Optional[str] = None # ☁️ 网盘提取码
107
  is_netdisk: Optional[bool] = None # ☁️ 是否为网盘资源
@@ -144,12 +146,16 @@ class RatingRequest(BaseModel):
144
 
145
  class RechargeRequest(BaseModel):
146
  account: str
147
- amount: int = Field(..., ge=1, le=10000) # 单次充值1-10000
148
  method: Optional[str] = "alipay"
149
 
150
  class PurchaseRequest(BaseModel):
151
  account: str
152
  item_id: str
 
 
 
 
153
 
154
  class TipRequest(BaseModel):
155
  sender_account: str
@@ -160,10 +166,10 @@ class TipRequest(BaseModel):
160
 
161
  class WithdrawRequest(BaseModel):
162
  account: str
163
- amount: int = Field(..., ge=1, le=5000) # 单次提现1-5000
164
- alipayAccount: str
165
- real_name: str
166
- code: str
167
 
168
 
169
  # ==========================================
@@ -175,8 +181,8 @@ class TaskCreate(BaseModel):
175
  description: str # 任务详情
176
  referenceImages: Optional[List[str]] = [] # 参考图(最多6张)
177
  referenceLink: Optional[str] = None # 参考链接
178
- totalPrice: int # 总价格(积分)
179
- depositRatio: int # 订金比例(10/20/30/50)
180
  deadline: str # 截止时间 (ISO格式)
181
  publisher: str # 发布者账号
182
 
@@ -186,8 +192,8 @@ class TaskUpdate(BaseModel):
186
  description: Optional[str] = None
187
  referenceImages: Optional[List[str]] = None
188
  referenceLink: Optional[str] = None
189
- totalPrice: Optional[int] = None
190
- depositRatio: Optional[int] = None
191
  deadline: Optional[str] = None
192
 
193
  # 🔧 P2修复:以下三个 body 模型修复前后端参数通道不匹配
@@ -251,7 +257,7 @@ class PromptCreate(BaseModel):
251
  audio_url: Optional[str] = None # 示例音频 URL(仅音乐提示词,图片+音频方案;视频可选)
252
  images: Optional[List[str]] = None # 图像提示词图片列表(最多9张,首张为封面)
253
  is_original: bool = False # 原创作品标记
254
- price: int = 0 # 定价(0=免费)
255
  tags: Optional[List[str]] = None # 自定义标签(最多10个,每个≤12字)
256
 
257
  class PromptUpdate(BaseModel):
@@ -266,10 +272,10 @@ class PromptUpdate(BaseModel):
266
  audio_url: Optional[str] = None # 示例音频 URL(仅音乐提示词;传 null 表示清除)
267
  images: Optional[List[str]] = None # 图像提示词图片列表(最多9张,首张为封面)
268
  is_original: Optional[bool] = None # 原创作品标记
269
- price: Optional[int] = None
270
  tags: Optional[List[str]] = None
271
 
272
  class PromptCategoryAdd(BaseModel):
273
  """ 添加提示词分类 """
274
  prompt_type: str # "image"、"video" 或 "music"
275
- name: str # 分类名称(≤8字,去重)
 
79
  parent_id: Optional[str] = None
80
 
81
  class ItemCreate(BaseModel):
82
+ request_id: Optional[str] = Field(default=None, strict=True, pattern=r'^[0-9a-f]{32}$')
83
  type: str
84
  title: str
85
  shortDesc: str
 
88
  coverUrl: Optional[str] = None
89
  imageUrls: Optional[List[str]] = []
90
  author: str
91
+ price: int = Field(default=0, ge=0, strict=True)
92
  github_token: Optional[str] = None
93
  netdisk_password: Optional[str] = None # ☁️ 网盘提取码(加密存储,购买后解密)
94
  is_netdisk: Optional[bool] = False # ☁️ 是否为网盘资源
 
96
  allow_refund: Optional[bool] = True # 💸 是否支持退款(默认支持)
97
 
98
  class ItemUpdate(BaseModel):
99
+ expected_revision: Optional[int] = Field(default=None, ge=0, le=9007199254740991, strict=True)
100
  title: Optional[str] = None
101
  shortDesc: Optional[str] = None
102
  fullDesc: Optional[str] = None
103
  link: Optional[str] = None
104
  coverUrl: Optional[str] = None
105
+ imageUrls: Optional[List[str]] = None
106
+ price: Optional[int] = Field(default=None, ge=0, strict=True)
107
  github_token: Optional[str] = None
108
  netdisk_password: Optional[str] = None # ☁️ 网盘提取码
109
  is_netdisk: Optional[bool] = None # ☁️ 是否为网盘资源
 
146
 
147
  class RechargeRequest(BaseModel):
148
  account: str
149
+ amount: int = Field(..., ge=1, le=10000, strict=True) # 单次充值1-10000
150
  method: Optional[str] = "alipay"
151
 
152
  class PurchaseRequest(BaseModel):
153
  account: str
154
  item_id: str
155
+ expected_price: Optional[int] = Field(default=None, ge=0, strict=True)
156
+
157
+ class PromptPurchaseRequest(BaseModel):
158
+ expected_price: Optional[int] = Field(default=None, ge=0, strict=True)
159
 
160
  class TipRequest(BaseModel):
161
  sender_account: str
 
166
 
167
  class WithdrawRequest(BaseModel):
168
  account: str
169
+ amount: int = Field(..., ge=1, le=5000, strict=True) # 单次提现1-5000
170
+ alipayAccount: str = Field(..., min_length=1, max_length=256)
171
+ real_name: str = Field(..., min_length=1, max_length=128)
172
+ code: str = Field(..., pattern=r"^[0-9]{6}$")
173
 
174
 
175
  # ==========================================
 
181
  description: str # 任务详情
182
  referenceImages: Optional[List[str]] = [] # 参考图(最多6张)
183
  referenceLink: Optional[str] = None # 参考链接
184
+ totalPrice: int = Field(..., ge=0, le=2147483647, strict=True) # SQL整数范围
185
+ depositRatio: int = Field(..., strict=True) # 订金比例(10/20/30/50)
186
  deadline: str # 截止时间 (ISO格式)
187
  publisher: str # 发布者账号
188
 
 
192
  description: Optional[str] = None
193
  referenceImages: Optional[List[str]] = None
194
  referenceLink: Optional[str] = None
195
+ totalPrice: Optional[int] = Field(None, ge=0, le=2147483647, strict=True)
196
+ depositRatio: Optional[int] = Field(None, strict=True)
197
  deadline: Optional[str] = None
198
 
199
  # 🔧 P2修复:以下三个 body 模型修复前后端参数通道不匹配
 
257
  audio_url: Optional[str] = None # 示例音频 URL(仅音乐提示词,图片+音频方案;视频可选)
258
  images: Optional[List[str]] = None # 图像提示词图片列表(最多9张,首张为封面)
259
  is_original: bool = False # 原创作品标记
260
+ price: int = Field(default=0, ge=0, le=10000, strict=True) # 提示词定价(0=免费)
261
  tags: Optional[List[str]] = None # 自定义标签(最多10个,每个≤12字)
262
 
263
  class PromptUpdate(BaseModel):
 
272
  audio_url: Optional[str] = None # 示例音频 URL(仅音乐提示词;传 null 表示清除)
273
  images: Optional[List[str]] = None # 图像提示词图片列表(最多9张,首张为封面)
274
  is_original: Optional[bool] = None # 原创作品标记
275
+ price: Optional[int] = Field(default=None, ge=0, le=10000, strict=True)
276
  tags: Optional[List[str]] = None
277
 
278
  class PromptCategoryAdd(BaseModel):
279
  """ 添加提示词分类 """
280
  prompt_type: str # "image"、"video" 或 "music"
281
+ name: str # 分类名称(≤8字,去重)
models_sql.py CHANGED
@@ -1,131 +1,288 @@
1
- # models_sql.py
2
- # ==========================================
3
- # 🗄️ SQL 数据库模型定义
4
- # ==========================================
5
- # 🚀 P1性能优化:添加复合索引加速查询
6
- # ==========================================
7
-
8
- from sqlalchemy import Column, Integer, String, DateTime, Boolean, Index, func
9
- from sqlalchemy.orm import declarative_base
10
- import datetime
11
-
12
- Base = declarative_base()
13
-
14
- class Wallet(Base):
15
- """用户钱包表"""
16
- __tablename__ = "wallets"
17
-
18
- account = Column(String, primary_key=True, index=True)
19
- balance = Column(Integer, default=0, nullable=False) # 用于消费的余额 (充值获得)
20
- earn_balance = Column(Integer, default=0, nullable=False) # 创作者销售收益余额 (别人购买获得)
21
- tip_balance = Column(Integer, default=0, nullable=False) # 【新增】创作者打赏收益余额 (粉丝赞助获得)
22
- task_balance = Column(Integer, default=0, nullable=False) # 【新增】任务相关余额
23
- frozen_balance = Column(Integer, default=0, nullable=False) # 提现审核冻结中的余额
24
-
25
- # 乐观锁版本号,防止并发扣款被击穿
26
- version = Column(Integer, default=1)
27
-
28
- class Ownership(Base):
29
- """资源所有权表"""
30
- __tablename__ = "ownerships"
31
-
32
- id = Column(Integer, primary_key=True, autoincrement=True)
33
- account = Column(String, index=True)
34
- item_id = Column(String, index=True)
35
- purchased_at = Column(DateTime, default=datetime.datetime.utcnow)
36
- # 🔄 P7后悔模式增强
37
- price_paid = Column(Integer, default=0) # 购买时支付的价格
38
- is_refunded = Column(Boolean, default=False) # 是否已退款
39
- refunded_at = Column(DateTime, nullable=True) # 退款时间
40
-
41
- # 🚀 P1性能优化:复合索引
42
- __table_args__ = (
43
- Index('ix_ownerships_account_item', 'account', 'item_id'),
44
- Index('ix_ownerships_account_refunded', 'account', 'is_refunded'),
45
- )
46
-
47
- class Refund(Base):
48
- """🔄 P7后悔模式:退款记录表"""
49
- __tablename__ = "refunds"
50
-
51
- id = Column(Integer, primary_key=True, autoincrement=True)
52
- account = Column(String, index=True) # 退款用户
53
- item_id = Column(String, index=True) # 退款商品
54
- amount = Column(Integer) # 退款金额
55
- refunded_at = Column(DateTime, default=datetime.datetime.utcnow)
56
- ban_until = Column(DateTime) # 禁止购买截止时间(30天后)
57
-
58
- class RechargeOrder(Base):
59
- """💰 充值订单预存表(订单号→账号映射持久化,防止实例重启导致回调找不到订单)"""
60
- __tablename__ = "recharge_orders"
61
-
62
- order_id = Column(String, primary_key=True) # 商户订单号 PAY_xxx
63
- account = Column(String, index=True) # 充值用户
64
- amount = Column(Integer) # 充值金额
65
- status = Column(String, default="pending") # pending/paid
66
- created_at = Column(DateTime, default=datetime.datetime.utcnow)
67
-
68
- class Transaction(Base):
69
- """交易流水表"""
70
- __tablename__ = "transactions"
71
-
72
- tx_id = Column(String, primary_key=True)
73
- account = Column(String, index=True)
74
- tx_type = Column(String) # 枚举: RECHARGE, PURCHASE, SALE, TIP_OUT, TIP_IN, WITHDRAW, WITHDRAW_FEE, REFUND, TASK_FREEZE, TASK_DEPOSIT, TASK_PAYMENT, TASK_INCOME, TASK_REFUND, TASK_CANCEL_REFUND
75
- amount = Column(Integer)
76
- related_account = Column(String, nullable=True)
77
- item_id = Column(String, nullable=True)
78
- created_at = Column(DateTime, default=datetime.datetime.utcnow, index=True) # 🚀 P1: 添加时间索引
79
- prev_hash = Column(String) # 上一笔订单的哈希
80
- tx_hash = Column(String) # 本笔订单的哈希 (防篡改)
81
-
82
- # 💰 提现相关字段 (仅 WITHDRAW_APPLY 类型交易使用)
83
- alipay_account = Column(String, nullable=True) # 收款支付宝账号
84
- real_name = Column(String, nullable=True) # 收款人姓名
85
- withdraw_status = Column(String, nullable=True) # pending / completed
86
- payment_order_id = Column(String, nullable=True) # 管理员填写的打款订单号
87
- net_amount = Column(Integer, nullable=True) # 实际到账金额(用于解冻时准确扣减)
88
-
89
- # 交易详情增强字段
90
- description = Column(String, nullable=True) # 交易自描述文本
91
- item_title = Column(String, nullable=True) # 资源/任务标题
92
- item_type = Column(String, nullable=True) # 资源类型 (tool/skill/app/recommend/post/task/prompt)
93
- related_user_name = Column(String, nullable=True) # 对方用户名
94
-
95
- # 🌐 开放API:来源插件标识(如 "nodecraft_ai"),用于按工具结构化追踪消费
96
- source_plugin = Column(String(50), nullable=True, index=True)
97
-
98
- # 🚀 P1性能优化:复合索引
99
- __table_args__ = (
100
- Index('ix_transactions_account_type', 'account', 'tx_type'),
101
- Index('ix_transactions_account_created', 'account', 'created_at'),
102
- Index('ix_transactions_source_plugin_created', 'source_plugin', 'created_at'),
103
- )
104
-
105
- class SystemConfig(Base):
106
- """系统配置表 - 持久化存储,替代 /tmp/system_config.json"""
107
- __tablename__ = "system_configs"
108
-
109
- config_key = Column(String, primary_key=True)
110
- config_value = Column(String, nullable=False, default="{}")
111
- updated_at = Column(DateTime, default=datetime.datetime.utcnow)
112
- updated_by = Column(String, nullable=True)
113
-
114
-
115
- class RegisteredPlugin(Base):
116
- """🌐 开放API:第三方插件注册信息表"""
117
- __tablename__ = "registered_plugins"
118
-
119
- plugin_id = Column(String(50), primary_key=True) # 英文标识如 "nodecraft_ai"
120
- plugin_key = Column(String(100), unique=True, nullable=False, index=True)
121
- name = Column(String(100), nullable=False) # 显示名
122
- description = Column(String(500), nullable=True)
123
- enabled = Column(Boolean, default=True, nullable=False)
124
- rate_limit = Column(Integer, default=100) # 每分钟最大请求数
125
- max_deduct_per_tx = Column(Integer, default=100) # 单笔扣款上限
126
- max_deduct_per_day = Column(Integer, default=1000) # 日扣款上限
127
- permissions = Column(String(200), default='["verify","balance","deduct","refund"]') # JSON数组
128
- created_at = Column(DateTime, default=func.now())
129
- created_by = Column(String(50)) # 创建者管理员账号
130
- updated_at = Column(DateTime, onupdate=func.now())
131
- last_rotated_at = Column(DateTime, nullable=True) # 上次轮换key时间
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # models_sql.py
2
+ # ==========================================
3
+ # 🗄️ SQL 数据库模型定义
4
+ # ==========================================
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"
18
+ request_key = Column(String(64), primary_key=True)
19
+ item_id = Column(String(100), nullable=False, unique=True)
20
+ author = Column(String, nullable=False)
21
+ request_payload = Column(Text, nullable=False)
22
+ payload = Column(Text, nullable=False)
23
+ projected = Column(Boolean, nullable=False, default=False, index=True)
24
+ created_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
25
+
26
+
27
+ class TaskPublication(Base):
28
+ """与任务资金冻结同提交的待发布记录;保留原始内容供失败恢复。"""
29
+ __tablename__ = "task_publications"
30
+ task_id = Column(String(64), primary_key=True)
31
+ publisher = Column(String, nullable=False)
32
+ payload = Column(Text, nullable=False)
33
+ projected = Column(Boolean, nullable=False, default=False, index=True)
34
+ created_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
35
+
36
+
37
+ class TaskRevision(Base):
38
+ """任务编辑的持久字段投影,与改价资金同事务;版本按钱包锁串行递增。"""
39
+ __tablename__ = "task_revisions"
40
+ task_id = Column(String(64), primary_key=True)
41
+ revision = Column(Integer, primary_key=True)
42
+ publisher = Column(String, nullable=False)
43
+ payload = Column(Text, nullable=False)
44
+ projected = Column(Boolean, nullable=False, default=False, index=True)
45
+ created_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
46
+
47
+
48
+ class TaskAssignment(Base):
49
+ """指派和订金SQL提交后的状态投影,不能因JSON失败再次扣订金。"""
50
+ __tablename__ = "task_assignments"
51
+ task_id = Column(String(64), primary_key=True)
52
+ publisher = Column(String, nullable=False)
53
+ assignee = Column(String, nullable=False)
54
+ deposit = Column(Integer, nullable=False)
55
+ assigned_at = Column(Integer, nullable=False)
56
+ projected = Column(Boolean, nullable=False, default=False, index=True)
57
+
58
+
59
+ class TaskDelivery(Base):
60
+ """交付/退回最新版本先持久保存,JSON失败不能丢失成果或恢复旧验收。"""
61
+ __tablename__ = "task_deliveries"
62
+ task_id = Column(String(64), primary_key=True)
63
+ publisher = Column(String, nullable=False)
64
+ assignee = Column(String, nullable=False)
65
+ revision = Column(Integer, nullable=False)
66
+ payload = Column(Text, nullable=False)
67
+ updated_at = Column(Integer, nullable=False)
68
+ projected = Column(Boolean, nullable=False, default=False, index=True)
69
+
70
+
71
+ class TaskSettlement(Base):
72
+ """验收付款和待完成状态同事务;一任务只结算一次。"""
73
+ __tablename__ = "task_settlements"
74
+ task_id = Column(String(64), primary_key=True)
75
+ publisher = Column(String, nullable=False)
76
+ assignee = Column(String, nullable=False)
77
+ amount = Column(Integer, nullable=False)
78
+ deposit = Column(Integer, nullable=False)
79
+ payment_tx_id = Column(String, nullable=False)
80
+ income_tx_id = Column(String, nullable=False)
81
+ completed_at = Column(Integer, nullable=False)
82
+ feedback = Column(Text, nullable=True)
83
+ projected = Column(Boolean, nullable=False, default=False, index=True)
84
+
85
+
86
+ class TaskResolution(Base):
87
+ """仲裁资金和裁决同提交;一任务只允许一次最终分配。"""
88
+ __tablename__ = "task_resolutions"
89
+ task_id = Column(String(64), primary_key=True)
90
+ dispute_id = Column(String(64), nullable=False, unique=True)
91
+ publisher = Column(String, nullable=False)
92
+ assignee = Column(String, nullable=False)
93
+ amount = Column(Integer, nullable=False)
94
+ deposit = Column(Integer, nullable=False)
95
+ publisher_refund = Column(Integer, nullable=False)
96
+ assignee_income = Column(Integer, nullable=False)
97
+ initiator_role = Column(String(16), nullable=False)
98
+ resolution = Column(String(32), nullable=False)
99
+ ratio = Column(Integer, nullable=True)
100
+ note = Column(Text, nullable=True)
101
+ resolved_by = Column(String, nullable=False)
102
+ resolved_at = Column(Integer, nullable=False)
103
+ payment_tx_id = Column(String, nullable=False)
104
+ refund_tx_id = Column(String, nullable=False)
105
+ income_tx_id = Column(String, nullable=False)
106
+ projected = Column(Boolean, nullable=False, default=False, index=True)
107
+
108
+ class TaskDisputeRecord(Base):
109
+ """申诉先持久保存;JSON同步期间也禁止普通验收结算。"""
110
+ __tablename__ = "task_dispute_records"
111
+ task_id = Column(String(64), primary_key=True)
112
+ dispute_id = Column(String(64), nullable=False, unique=True)
113
+ publisher = Column(String, nullable=False)
114
+ assignee = Column(String, nullable=False)
115
+ payload = Column(Text, nullable=False)
116
+ projected = Column(Boolean, nullable=False, default=False, index=True)
117
+ created_at = Column(Integer, nullable=False)
118
+
119
+
120
+ class TaskDisputeResponseRecord(Base):
121
+ """首次申诉回应持久记录;一个任务只保存一份原始回应。"""
122
+ __tablename__ = "task_dispute_responses"
123
+ task_id = Column(String(64), primary_key=True)
124
+ dispute_id = Column(String(64), nullable=False, unique=True)
125
+ publisher = Column(String, nullable=False)
126
+ assignee = Column(String, nullable=False)
127
+ respondent = Column(String, nullable=False)
128
+ payload = Column(Text, nullable=False)
129
+ projected = Column(Boolean, nullable=False, default=False, index=True)
130
+
131
+
132
+ class Wallet(Base):
133
+ """用户钱包表"""
134
+ __tablename__ = "wallets"
135
+
136
+ account = Column(String, primary_key=True, index=True)
137
+ balance = Column(Integer, default=0, nullable=False) # 用于消费的余额 (充值获得)
138
+ earn_balance = Column(Integer, default=0, nullable=False) # 创作者销售收益余额 (别人购买获得)
139
+ tip_balance = Column(Integer, default=0, nullable=False) # 【新增】创作者打赏收益余额 (粉丝赞助获得)
140
+ task_balance = Column(Integer, default=0, nullable=False) # 【新增】任务相关余额
141
+ frozen_balance = Column(Integer, default=0, nullable=False) # 提现审核冻结中的余额
142
+
143
+ # 预留版本列;未配置 version_id_col,不提供 ORM 乐观锁保护
144
+ version = Column(Integer, default=1)
145
+
146
+ class Ownership(Base):
147
+ """资源所有权表"""
148
+ __tablename__ = "ownerships"
149
+
150
+ id = Column(Integer, primary_key=True, autoincrement=True)
151
+ account = Column(String, index=True)
152
+ item_id = Column(String, index=True)
153
+ purchased_at = Column(DateTime, default=datetime.datetime.utcnow)
154
+ # 🔄 P7后悔模式增强
155
+ price_paid = Column(Integer, default=0) # 购买时支付的价格
156
+ is_refunded = Column(Boolean, default=False) # 是否已退款
157
+ refunded_at = Column(DateTime, nullable=True) # 退款时间
158
+
159
+ # 🚀 P1性能优化:复合索引
160
+ __table_args__ = (
161
+ Index('ix_ownerships_account_item', 'account', 'item_id'),
162
+ Index('ix_ownerships_account_refunded', 'account', 'is_refunded'),
163
+ )
164
+
165
+ class Refund(Base):
166
+ """🔄 P7后悔模式:退款记录表"""
167
+ __tablename__ = "refunds"
168
+
169
+ id = Column(Integer, primary_key=True, autoincrement=True)
170
+ account = Column(String, index=True) # 退款用户
171
+ item_id = Column(String, index=True) # 退款商品
172
+ amount = Column(Integer) # 退款金额
173
+ refunded_at = Column(DateTime, default=datetime.datetime.utcnow)
174
+ ban_until = Column(DateTime) # 禁止购买截止时间(30天后)
175
+
176
+ class RechargeOrder(Base):
177
+ """💰 充值订单预存表(订单号→账号映射持久化,防止实例重启导致回调找不到订单)"""
178
+ __tablename__ = "recharge_orders"
179
+
180
+ order_id = Column(String, primary_key=True) # 商户订单号 PAY_xxx
181
+ account = Column(String, index=True) # 充值用户
182
+ amount = Column(Integer) # 充值金额
183
+ status = Column(String, default="pending") # pending/paid
184
+ created_at = Column(DateTime, default=datetime.datetime.utcnow)
185
+
186
+ class Transaction(Base):
187
+ """交易流水表"""
188
+ __tablename__ = "transactions"
189
+
190
+ tx_id = Column(String, primary_key=True)
191
+ account = Column(String, index=True)
192
+ tx_type = Column(String) # 枚举: RECHARGE, PURCHASE, SALE, TIP_OUT, TIP_IN, WITHDRAW, WITHDRAW_FEE, REFUND, SALE_REFUND, TASK_FREEZE, TASK_PRICE_ADJUST, TASK_DEPOSIT, TASK_PAYMENT, TASK_INCOME, TASK_REFUND, TASK_CANCEL_REFUND
193
+ amount = Column(Integer)
194
+ related_account = Column(String, nullable=True)
195
+ item_id = Column(String, nullable=True)
196
+ created_at = Column(DateTime, default=datetime.datetime.utcnow, index=True) # 🚀 P1: 添加时间索引
197
+ prev_hash = Column(String) # 上一笔订单的哈希
198
+ tx_hash = Column(String) # 本笔订单的哈希 (防篡改)
199
+
200
+ # 💰 提现相关字段 (仅 WITHDRAW_APPLY 类型交易使用)
201
+ alipay_account = Column(String, nullable=True) # 收款支付宝账号
202
+ real_name = Column(String, nullable=True) # 收款人姓名
203
+ withdraw_status = Column(String, nullable=True) # pending / completed
204
+ payment_order_id = Column(String, nullable=True) # 管理员填写的打款订单号
205
+ net_amount = Column(Integer, nullable=True) # 实际到账金额(用于解冻时准确扣减)
206
+
207
+ # 交易详情增强字段
208
+ description = Column(String, nullable=True) # 交易自描述文本
209
+ item_title = Column(String, nullable=True) # 资源/任务标题
210
+ item_type = Column(String, nullable=True) # 资源类型 (tool/skill/app/recommend/post/task/prompt)
211
+ related_user_name = Column(String, nullable=True) # 对方用户名
212
+
213
+ # 🌐 开放API:来源插件标识(如 "nodecraft_ai"),用于按工具结构化追踪消费
214
+ source_plugin = Column(String(50), nullable=True, index=True)
215
+
216
+ # 🚀 P1性能优化:复合索引
217
+ __table_args__ = (
218
+ Index('ix_transactions_account_type', 'account', 'tx_type'),
219
+ Index('ix_transactions_account_created', 'account', 'created_at'),
220
+ Index('ix_transactions_source_plugin_created', 'source_plugin', 'created_at'),
221
+ )
222
+
223
+ class SystemConfig(Base):
224
+ """系统配置表 - 持久化存储,替代 /tmp/system_config.json"""
225
+ __tablename__ = "system_configs"
226
+
227
+ config_key = Column(String, primary_key=True)
228
+ config_value = Column(String, nullable=False, default="{}")
229
+ updated_at = Column(DateTime, default=datetime.datetime.utcnow)
230
+ updated_by = Column(String, nullable=True)
231
+
232
+
233
+ class RegisteredPlugin(Base):
234
+ """🌐 开放API:第三方插件注册信息表"""
235
+ __tablename__ = "registered_plugins"
236
+
237
+ plugin_id = Column(String(50), primary_key=True) # 英文标识如 "nodecraft_ai"
238
+ plugin_key = Column(String(100), unique=True, nullable=False, index=True)
239
+ name = Column(String(100), nullable=False) # 显示名
240
+ description = Column(String(500), nullable=True)
241
+ enabled = Column(Boolean, default=True, nullable=False)
242
+ rate_limit = Column(Integer, default=100) # 每分钟最大请求数
243
+ max_deduct_per_tx = Column(Integer, default=100) # 单笔扣款上限
244
+ max_deduct_per_day = Column(Integer, default=1000) # 日扣款上限
245
+ permissions = Column(String(200), default='["verify","balance","deduct","refund"]') # JSON数组
246
+ created_at = Column(DateTime, default=func.now())
247
+ created_by = Column(String(50)) # 创建者管理员账号
248
+ updated_at = Column(DateTime, onupdate=func.now())
249
+ last_rotated_at = Column(DateTime, nullable=True) # 上次轮换key时间
250
+
251
+
252
+ class OpenApiRateLimitBucket(Base):
253
+ """跨 worker 持久化的开放 API 插件分钟窗口计数。"""
254
+ __tablename__ = "open_api_rate_limit_buckets"
255
+
256
+ plugin_id = Column(String(50), primary_key=True)
257
+ window_start = Column(Integer, nullable=False)
258
+ request_count = Column(Integer, nullable=False, default=0)
259
+ updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
260
+
261
+
262
+ class ApiRateLimitBucket(Base):
263
+ """跨 worker 共享的常规 API 限流分钟窗口。"""
264
+ __tablename__ = "api_rate_limit_buckets"
265
+
266
+ bucket_key = Column(String(64), primary_key=True)
267
+ window_start = Column(Integer, nullable=False)
268
+ request_count = Column(Integer, nullable=False, default=0)
269
+ updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
270
+
271
+
272
+ class VerificationContactCooldown(Base):
273
+ """验证码联系方式发送冷却;只持久化脱敏哈希键,不保存原始邮箱/手机号。"""
274
+ __tablename__ = "verification_contact_cooldowns"
275
+
276
+ contact_key = Column(String(64), primary_key=True)
277
+ last_sent_at = Column(Integer, nullable=False)
278
+ claim_id = Column(String(32), nullable=False)
279
+
280
+
281
+ class VerificationCode(Base):
282
+ """跨 worker 一次性验证码;仅保存联系方式哈希和带密钥的验证码摘要。"""
283
+ __tablename__ = "verification_codes"
284
+
285
+ contact_key = Column(String(64), primary_key=True)
286
+ code_digest = Column(String(64), nullable=False)
287
+ expires_at = Column(Integer, nullable=False, index=True)
288
+ updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow)
notification_store.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """通知与公告的严格读取、白名单输出和锁内更新。"""
2
+ import copy
3
+ import logging
4
+ import math
5
+ import time
6
+ import uuid
7
+
8
+ from fastapi import HTTPException
9
+ import 数据库连接 as db
10
+
11
+ logger = logging.getLogger(__name__)
12
+ TTL = 7 * 24 * 3600
13
+ FIELDS = frozenset({"id", "type", "from_user", "from_name", "from_avatar", "target_item_id",
14
+ "target_item_title", "content", "is_read", "created_at", "receiver"})
15
+
16
+
17
+ def _unavailable():
18
+ return HTTPException(status_code=503, detail="通知数据暂不可用")
19
+
20
+
21
+ def _timestamp(message):
22
+ value = message["created_at"]
23
+ return value / 1000 if value >= 1e12 else value
24
+
25
+
26
+ def _validate(message):
27
+ if not isinstance(message, dict):
28
+ raise _unavailable()
29
+ if any(not isinstance(message.get(key), str) or not message[key] for key in ("id", "type")):
30
+ raise _unavailable()
31
+ timestamp = message.get("created_at")
32
+ try:
33
+ valid_timestamp = type(timestamp) in (int, float) and math.isfinite(timestamp) and timestamp >= 0
34
+ except (OverflowError, ValueError):
35
+ valid_timestamp = False
36
+ if not valid_timestamp:
37
+ raise _unavailable()
38
+ if not isinstance(message.get("content", ""), str) or type(message.get("is_read", False)) is not bool:
39
+ raise _unavailable()
40
+ return message
41
+
42
+
43
+ def _messages(data, account):
44
+ if not isinstance(data, dict) or not isinstance(data.get(account, []), list):
45
+ raise _unavailable()
46
+ messages = data.get(account, [])
47
+ for message in messages:
48
+ _validate(message)
49
+ if "receiver" in message and message["receiver"] != account:
50
+ raise _unavailable()
51
+ return messages
52
+
53
+
54
+ def _announcements(data):
55
+ if not isinstance(data, list):
56
+ raise _unavailable()
57
+ for message in data:
58
+ if _validate(message)["type"] != "system":
59
+ raise _unavailable()
60
+ return data
61
+
62
+
63
+ def _read(name, default):
64
+ try:
65
+ return db.load_data(name, default_data=default, skip_cache=True, strict=True)
66
+ except Exception as error:
67
+ logger.warning("通知读取失败: %s", type(error).__name__)
68
+ raise _unavailable() from None
69
+
70
+
71
+ def _mutate(name, updater, default):
72
+ try:
73
+ return db.atomic_update(name, updater, default_data=default)
74
+ except HTTPException:
75
+ raise
76
+ except Exception as error:
77
+ logger.warning("通知写入失败: %s", type(error).__name__)
78
+ raise _unavailable() from None
79
+
80
+
81
+ def _profile(account):
82
+ users = _read("users.json", {})
83
+ if not isinstance(users, dict) or not isinstance(users.get(account, {}), dict):
84
+ raise _unavailable()
85
+ return users.get(account, {})
86
+
87
+
88
+ def publish_announcement(account, content):
89
+ if not isinstance(content, str) or not content.strip():
90
+ raise HTTPException(status_code=400, detail="公告内容不能为空")
91
+ user = _profile(account)
92
+ announcement = {"id": f"sys_{int(time.time())}_{uuid.uuid4().hex}", "type": "system", "from_user": account,
93
+ "from_name": user.get("name", account), "from_avatar": user.get("avatarDataUrl", ""),
94
+ "content": content, "created_at": int(time.time())}
95
+ def update(data):
96
+ _announcements(data).append(announcement)
97
+ _mutate("announcements.json", update, [])
98
+
99
+
100
+ def append_notification(account, payload):
101
+ source = payload.get("from_user")
102
+ if not account or source == account:
103
+ return
104
+ user = _profile(source) if source and source != "system" else {}
105
+ message = {"id": f"msg_{int(time.time())}_{uuid.uuid4().hex}", "type": payload.get("type"),
106
+ "from_user": source, "from_name": user.get("name", source) if source and source != "system" else "系统通知",
107
+ "from_avatar": user.get("avatarDataUrl", ""), "target_item_id": payload.get("target_item_id", ""),
108
+ "target_item_title": payload.get("target_item_title", ""), "content": payload.get("content", ""),
109
+ "is_read": False, "created_at": int(time.time())}
110
+ _validate(message)
111
+ def update(data):
112
+ messages = _messages(data, account)
113
+ data[account] = [message, *messages]
114
+ _mutate("messages.json", update, {})
115
+
116
+
117
+ def get_messages(account, count_only=False):
118
+ now = time.time()
119
+ announcements = _announcements(_read("announcements.json", []))
120
+ # 过期公告不重复注入已被清理的旧通知,也不在角标中重新出现。
121
+ active = [message for message in announcements if now - _timestamp(message) < TTL]
122
+ messages = _messages(_read("messages.json", {}), account)
123
+
124
+ def combine(messages):
125
+ result = copy.deepcopy(messages)
126
+ ids = {message["id"] for message in result}
127
+ for message in active:
128
+ if message["id"] not in ids:
129
+ result.append({**copy.deepcopy(message), "receiver": account, "is_read": False})
130
+ ids.add(message["id"])
131
+ return result
132
+
133
+ def unread(messages):
134
+ return sum(not message.get("is_read", False) and message["type"] not in ("private", "plugin_update")
135
+ and now - _timestamp(message) < TTL for message in messages)
136
+
137
+ def project(messages):
138
+ return [copy.deepcopy({key: value for key, value in message.items() if key in FIELDS}) for message in messages]
139
+
140
+ combined = combine(messages)
141
+ if count_only:
142
+ return {"status": "success", "unread_count": unread(combined)}
143
+ if len(combined) == len(messages) and all(message.get("is_read", False) and now - _timestamp(message) < TTL for message in messages):
144
+ return {"status": "success", "data": project(messages), "unread_count": 0}
145
+
146
+ def update(data):
147
+ latest = combine(_messages(data, account))
148
+ count = unread(latest)
149
+ kept = [message for message in latest if not message.get("is_read", False) or now - _timestamp(message) < TTL]
150
+ for message in kept:
151
+ message["is_read"] = True
152
+ kept.sort(key=_timestamp, reverse=True)
153
+ data[account] = kept
154
+ return {"status": "success", "data": project(kept), "unread_count": count}
155
+ return _mutate("messages.json", update, {})
156
+
157
+
158
+ def mark_messages_read(account):
159
+ messages = _messages(_read("messages.json", {}), account)
160
+ if all(message.get("is_read", False) for message in messages):
161
+ return
162
+ def update(data):
163
+ for message in _messages(data, account):
164
+ message["is_read"] = True
165
+ _mutate("messages.json", update, {})
notifications.py CHANGED
@@ -1,44 +1,8 @@
1
- import time
2
- import uuid
3
- import 数据库连接 as db
4
 
5
  def add_notification(target_account: str, notif_data: dict):
6
  try:
7
- from_user = notif_data.get("from_user")
8
- if not target_account:
9
- return
10
- if from_user and target_account == from_user:
11
- return # 不给自己发通知
12
-
13
- users_db = db.load_data("users.json", default_data={})
14
-
15
- if from_user == "system" or not from_user:
16
- from_name = "系统通知"
17
- from_avatar = ""
18
- else:
19
- user_info = users_db.get(from_user, {})
20
- from_name = user_info.get("name", from_user)
21
- from_avatar = user_info.get("avatarDataUrl", "")
22
-
23
- notif = {
24
- "id": f"msg_{int(time.time())}_{uuid.uuid4().hex[:6]}",
25
- "type": notif_data.get("type"),
26
- "from_user": from_user,
27
- "from_name": from_name,
28
- "from_avatar": from_avatar,
29
- "target_item_id": notif_data.get("target_item_id", ""),
30
- "target_item_title": notif_data.get("target_item_title", ""),
31
- "content": notif_data.get("content", ""),
32
- "is_read": False,
33
- "created_at": int(time.time())
34
- }
35
-
36
- def updater(data):
37
- if target_account not in data:
38
- data[target_account] = []
39
- data[target_account].insert(0, notif)
40
-
41
- db.atomic_update("messages.json", updater, default_data={})
42
- except Exception as e:
43
- print(f"⚠️ 通知发送失败 (target={target_account}): {e}")
44
- # 不抛出异常,让主流程继续
 
1
+ import notification_store
 
 
2
 
3
  def add_notification(target_account: str, notif_data: dict):
4
  try:
5
+ notification_store.append_notification(target_account, notif_data)
6
+ except Exception as error:
7
+ # 通知属于派生操作,故障不影响已经完成的业务;不打印账号/正文。
8
+ print(f"通知写入失败: error_type={type(error).__name__}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
open_transaction.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """开放资金接口的实时插件授权与钱包写锁;事务由调用方提交/回滚。"""
2
+ import hmac
3
+ import json
4
+ from decimal import Decimal
5
+
6
+ from fastapi import HTTPException
7
+ from models_sql import RegisteredPlugin
8
+ from tip_transaction import lock_wallet_accounts
9
+
10
+
11
+ def validate_live_plugin(row, key, permission):
12
+ if row is None or not row.enabled:
13
+ raise HTTPException(status_code=403, detail="插件已停用或不存在")
14
+ if not isinstance(row.plugin_key, str) or not isinstance(key, str) or not hmac.compare_digest(row.plugin_key.encode('utf-8'), key.encode('utf-8')):
15
+ raise HTTPException(status_code=403, detail="Plugin Key 已失效")
16
+ try:
17
+ permissions = json.loads(row.permissions)
18
+ if not isinstance(permissions, list) or any(not isinstance(value, str) for value in permissions):
19
+ raise ValueError("invalid permissions")
20
+ except Exception:
21
+ raise HTTPException(status_code=503, detail="插件权限配置暂不可用")
22
+ if permission not in permissions:
23
+ raise HTTPException(status_code=403, detail="插件无权限调用该接口")
24
+ return row
25
+
26
+
27
+ def lock_open_wallet(session, account, plugin_id, key, permission):
28
+ # 首项资金SQL取得SQLite写事务;其它方言先锁钱包再锁插件,所有开放资金路径同序。
29
+ wallet = lock_wallet_accounts(session, (account,))[account]
30
+ row = session.query(RegisteredPlugin).filter(RegisteredPlugin.plugin_id == plugin_id).populate_existing().with_for_update().first()
31
+ validate_live_plugin(row, key, permission)
32
+ return wallet, row
33
+
34
+
35
+ def positive_integer(value):
36
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
37
+ raise HTTPException(status_code=503, detail="插件额度配置暂不可用")
38
+ return value
39
+
40
+
41
+ def quota_total(value):
42
+ """SQL聚合可返回整数或精确Decimal;拒绝浮点/字符串/非整数与坏统计。"""
43
+ if isinstance(value, Decimal) and value.is_finite() and value >= 0 and value == value.to_integral_value():
44
+ return int(value)
45
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
46
+ raise ValueError("invalid quota history")
47
+ return value
owned_thread.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """等待所属同步工作结束;取消不能遗留仍持有文件/SDK操作的线程。"""
2
+ import asyncio
3
+
4
+
5
+ async def run_owned_thread(function, *args, **kwargs):
6
+ async def finish():
7
+ # 被取消的shield在部分Python版本会立即记录内层异常正文。
8
+ # 先把常规线程结果封装为数据,所属调用再决定传播异常或取消。
9
+ try:
10
+ return True, await asyncio.to_thread(function, *args, **kwargs)
11
+ except Exception as error:
12
+ return False, error
13
+
14
+ task = asyncio.create_task(finish())
15
+ cancelled = None
16
+ while not task.done():
17
+ try:
18
+ await asyncio.shield(task)
19
+ except asyncio.CancelledError as error:
20
+ cancelled = error
21
+ except Exception:
22
+ break
23
+ if cancelled is not None:
24
+ # 消费线程结果/异常,取消仍是本次调用的终态。
25
+ try:
26
+ task.result()
27
+ except Exception:
28
+ pass
29
+ raise cancelled
30
+ succeeded, result = task.result()
31
+ if not succeeded:
32
+ raise result
33
+ return result
post_store.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """帖子主记录在最新文件上的局部修改;不处理资金或评论跨文件清理。"""
2
+ import copy
3
+ from publication_identity import same_publication
4
+
5
+
6
+ class PostStoreError(Exception):
7
+ def __init__(self, status_code, message):
8
+ self.status_code = status_code
9
+ self.message = message
10
+ super().__init__(message)
11
+
12
+
13
+ class _Unchanged(Exception):
14
+ def __init__(self, result): self.result = result
15
+
16
+
17
+ def _records(source):
18
+ if not isinstance(source, list):
19
+ raise ValueError('invalid post source')
20
+ identities = set()
21
+ for record in source:
22
+ if (not isinstance(record, dict) or not isinstance(record.get('id'), str)
23
+ or not record['id'].strip() or record['id'] in identities
24
+ or not isinstance(record.get('author'), str) or not record['author'].strip()):
25
+ raise ValueError('invalid or ambiguous post record')
26
+ identities.add(record['id'])
27
+ return source
28
+
29
+
30
+ def _mutate(store, callback):
31
+ try:
32
+ return store.atomic_update('posts.json', lambda source: callback(_records(source)), default_data=[])
33
+ except _Unchanged as unchanged:
34
+ return unchanged.result
35
+ except PostStoreError:
36
+ raise
37
+ except Exception:
38
+ # 替换后的同步故障也属于未知结果,不推断为未保存,不自动重放。
39
+ raise PostStoreError(503, '帖子保存结果暂时无法确认,请稍后核对') from None
40
+
41
+
42
+ def publish_post(store, record, replay=False):
43
+ saved = copy.deepcopy(record)
44
+ try:
45
+ _records([saved])
46
+ except ValueError:
47
+ raise PostStoreError(400, '帖子发布参数无效') from None
48
+ def append(records):
49
+ existing = next((item for item in records if item['id'] == saved['id']), None)
50
+ if existing is not None:
51
+ fields = ('author', 'title', 'content', 'cover_image', 'images', 'is_original', 'post_type', 'video_url')
52
+ if replay and same_publication(existing, saved, fields):
53
+ raise _Unchanged(copy.deepcopy(existing))
54
+ raise PostStoreError(409, '帖子发布请求与已有记录冲突,请核对后再提交')
55
+ records.insert(0, copy.deepcopy(saved))
56
+ return copy.deepcopy(saved)
57
+ return _mutate(store, append)
58
+
59
+
60
+ def _owned(records, post_id, account, action):
61
+ index = next((index for index, post in enumerate(records) if post['id'] == post_id), None)
62
+ if index is None:
63
+ raise PostStoreError(404, '帖子不存在')
64
+ if records[index]['author'] != account:
65
+ raise PostStoreError(403, f'无权{action}他人帖子')
66
+ return index
67
+
68
+
69
+ def edit_post(store, post_id, account, changes):
70
+ fields = {'title', 'content', 'cover_image', 'images', 'is_original', 'post_type', 'video_url'}
71
+ if not isinstance(changes, dict) or set(changes) - fields:
72
+ raise PostStoreError(400, '帖子编辑参数无效')
73
+ saved = {key: copy.deepcopy(value) for key, value in changes.items() if value is not None}
74
+ def edit(records):
75
+ index = _owned(records, post_id, account, '修改')
76
+ edited = copy.deepcopy(records[index])
77
+ edited.update(copy.deepcopy(saved))
78
+ if 'images' in saved:
79
+ edited['images'] = saved['images'][:9]
80
+ if saved.get('post_type') == 'video':
81
+ edited['images'] = []
82
+ records[index] = edited
83
+ return copy.deepcopy(edited)
84
+ return _mutate(store, edit)
85
+
86
+
87
+ def delete_post(store, post_id, account):
88
+ def remove(records):
89
+ index = _owned(records, post_id, account, '删除')
90
+ records.pop(index)
91
+ return _mutate(store, remove)
prompt_store.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """提示词主记录与分类的锁内局部修改,价格只在副本上投影。"""
2
+ import copy
3
+ from publication_identity import same_publication
4
+ import datetime
5
+ from purchase_transaction import effective_resource_price
6
+
7
+
8
+ class PromptStoreError(Exception):
9
+ def __init__(self, status_code, message):
10
+ self.status_code = status_code
11
+ self.message = message
12
+ super().__init__(message)
13
+
14
+
15
+ class _Unchanged(Exception):
16
+ def __init__(self, result): self.result = result
17
+
18
+
19
+ def _mutate(store, filename, callback, default):
20
+ try:
21
+ return store.atomic_update(filename, callback, default_data=default)
22
+ except _Unchanged as unchanged:
23
+ return unchanged.result
24
+ except PromptStoreError:
25
+ raise
26
+ except Exception:
27
+ raise PromptStoreError(503, '提示词保存结果暂时无法确认,请稍后核对') from None
28
+
29
+
30
+ def _records(source):
31
+ if not isinstance(source, list): raise ValueError('invalid prompt collection')
32
+ ids = set()
33
+ for record in source:
34
+ if (not isinstance(record, dict) or not isinstance(record.get('id'), str)
35
+ or not record['id'].strip() or record['id'] in ids
36
+ or not isinstance(record.get('author'), str) or not record['author'].strip()):
37
+ raise ValueError('invalid prompt identity')
38
+ ids.add(record['id'])
39
+ return source
40
+
41
+
42
+ def read_prompts(store):
43
+ try:
44
+ return copy.deepcopy(_records(store.load_data('prompts.json', default_data=[], strict=True, skip_cache=True)))
45
+ except Exception:
46
+ raise PromptStoreError(503, '提示词暂时不可读取') from None
47
+
48
+
49
+ def project_price(record):
50
+ result = copy.deepcopy(record)
51
+ result['price'] = effective_resource_price(result)
52
+ if result.get('pending_price') is not None and result.get('pending_price_effective_at'):
53
+ when = datetime.datetime.fromisoformat(result['pending_price_effective_at'])
54
+ now = datetime.datetime.now(when.tzinfo) if when.tzinfo else datetime.datetime.now()
55
+ if now >= when:
56
+ result['pending_price'] = None
57
+ result['pending_price_effective_at'] = None
58
+ return result
59
+
60
+
61
+ def publish_prompt(store, record, replay=False):
62
+ saved = copy.deepcopy(record)
63
+ try:
64
+ _records([saved])
65
+ effective_resource_price(saved)
66
+ except Exception:
67
+ raise PromptStoreError(400, '提示词发布参数无效') from None
68
+ def append(source):
69
+ records = _records(source)
70
+ existing = next((item for item in records if item['id'] == saved['id']), None)
71
+ if existing is not None:
72
+ fields = ('author', 'prompt_type', 'category', 'title', 'content', 'prompt_text', 'cover_image',
73
+ 'media_type', 'video_url', 'audio_url', 'images', 'is_original', 'price', 'tags')
74
+ if replay and same_publication(existing, saved, fields):
75
+ raise _Unchanged(copy.deepcopy(existing))
76
+ raise PromptStoreError(409, '提示词发布请求与已有记录冲突,请核对后再提交')
77
+ records.insert(0, copy.deepcopy(saved))
78
+ return copy.deepcopy(saved)
79
+ return _mutate(store, 'prompts.json', append, [])
80
+
81
+
82
+ def _owned(source, prompt_id, account, action):
83
+ records = _records(source)
84
+ index = next((index for index, record in enumerate(records) if record['id'] == prompt_id), None)
85
+ if index is None: raise PromptStoreError(404, '提示词不存在')
86
+ if records[index]['author'] != account:
87
+ raise PromptStoreError(403, f'无权{action}他人提示词')
88
+ return index
89
+
90
+
91
+ def edit_prompt(store, prompt_id, account, apply):
92
+ def edit(source):
93
+ index = _owned(source, prompt_id, account, '修改')
94
+ edited = project_price(source[index])
95
+ apply(edited)
96
+ source[index] = edited
97
+ return copy.deepcopy(edited)
98
+ return _mutate(store, 'prompts.json', edit, [])
99
+
100
+
101
+ def delete_prompt(store, prompt_id, account):
102
+ def remove(source): source.pop(_owned(source, prompt_id, account, '删除'))
103
+ return _mutate(store, 'prompts.json', remove, [])
104
+
105
+
106
+ def _categories(source, defaults):
107
+ if not isinstance(source, dict): raise ValueError('invalid prompt categories')
108
+ result = copy.deepcopy(source)
109
+ for kind, values in defaults.items():
110
+ existing = result.setdefault(kind, copy.deepcopy(values))
111
+ if not isinstance(existing, list) or not all(isinstance(name, str) and name.strip() for name in existing):
112
+ raise ValueError('invalid prompt category group')
113
+ return result
114
+
115
+
116
+ def load_categories(store, defaults):
117
+ try:
118
+ source = store.load_data('prompt_categories.json', default_data={}, strict=True, skip_cache=True)
119
+ completed = _categories(source, defaults)
120
+ except Exception:
121
+ raise PromptStoreError(503, '提示词分类暂时不可读取') from None
122
+ if completed == source: return completed
123
+ def initialize(latest):
124
+ completed = _categories(latest, defaults)
125
+ if completed == latest: raise _Unchanged(completed)
126
+ latest.clear(); latest.update(completed)
127
+ return copy.deepcopy(completed)
128
+ return _mutate(store, 'prompt_categories.json', initialize, {})
129
+
130
+
131
+ def modify_category(store, defaults, kind, name, *, delete=False, maximum=30):
132
+ if kind not in defaults or not isinstance(name, str) or not name.strip():
133
+ raise PromptStoreError(400, '分类参数无效')
134
+ def update(source):
135
+ completed = _categories(source, defaults)
136
+ values = completed[kind]
137
+ if delete:
138
+ if name not in values: raise PromptStoreError(404, '分类不存在')
139
+ values.remove(name)
140
+ else:
141
+ if name in values: raise PromptStoreError(400, '该分类已存在')
142
+ if len(values) >= maximum: raise PromptStoreError(400, f'分类数量已达上限({maximum})')
143
+ values.append(name)
144
+ source.clear(); source.update(completed)
145
+ return copy.deepcopy(values)
146
+ return _mutate(store, 'prompt_categories.json', update, {})
proxy_access.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """下载代理共用资源/有效价格/当前所有权核查,不把存储故障视为免费。"""
2
+ import asyncio
3
+ from models_sql import Ownership
4
+ from purchase_transaction import effective_resource_price
5
+
6
+
7
+ class ProxyAccessError(Exception):
8
+ def __init__(self, status_code, message):
9
+ self.status_code = status_code
10
+ self.message = message
11
+ super().__init__(message)
12
+
13
+
14
+ def read_proxy_item(store, item_id):
15
+ try:
16
+ source = store.load_data('items.json', default_data=[], strict=True, skip_cache=True)
17
+ if not isinstance(source, list):
18
+ raise ValueError('invalid resource source')
19
+ if any(not isinstance(item, dict) or not isinstance(item.get('id'), str) or not item['id'] for item in source):
20
+ raise ValueError('invalid resource record')
21
+ matches = [item for item in source if item.get('id') == item_id]
22
+ if not matches:
23
+ raise ProxyAccessError(404, '资源不存在')
24
+ if len(matches) != 1:
25
+ raise ValueError('ambiguous resource')
26
+ item = matches[0]
27
+ author = item.get('author')
28
+ if not isinstance(author, str) or not author.strip():
29
+ raise ValueError('invalid resource author')
30
+ effective_resource_price(item)
31
+ return item
32
+ except ProxyAccessError:
33
+ raise
34
+ except Exception:
35
+ raise ProxyAccessError(503, '资源信息暂时无法核实,请稍后核对') from None
36
+
37
+
38
+ async def authorized_proxy_item(store, session, item_id, account):
39
+ try:
40
+ item = await asyncio.to_thread(read_proxy_item, store, item_id)
41
+ author = item['author']
42
+ price = effective_resource_price(item)
43
+ if price > 0 and account != author:
44
+ # SQL会话仍留在调用线程;不用陈旧的身份/拥有缓存,也不授予已退款记录。
45
+ ownerships = session.query(Ownership).filter(Ownership.account == account, Ownership.item_id == item_id).populate_existing().all()
46
+ if any(type(row.is_refunded) is not bool for row in ownerships):
47
+ raise ValueError('invalid ownership refund state')
48
+ rows = [row for row in ownerships if not row.is_refunded]
49
+ if len(rows) > 1:
50
+ raise ValueError('ambiguous ownership')
51
+ if not rows:
52
+ raise ProxyAccessError(403, '尚未获取该资源')
53
+ return item
54
+ except ProxyAccessError:
55
+ raise
56
+ except Exception:
57
+ raise ProxyAccessError(503, '下载授权暂时无法核实,请稍后核对') from None
58
+ finally:
59
+ try:
60
+ session.rollback() # 不让下载流长期持有所有权读取事务。
61
+ except Exception:
62
+ raise ProxyAccessError(503, '下载授权暂时无法核实,请稍后核对') from None
proxy_resources.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """代理请求的所属响应/客户端收尾:每对象一次,取消等待同一任务。"""
2
+ import asyncio
3
+ import logging
4
+
5
+ logger = logging.getLogger("ComfyUI-Ranking")
6
+
7
+
8
+ async def _await_close(task):
9
+ cancelled = None
10
+ while not task.done():
11
+ try:
12
+ await asyncio.shield(task)
13
+ except asyncio.CancelledError as error:
14
+ cancelled = error
15
+ task.result()
16
+ if cancelled is not None:
17
+ raise cancelled
18
+
19
+
20
+ class ProxyResources:
21
+ def __init__(self, client):
22
+ self.client = client
23
+ self._responses = []
24
+ self._response_tasks = {}
25
+ self._close_task = None
26
+
27
+ def track(self, response):
28
+ if self._close_task is not None:
29
+ raise RuntimeError("proxy resources already closing")
30
+ if not any(previous is response for previous in self._responses):
31
+ self._responses.append(response)
32
+ return response
33
+
34
+ async def close_response(self, response):
35
+ key = id(response)
36
+ if key not in self._response_tasks:
37
+ async def finish():
38
+ try:
39
+ await response.aclose()
40
+ except Exception as error:
41
+ logger.warning("PROXY_RESPONSE_CLOSE_FAIL | error_type=%s", type(error).__name__)
42
+ self._response_tasks[key] = asyncio.create_task(finish())
43
+ await _await_close(self._response_tasks[key])
44
+
45
+ async def close(self):
46
+ if self._close_task is None:
47
+ async def finish():
48
+ cancelled = None
49
+ try:
50
+ for response in self._responses:
51
+ try:
52
+ await self.close_response(response)
53
+ except asyncio.CancelledError as error:
54
+ cancelled = error
55
+ finally:
56
+ try:
57
+ await self.client.aclose()
58
+ except Exception as error:
59
+ logger.warning("PROXY_CLIENT_CLOSE_FAIL | error_type=%s", type(error).__name__)
60
+ if cancelled is not None:
61
+ raise cancelled
62
+ self._close_task = asyncio.create_task(finish())
63
+ await _await_close(self._close_task)
public_list_etag.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bounded conditional replies for anonymous public list JSON responses."""
2
+ import hashlib
3
+
4
+ PUBLIC_LIST_PATHS = frozenset({'/api/items', '/api/creators', '/api/creators/search',
5
+ '/api/posts', '/api/prompts', '/api/tasks'})
6
+ MAX_JSON_BYTES = 1024 * 1024
7
+ MAX_VALIDATOR_BYTES = 4096
8
+
9
+
10
+ def _matches(value, etag):
11
+ if len(value) > MAX_VALIDATOR_BYTES:
12
+ return False
13
+ for candidate in value.split(','):
14
+ candidate = candidate.strip()
15
+ if candidate == '*' or candidate.removeprefix('W/') == etag.removeprefix('W/'):
16
+ return True
17
+ return False
18
+
19
+
20
+ class PublicListETag:
21
+ """Inspect a single bounded JSON body; streams and private responses pass through."""
22
+ def __init__(self, app):
23
+ self.app = app
24
+
25
+ async def __call__(self, scope, receive, send):
26
+ headers = {key.lower(): value for key, value in scope.get('headers', [])}
27
+ eligible = (scope.get('type') == 'http' and scope.get('method') == 'GET'
28
+ and scope.get('path') in PUBLIC_LIST_PATHS
29
+ and b'authorization' not in headers and b'cookie' not in headers)
30
+ if not eligible:
31
+ return await self.app(scope, receive, send)
32
+ validator = headers.get(b'if-none-match', b'').decode('latin-1')
33
+ start = None
34
+ bypass = False
35
+
36
+ async def conditional_send(message):
37
+ nonlocal start, bypass
38
+ if bypass:
39
+ return await send(message)
40
+ if message['type'] == 'http.response.start':
41
+ response_headers = {key.lower(): value for key, value in message.get('headers', [])}
42
+ media = response_headers.get(b'content-type', b'').split(b';', 1)[0].strip().lower()
43
+ if (message['status'] != 200 or media != b'application/json'
44
+ or b'content-encoding' in response_headers or b'etag' in response_headers
45
+ or b'set-cookie' in response_headers
46
+ or b'no-store' in response_headers.get(b'cache-control', b'').lower()):
47
+ bypass = True
48
+ return await send(message)
49
+ start = message
50
+ return
51
+ if message['type'] != 'http.response.body' or start is None:
52
+ return await send(message)
53
+ body = message.get('body', b'')
54
+ if message.get('more_body', False) or len(body) > MAX_JSON_BYTES:
55
+ bypass = True
56
+ await send(start)
57
+ return await send(message)
58
+ etag = 'W/"rk-' + hashlib.sha256(body).hexdigest() + '"'
59
+ updated = [(key, value) for key, value in start.get('headers', [])
60
+ if key.lower() not in {b'cache-control', b'etag'}]
61
+ updated.extend([(b'etag', etag.encode('ascii')), (b'cache-control', b'private, no-cache')])
62
+ if _matches(validator, etag):
63
+ updated = [(key, value) for key, value in updated if key.lower() != b'content-length']
64
+ await send({**start, 'status': 304, 'headers': updated})
65
+ await send({'type': 'http.response.body', 'body': b''})
66
+ else:
67
+ await send({**start, 'headers': updated})
68
+ await send(message)
69
+ bypass = True
70
+
71
+ await self.app(scope, receive, conditional_send)
publication_identity.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """可选发布请求身份;只提供现存内容记录范围内的重复创建确认。"""
2
+ import hashlib
3
+ import json
4
+ import re
5
+
6
+
7
+ def publication_identity(kind, account, key):
8
+ if key is None:
9
+ return None
10
+ if kind not in {'post', 'prompt'} or not isinstance(account, str) or not account:
11
+ raise ValueError('invalid publication owner')
12
+ if not isinstance(key, str) or re.fullmatch(r'[0-9a-f]{32}', key) is None:
13
+ raise ValueError('invalid publication key')
14
+ data = json.dumps([kind, account, key], ensure_ascii=False, separators=(',', ':')).encode('utf-8')
15
+ return f'{kind}_publish_{hashlib.sha256(data).hexdigest()}'
16
+
17
+
18
+ def same_publication(existing, incoming, fields):
19
+ def content(record):
20
+ return json.dumps({field: record.get(field) for field in fields}, ensure_ascii=False,
21
+ sort_keys=True, separators=(',', ':'), allow_nan=False)
22
+ return content(existing) == content(incoming)
purchase_transaction.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """资源购买的有效价格与写事务准备;调用方负责提交/回滚。"""
2
+ import datetime
3
+ from tip_transaction import lock_wallet_accounts
4
+
5
+
6
+ def effective_resource_price(item):
7
+ price = item.get("price", 0)
8
+ pending = item.get("pending_price")
9
+ effective = item.get("pending_price_effective_at")
10
+ if pending is not None and effective:
11
+ when = datetime.datetime.fromisoformat(effective)
12
+ now = datetime.datetime.now(when.tzinfo) if when.tzinfo else datetime.datetime.now()
13
+ if now >= when:
14
+ price = pending
15
+ if isinstance(price, bool) or not isinstance(price, int) or price < 0:
16
+ raise ValueError("invalid resource price")
17
+ return price
18
+
19
+
20
+ def lock_resource_purchase_wallets(session, buyer, seller, free):
21
+ # 免费/作者本人仍需买方钱包行作为跨进程所有权查重的锁锚点。
22
+ accounts = (buyer,) if free else (buyer, seller)
23
+ create = (buyer,) if free else (seller,)
24
+ wallets = lock_wallet_accounts(session, accounts, create)
25
+ return wallets[buyer], wallets.get(seller)
rate_limit.py ADDED
@@ -0,0 +1,290 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """跨 worker API 限流:已验证用户按账号计数,匿名请求按客户端 IP 计数。"""
2
+
3
+ from datetime import datetime
4
+ from functools import wraps
5
+ import hashlib
6
+ import hmac
7
+ import logging
8
+ import threading
9
+ import time
10
+
11
+ from fastapi import Request
12
+ from fastapi import HTTPException
13
+ from starlette.concurrency import run_in_threadpool
14
+ from sqlalchemy import case, delete
15
+ from sqlalchemy.dialects.mysql import insert as mysql_insert
16
+ from sqlalchemy.dialects.postgresql import insert as postgresql_insert
17
+ from sqlalchemy.dialects.sqlite import insert as sqlite_insert
18
+
19
+ from 安全认证 import get_account_from_token
20
+ from database_sql import SessionLocal, engine
21
+ from models_sql import ApiRateLimitBucket, VerificationCode, VerificationContactCooldown
22
+ from 安全认证 import JWT_SECRET
23
+
24
+ logger = logging.getLogger("ComfyUI-Ranking.RateLimit")
25
+ _cleanup_lock = threading.Lock()
26
+ _last_cleanup_at = 0.0
27
+
28
+
29
+ def get_remote_address(request: Request) -> str:
30
+ """仅信任 ASGI transport 提供的客户端地址,不直接信任可伪造的代理头。"""
31
+ client = getattr(request, "client", None)
32
+ return getattr(client, "host", None) or "unknown"
33
+
34
+
35
+ def get_rate_limit_key(request: Request) -> str:
36
+ """返回稳定的限流身份键;只读取并验证 Bearer Token,不记录或返回 Token。"""
37
+ authorization = request.headers.get("Authorization", "")
38
+ scheme, _, token = authorization.partition(" ")
39
+ if scheme.lower() == "bearer" and token.strip():
40
+ account = get_account_from_token(token.strip())
41
+ if account:
42
+ return f"account:{account.casefold()}"
43
+ return f"ip:{get_remote_address(request)}"
44
+
45
+
46
+ def _consume_shared_limit(identity: str, scope: str, limit: int) -> None:
47
+ """使用数据库原子 UPSERT 计数;数据库不可用时 fail closed。"""
48
+ now = int(time.time())
49
+ window_start = now - (now % 60)
50
+ bucket_key = hashlib.sha256(f"{identity}\0{scope}".encode("utf-8")).hexdigest()
51
+ session = SessionLocal()
52
+ try:
53
+ dialect = engine.dialect.name
54
+ values = {
55
+ "bucket_key": bucket_key,
56
+ "window_start": window_start,
57
+ "request_count": 1,
58
+ "updated_at": datetime.utcnow(),
59
+ }
60
+ if dialect == "sqlite":
61
+ statement = sqlite_insert(ApiRateLimitBucket).values(**values)
62
+ statement = statement.on_conflict_do_update(
63
+ index_elements=[ApiRateLimitBucket.bucket_key],
64
+ set_={
65
+ "window_start": window_start,
66
+ "request_count": case(
67
+ (ApiRateLimitBucket.window_start == window_start,
68
+ case((ApiRateLimitBucket.request_count < limit + 1,
69
+ ApiRateLimitBucket.request_count + 1), else_=limit + 1)),
70
+ else_=1,
71
+ ),
72
+ "updated_at": datetime.utcnow(),
73
+ },
74
+ )
75
+ elif dialect == "postgresql":
76
+ statement = postgresql_insert(ApiRateLimitBucket).values(**values)
77
+ statement = statement.on_conflict_do_update(
78
+ index_elements=[ApiRateLimitBucket.bucket_key],
79
+ set_={
80
+ "window_start": window_start,
81
+ "request_count": case(
82
+ (ApiRateLimitBucket.window_start == window_start,
83
+ case((ApiRateLimitBucket.request_count < limit + 1,
84
+ ApiRateLimitBucket.request_count + 1), else_=limit + 1)),
85
+ else_=1,
86
+ ),
87
+ "updated_at": datetime.utcnow(),
88
+ },
89
+ )
90
+ elif dialect == "mysql":
91
+ statement = mysql_insert(ApiRateLimitBucket).values(**values)
92
+ statement = statement.on_duplicate_key_update(
93
+ [
94
+ # MySQL evaluates ON DUPLICATE KEY UPDATE assignments left-to-right.
95
+ # Read the old window before replacing it, or a new minute would
96
+ # incorrectly continue the prior minute's count.
97
+ ("request_count", case(
98
+ (ApiRateLimitBucket.window_start == window_start,
99
+ case((ApiRateLimitBucket.request_count < limit + 1,
100
+ ApiRateLimitBucket.request_count + 1), else_=limit + 1)),
101
+ else_=1,
102
+ )),
103
+ ("window_start", window_start),
104
+ ("updated_at", datetime.utcnow()),
105
+ ]
106
+ )
107
+ else:
108
+ raise RuntimeError(f"Unsupported SQL dialect for shared rate limiting: {dialect}")
109
+
110
+ session.execute(statement)
111
+ bucket = session.get(ApiRateLimitBucket, bucket_key)
112
+ if not bucket:
113
+ raise RuntimeError("Rate limit bucket upsert did not produce a row")
114
+ request_count = bucket.request_count
115
+ session.commit()
116
+ if request_count > limit:
117
+ raise HTTPException(status_code=429, detail=f"请求过于频繁,限流 {limit}/分钟,请稍后再试")
118
+ _cleanup_old_buckets_if_due()
119
+ except HTTPException:
120
+ session.rollback()
121
+ raise
122
+ except Exception as exc:
123
+ session.rollback()
124
+ logger.error("SHARED_RATE_LIMIT_DB_FAIL | scope=%s | error_type=%s", scope, type(exc).__name__)
125
+ raise HTTPException(status_code=503, detail="限流服务暂不可用") from exc
126
+ finally:
127
+ session.close()
128
+
129
+
130
+ def _cleanup_old_buckets_if_due() -> None:
131
+ global _last_cleanup_at
132
+ now = time.time()
133
+ if now - _last_cleanup_at < 900:
134
+ return
135
+ if not _cleanup_lock.acquire(blocking=False):
136
+ return
137
+ try:
138
+ if now - _last_cleanup_at < 900:
139
+ return
140
+ session = SessionLocal()
141
+ try:
142
+ session.execute(delete(ApiRateLimitBucket).where(ApiRateLimitBucket.window_start < int(now) - 86_400))
143
+ session.execute(delete(VerificationContactCooldown).where(
144
+ VerificationContactCooldown.last_sent_at < int(now) - 86_400
145
+ ))
146
+ session.execute(delete(VerificationCode).where(VerificationCode.expires_at < int(now)))
147
+ session.commit()
148
+ _last_cleanup_at = now
149
+ except Exception:
150
+ session.rollback()
151
+ logger.warning("SHARED_RATE_LIMIT_CLEANUP_FAIL")
152
+ finally:
153
+ session.close()
154
+ finally:
155
+ _cleanup_lock.release()
156
+
157
+
158
+ def shared_limit(limit: int):
159
+ """FastAPI endpoint decorator backed by the shared SQL minute-window table."""
160
+ if not isinstance(limit, int) or limit < 1:
161
+ raise ValueError("limit must be a positive integer")
162
+
163
+ def decorate(endpoint):
164
+ scope = f"{endpoint.__module__}.{endpoint.__name__}"
165
+
166
+ @wraps(endpoint)
167
+ async def wrapped(*args, **kwargs):
168
+ request = kwargs.get("request")
169
+ if request is None:
170
+ request = next((arg for arg in args if isinstance(arg, Request)), None)
171
+ if request is None:
172
+ raise RuntimeError(f"{scope} requires a FastAPI Request parameter")
173
+ identity = await run_in_threadpool(get_rate_limit_key, request)
174
+ await run_in_threadpool(_consume_shared_limit, identity, scope, limit)
175
+ return await endpoint(*args, **kwargs)
176
+
177
+ return wrapped
178
+
179
+ return decorate
180
+
181
+
182
+ def reserve_contact_cooldown(contact: str, cooldown_seconds: int) -> int:
183
+ """原子预留联系方式冷却;返回 0 表示成功,否则返回剩余秒数。"""
184
+ normalized = str(contact or "").strip().casefold()
185
+ contact_key = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
186
+ now = int(time.time())
187
+ cutoff = now - cooldown_seconds
188
+ claim_id = hashlib.sha256(f"{contact_key}:{now}:{time.time_ns()}".encode()).hexdigest()[:32]
189
+ session = SessionLocal()
190
+ try:
191
+ values = {"contact_key": contact_key, "last_sent_at": now, "claim_id": claim_id}
192
+ dialect = engine.dialect.name
193
+ if dialect == "sqlite":
194
+ statement = sqlite_insert(VerificationContactCooldown).values(**values)
195
+ statement = statement.on_conflict_do_update(
196
+ index_elements=[VerificationContactCooldown.contact_key],
197
+ set_={
198
+ "last_sent_at": case((VerificationContactCooldown.last_sent_at <= cutoff, now),
199
+ else_=VerificationContactCooldown.last_sent_at),
200
+ "claim_id": case((VerificationContactCooldown.last_sent_at <= cutoff, claim_id),
201
+ else_=VerificationContactCooldown.claim_id),
202
+ },
203
+ )
204
+ elif dialect == "postgresql":
205
+ statement = postgresql_insert(VerificationContactCooldown).values(**values)
206
+ statement = statement.on_conflict_do_update(
207
+ index_elements=[VerificationContactCooldown.contact_key],
208
+ set_={
209
+ "last_sent_at": case((VerificationContactCooldown.last_sent_at <= cutoff, now),
210
+ else_=VerificationContactCooldown.last_sent_at),
211
+ "claim_id": case((VerificationContactCooldown.last_sent_at <= cutoff, claim_id),
212
+ else_=VerificationContactCooldown.claim_id),
213
+ },
214
+ )
215
+ elif dialect == "mysql":
216
+ statement = mysql_insert(VerificationContactCooldown).values(**values)
217
+ statement = statement.on_duplicate_key_update(
218
+ [
219
+ # Evaluate eligibility against the old timestamp before advancing it.
220
+ ("claim_id", case((VerificationContactCooldown.last_sent_at <= cutoff, claim_id),
221
+ else_=VerificationContactCooldown.claim_id)),
222
+ ("last_sent_at", case((VerificationContactCooldown.last_sent_at <= cutoff, now),
223
+ else_=VerificationContactCooldown.last_sent_at)),
224
+ ]
225
+ )
226
+ else:
227
+ raise RuntimeError(f"Unsupported SQL dialect for contact cooldown: {dialect}")
228
+
229
+ session.execute(statement)
230
+ record = session.get(VerificationContactCooldown, contact_key)
231
+ if not record:
232
+ raise RuntimeError("Contact cooldown upsert did not produce a row")
233
+ allowed = record.claim_id == claim_id
234
+ last_sent_at = record.last_sent_at
235
+ session.commit()
236
+ return 0 if allowed else max(1, cooldown_seconds - (now - last_sent_at))
237
+ except Exception as exc:
238
+ session.rollback()
239
+ logger.error("CONTACT_COOLDOWN_DB_FAIL | error_type=%s", type(exc).__name__)
240
+ raise HTTPException(status_code=503, detail="验证码发送频控暂不可用") from exc
241
+ finally:
242
+ session.close()
243
+
244
+
245
+ def _verification_key(key: str) -> str:
246
+ return hashlib.sha256(str(key).strip().casefold().encode("utf-8")).hexdigest()
247
+
248
+
249
+ def _verification_digest(key: str, code: str) -> str:
250
+ return hmac.new(JWT_SECRET.encode("utf-8"), f"{_verification_key(key)}\0{code}".encode("utf-8"), hashlib.sha256).hexdigest()
251
+
252
+
253
+ def store_verification_code(key: str, code: str, expires_at: int) -> None:
254
+ """存储跨 worker 可用的一次性验证码,不保存验证码或联系方式明文。"""
255
+ session = SessionLocal()
256
+ try:
257
+ session.merge(VerificationCode(contact_key=_verification_key(key),
258
+ code_digest=_verification_digest(key, code),
259
+ expires_at=int(expires_at)))
260
+ session.commit()
261
+ except Exception as exc:
262
+ session.rollback()
263
+ logger.error("VERIFICATION_CODE_STORE_FAIL | error_type=%s", type(exc).__name__)
264
+ raise HTTPException(status_code=503, detail="验证码服务暂不可用") from exc
265
+ finally:
266
+ session.close()
267
+
268
+
269
+ def consume_verification_code(key: str, code: str, session=None) -> bool:
270
+ """正确且未过期时原子删除验证码;可传入业务事务 session 一并提交。"""
271
+ owns_session = session is None
272
+ session = session or SessionLocal()
273
+ try:
274
+ result = session.execute(delete(VerificationCode).where(
275
+ VerificationCode.contact_key == _verification_key(key),
276
+ VerificationCode.code_digest == _verification_digest(key, code),
277
+ VerificationCode.expires_at >= int(time.time()),
278
+ ))
279
+ consumed = result.rowcount == 1
280
+ if owns_session:
281
+ session.commit()
282
+ return consumed
283
+ except Exception as exc:
284
+ if owns_session:
285
+ session.rollback()
286
+ logger.error("VERIFICATION_CODE_CONSUME_FAIL | error_type=%s", type(exc).__name__)
287
+ raise HTTPException(status_code=503, detail="验证码服务暂不可用") from exc
288
+ finally:
289
+ if owns_session:
290
+ session.close()
recharge_transaction.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """充值只信任持久订单;回调与主动查单共用同一到账事务。"""
2
+ import hashlib
3
+ import re
4
+ from decimal import Decimal
5
+ from sqlalchemy.orm import Session
6
+ from models_sql import RechargeOrder, Transaction
7
+ from tip_transaction import lock_wallet_accounts
8
+
9
+
10
+ def payment_amount(value):
11
+ if not isinstance(value, str) or not re.fullmatch(r"[0-9]{1,5}(?:\.[0-9]{1,2})?", value):
12
+ raise ValueError("invalid payment amount")
13
+ amount = Decimal(value)
14
+ if not 1 <= amount <= 10000 or amount != amount.to_integral_value():
15
+ raise ValueError("invalid payment amount")
16
+ return int(amount)
17
+
18
+
19
+ def validate_order(row):
20
+ if (row is None or not isinstance(row.account, str) or not row.account.strip()
21
+ or type(row.amount) is not int or not 1 <= row.amount <= 10000
22
+ or row.status not in {"pending", "paid"}):
23
+ raise ValueError("invalid recharge order")
24
+
25
+
26
+ def validate_ledger(tx, account, amount):
27
+ if tx is not None and (tx.account != account or tx.tx_type != "RECHARGE"
28
+ or type(tx.amount) is not int or tx.amount != amount):
29
+ raise ValueError("invalid recharge ledger")
30
+
31
+
32
+ def read_order(db, order_id):
33
+ if not isinstance(order_id, str) or not re.fullmatch(r"PAY_[0-9a-f]{32}", order_id):
34
+ return None
35
+ # 网络查单前的读取不在业务 Session 留下事务或钱包锁。
36
+ with Session(bind=db.get_bind(), autoflush=False) as lookup:
37
+ row = lookup.get(RechargeOrder, order_id)
38
+ if row is None:
39
+ return None
40
+ validate_order(row)
41
+ tx = lookup.get(Transaction, order_id)
42
+ validate_ledger(tx, row.account, row.amount)
43
+ if row.status == "paid" and tx is None:
44
+ raise ValueError("paid recharge ledger missing")
45
+ return {"account": row.account, "amount": row.amount, "credited": tx is not None}
46
+
47
+
48
+ def persist_order(db, order_id, account, amount):
49
+ if type(amount) is not int or not 1 <= amount <= 10000 or not isinstance(account, str) or not account.strip():
50
+ raise ValueError("invalid recharge request")
51
+ try:
52
+ db.add(RechargeOrder(order_id=order_id, account=account, amount=amount, status="pending"))
53
+ db.commit()
54
+ except Exception:
55
+ db.rollback()
56
+ raise
57
+
58
+
59
+ def credit_order(db, order_id, amount):
60
+ info = read_order(db, order_id)
61
+ if info is None or type(amount) is not int or amount != info["amount"]:
62
+ raise ValueError("payment order mismatch")
63
+ account = info["account"]
64
+ try:
65
+ wallet = lock_wallet_accounts(db, (account,), (account,))[account]
66
+ row = db.query(RechargeOrder).filter(RechargeOrder.order_id == order_id).populate_existing().with_for_update().first()
67
+ validate_order(row)
68
+ if row.account != account or row.amount != amount:
69
+ raise ValueError("payment order changed")
70
+ tx = db.query(Transaction).filter(Transaction.tx_id == order_id).populate_existing().with_for_update().first()
71
+ validate_ledger(tx, account, amount)
72
+ if tx is not None:
73
+ row.status = "paid"
74
+ db.commit()
75
+ return False
76
+ if row.status != "pending" or type(wallet.balance) is not int:
77
+ raise ValueError("invalid recharge state")
78
+ last = db.query(Transaction).filter(Transaction.account == account).order_by(
79
+ Transaction.created_at.desc(), Transaction.tx_id.desc()).with_for_update().first()
80
+ previous = last.tx_hash if last else "GENESIS_HASH"
81
+ digest = hashlib.sha256(f"{order_id}{account}RECHARGE{amount}{previous}".encode()).hexdigest()
82
+ wallet.balance += amount
83
+ row.status = "paid"
84
+ db.add(Transaction(tx_id=order_id, account=account, tx_type="RECHARGE", amount=amount,
85
+ prev_hash=previous, tx_hash=digest, description="支付宝充值"))
86
+ db.commit()
87
+ return True
88
+ except Exception:
89
+ db.rollback()
90
+ raise
requirements-build.lock ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Source SDK build tools, pinned separately from runtime dependencies.
2
+
3
+ packaging==26.3 \
4
+ --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \
5
+ --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
6
+
7
+ setuptools==84.0.0 \
8
+ --hash=sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670 \
9
+ --hash=sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73
10
+
11
+ wheel==0.48.0 \
12
+ --hash=sha256:3217dcc807155e45db462d7ef2431f5ddda0d7273b700d05a67b271ceb1287ab \
13
+ --hash=sha256:94800765601e9171bf5d58d066e640662842bcedcbab982b2c90787a2c987322
requirements.lock ADDED
The diff for this file is too large to render. See raw diff
 
requirements.txt CHANGED
@@ -1,19 +1,23 @@
1
- # 🔒 P0安全修复:依赖版本锁定,防止供应链攻击和不兼容更新
2
- fastapi==0.104.1
3
  uvicorn==0.24.0
4
- pydantic==2.5.2
5
  huggingface_hub==0.19.4
6
  # 🧹 P2清理:datasets/aiofiles 全库零引用,已移除
7
- python-multipart==0.0.6
8
  alibabacloud_dysmsapi20170525==2.0.24
9
  # 🧹 P2修复:verify_code_engine.py 直接导入 alibabacloud_tea_openapi,显式声明避免隐式依赖漂移
10
  alibabacloud_tea_openapi==0.3.8
11
  sqlalchemy==2.0.23
12
  psycopg2-binary==2.9.9
13
  httpx==0.25.2
14
- # 🚀 P2优化:速率限制
15
- slowapi==0.1.9
16
  # 🔒 P0安全增强:bcrypt密码哈希
17
  bcrypt==4.1.2
18
  # 🔧 P0修复:升级支付宝SDK,打破旧版死锁,自动适配最新安全加密库
19
- python-alipay-sdk>=3.3.0
 
 
 
 
 
1
+ # 直接依赖入口;生产部署以requirements.lock固定完整依赖并校验SHA256
2
+ fastapi==0.142.2
3
  uvicorn==0.24.0
4
+ pydantic==2.13.5
5
  huggingface_hub==0.19.4
6
  # 🧹 P2清理:datasets/aiofiles 全库零引用,已移除
7
+ python-multipart==0.0.32
8
  alibabacloud_dysmsapi20170525==2.0.24
9
  # 🧹 P2修复:verify_code_engine.py 直接导入 alibabacloud_tea_openapi,显式声明避免隐式依赖漂移
10
  alibabacloud_tea_openapi==0.3.8
11
  sqlalchemy==2.0.23
12
  psycopg2-binary==2.9.9
13
  httpx==0.25.2
14
+ # HTTPX 0.25.2 使用的连接后端直接依赖,供下载代理绑定已校验 DNS 地址到 TCP 连接
15
+ httpcore==1.0.9
16
  # 🔒 P0安全增强:bcrypt密码哈希
17
  bcrypt==4.1.2
18
  # 🔧 P0修复:升级支付宝SDK,打破旧版死锁,自动适配最新安全加密库
19
+ python-alipay-sdk==3.4.0
20
+
21
+ # 表单解析与异步资源管理的安全修复版本,与FastAPI组合验证
22
+ starlette==1.7.0
23
+ anyio==4.15.1
resource_deletion.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """资源删除的本地恢复记录与关联清理;不访问或修改资金数据。"""
2
+ import asyncio
3
+ import copy
4
+ import datetime
5
+ import logging
6
+ import re
7
+ import threading
8
+ import uuid
9
+ from resource_store import ResourceStoreError, _records
10
+
11
+ DELETION_FILE = 'resource_deletions.json'
12
+ resource_mutation_lock = threading.RLock()
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def _queue(source):
17
+ if not isinstance(source, dict):
18
+ raise ValueError('invalid resource deletion source')
19
+ for item_id, entry in source.items():
20
+ if (not isinstance(item_id, str) or not item_id.strip() or not isinstance(entry, dict)
21
+ or entry.get('item_id') != item_id or type(entry.get('schema')) is not int or entry['schema'] != 1
22
+ or not isinstance(entry.get('author'), str) or not entry['author'].strip()
23
+ or not isinstance(entry.get('ticket'), str) or not re.fullmatch(r'[0-9a-f]{32}', entry['ticket'])
24
+ or entry.get('state') not in ('pending', 'complete')):
25
+ raise ValueError('invalid resource deletion record')
26
+ return source
27
+
28
+
29
+ def _read_queue(store):
30
+ return _queue(store.load_data(DELETION_FILE, default_data={}, strict=True, skip_cache=True))
31
+
32
+
33
+ def _read_items(store):
34
+ return _records(store.load_data('items.json', default_data=[], strict=True, skip_cache=True))
35
+
36
+
37
+ def assert_id_not_deleted(store, item_id):
38
+ if item_id in _read_queue(store):
39
+ raise ResourceStoreError(409, '资源编号已用于删除,不能重复使用')
40
+
41
+
42
+ def assert_comment_target_active(store, item_id):
43
+ if item_id not in _read_queue(store):
44
+ return
45
+ if not any(item['id'] == item_id for item in _read_items(store)):
46
+ raise ResourceStoreError(404, '资源已删除,不能添加评论')
47
+
48
+
49
+ def _authorized(author, account, can_moderate):
50
+ if not isinstance(author, str) or not author.strip():
51
+ raise ValueError('invalid resource author')
52
+ if author != account and can_moderate is not True:
53
+ raise ResourceStoreError(403, '无权删除他人发布的内容')
54
+
55
+
56
+ def _prepare(store, item):
57
+ receipt = dict(schema=1, item_id=item['id'], author=item['author'], ticket=uuid.uuid4().hex,
58
+ state='pending', created_at=datetime.datetime.now(datetime.timezone.utc).isoformat())
59
+ expected = None
60
+ def update(source):
61
+ nonlocal expected
62
+ queue = _queue(source)
63
+ old = queue.get(item['id'])
64
+ if old:
65
+ if old['author'] != item['author']:
66
+ raise ValueError('ambiguous deletion owner')
67
+ if old['state'] == 'pending':
68
+ expected = copy.deepcopy(old)
69
+ return copy.deepcopy(old)
70
+ queue[item['id']] = copy.deepcopy(receipt)
71
+ expected = copy.deepcopy(receipt)
72
+ return copy.deepcopy(receipt)
73
+ try:
74
+ return store.atomic_update(DELETION_FILE, update, default_data={})
75
+ except Exception:
76
+ confirmed = _read_queue(store).get(item['id'])
77
+ if expected and confirmed == expected and confirmed['state'] == 'pending':
78
+ return copy.deepcopy(confirmed)
79
+ raise
80
+
81
+
82
+ def _drop_bucket(store, filename, item_id):
83
+ source = store.load_data(filename, default_data={}, strict=True, skip_cache=True)
84
+ if not isinstance(source, dict):
85
+ raise ValueError('invalid associated resource source')
86
+ if item_id not in source:
87
+ return False
88
+ def update(current):
89
+ if not isinstance(current, dict):
90
+ raise ValueError('invalid associated resource source')
91
+ return current.pop(item_id, None) is not None
92
+ store.atomic_update(filename, update, default_data={})
93
+ return True
94
+
95
+
96
+ def _cleanup(store, receipt):
97
+ """调用者持协调锁;严格确认主资源已不存在才清理本ID关联数据。"""
98
+ item_id = receipt['item_id']
99
+ if any(item['id'] == item_id for item in _read_items(store)):
100
+ return False, False
101
+ current = _read_queue(store).get(item_id)
102
+ if not current or current['ticket'] != receipt['ticket']:
103
+ return False, False
104
+ changed = _drop_bucket(store, 'comments.json', item_id)
105
+ changed = _drop_bucket(store, 'versions.json', item_id) or changed
106
+ if current['state'] == 'complete':
107
+ return True, changed
108
+ def acknowledge(source):
109
+ queue = _queue(source)
110
+ entry = queue.get(item_id)
111
+ if not entry or entry['ticket'] != receipt['ticket']:
112
+ return False
113
+ entry['state'] = 'complete'
114
+ entry.setdefault('completed_at', datetime.datetime.now(datetime.timezone.utc).isoformat())
115
+ return True
116
+ acknowledged = store.atomic_update(DELETION_FILE, acknowledge, default_data={})
117
+ return bool(acknowledged), bool(acknowledged) or changed
118
+
119
+
120
+ def delete_resource(store, item_id, account, can_moderate=False):
121
+ try:
122
+ with resource_mutation_lock:
123
+ source = _read_items(store)
124
+ item = next((item for item in source if item['id'] == item_id), None)
125
+ if item is None:
126
+ receipt = _read_queue(store).get(item_id)
127
+ if receipt is None:
128
+ raise ResourceStoreError(404, '找不到该内容记录')
129
+ _authorized(receipt['author'], account, can_moderate)
130
+ else:
131
+ _authorized(item.get('author'), account, can_moderate)
132
+ receipt = _prepare(store, item)
133
+ attempted = False
134
+ def remove(current):
135
+ nonlocal attempted
136
+ records = _records(current)
137
+ index = next((index for index, value in enumerate(records) if value['id'] == item_id), None)
138
+ if index is not None:
139
+ _authorized(records[index].get('author'), account, can_moderate)
140
+ if records[index]['author'] != receipt['author']:
141
+ raise ValueError('changed resource author')
142
+ records.pop(index)
143
+ attempted = True
144
+ try:
145
+ store.atomic_update('items.json', remove, default_data=[])
146
+ except ResourceStoreError:
147
+ raise
148
+ except Exception:
149
+ if not attempted or any(item['id'] == item_id for item in _read_items(store)):
150
+ raise
151
+ logger.warning('资源主删除本地已核实,后续同步暂未确认')
152
+ # 清理失败不能把已经核实的主删除误报为失败;保留恢复记录继续重试。
153
+ try:
154
+ complete, _ = _cleanup(store, receipt)
155
+ except Exception as error:
156
+ logger.warning('资源删除关联清理待重试: error_type=%s', type(error).__name__)
157
+ complete = False
158
+ return dict(cleanup_pending=not complete)
159
+ except ResourceStoreError:
160
+ raise
161
+ except Exception:
162
+ raise ResourceStoreError(503, '资源删除暂时无法核实,请稍后核对') from None
163
+
164
+
165
+ def repair_resource_deletions(store, cursor=0, limit=50, should_stop=lambda: False):
166
+ queue = _read_queue(store)
167
+ ids = list(queue)
168
+ if not ids:
169
+ return 0, 0
170
+ start = cursor % len(ids)
171
+ selected = (ids[start:] + ids[:start])[:limit]
172
+ changed = 0
173
+ processed = 0
174
+ for item_id in selected:
175
+ if should_stop():
176
+ break
177
+ try:
178
+ with resource_mutation_lock:
179
+ _, updated = _cleanup(store, queue[item_id])
180
+ changed += bool(updated)
181
+ except Exception as error:
182
+ logger.warning('资源删除恢复失败: error_type=%s', type(error).__name__)
183
+ processed += 1
184
+ return (start + processed) % len(ids), changed
185
+
186
+
187
+ async def resource_deletion_worker(store, stop, invalidate=None, interval=30):
188
+ initialized = False
189
+ cursor = 0
190
+ while not stop.is_set():
191
+ try:
192
+ if not initialized:
193
+ await asyncio.to_thread(store.atomic_update, DELETION_FILE, _queue, default_data={})
194
+ initialized = True
195
+ cursor, changed = await asyncio.to_thread(repair_resource_deletions, store, cursor, 50, stop.is_set)
196
+ if changed and invalidate:
197
+ try:
198
+ invalidate('items:')
199
+ except Exception:
200
+ logger.warning('资源删除恢复后的排序失效失败')
201
+ except Exception as error:
202
+ logger.warning('资源删除恢复轮次失败: error_type=%s', type(error).__name__)
203
+ if stop.is_set():
204
+ break
205
+ try:
206
+ await asyncio.wait_for(stop.wait(), timeout=interval)
207
+ except asyncio.TimeoutError:
208
+ pass
resource_probe_service.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """资源可用性预检:可信资源记录决定来源;不替代真实下载所有权核查。"""
2
+ import logging
3
+ import os
4
+ import time
5
+ import urllib.error
6
+ import urllib.parse
7
+ import urllib.request
8
+ import uuid
9
+ from fastapi.responses import JSONResponse
10
+ from github_source import repository_parts
11
+ from proxy_access import read_proxy_item, ProxyAccessError
12
+ from workflow_source import authorized_workflow_path, WorkflowSourceError
13
+
14
+ logger = logging.getLogger("ComfyUI-Ranking")
15
+
16
+
17
+ def probe_resource(item_id, *, store, download, open_url, token_selector, error_response):
18
+ # 购买前只探测资源可用性;真实下载仍须通过独立所有权授权。
19
+ try:
20
+ item = read_proxy_item(store, item_id)
21
+ except ProxyAccessError as error:
22
+ return JSONResponse(content={"error": error.message}, status_code=error.status_code)
23
+ target_url = item.get("link")
24
+ if not isinstance(target_url, str):
25
+ return JSONResponse(content={"error": "无效的下载链接"}, status_code=400)
26
+ try:
27
+ parsed = urllib.parse.urlsplit(target_url)
28
+ valid_origin = parsed.scheme == "https" and not parsed.username and not parsed.password and parsed.port in (None, 443)
29
+ except ValueError:
30
+ valid_origin = False
31
+ if not valid_origin:
32
+ return JSONResponse(content={"error": "无效的下载链接"}, status_code=400)
33
+ if (parsed.hostname == "zhiwei666-comfyui-ranking-api.hf.space"
34
+ and parsed.path == "/api/image_proxy"):
35
+ return JSONResponse(content={"error": "工作流下载不使用公开媒体地址,请重新上传"}, status_code=400)
36
+ if parsed.hostname not in ("github.com", "huggingface.co"):
37
+ return JSONResponse(content={"error": "无效的下载链接"}, status_code=400)
38
+ try:
39
+ if target_url.startswith("https://github.com/"):
40
+ owner, repo = repository_parts(target_url)
41
+ active_token = token_selector(item)
42
+ headers = {"User-Agent": "ComfyUI-Ranking-SaaS"}
43
+ if active_token:
44
+ headers["Authorization"] = f"Bearer {active_token}"
45
+ api_url = f"https://api.github.com/repos/{owner}/{repo}"
46
+
47
+ req = urllib.request.Request(api_url, headers=headers)
48
+ with open_url(req, timeout=10) as response:
49
+ if response.status != 200:
50
+ return JSONResponse(content={"error": "资源仓库不可访问,可能已被作者删除或设为私有"}, status_code=404)
51
+ return {"status": "success", "message": "资源有效"}
52
+
53
+ elif target_url.startswith("https://huggingface.co/datasets/"):
54
+ hf_token = os.environ.get("HF_TOKEN")
55
+ if not hf_token:
56
+ return JSONResponse(content={"error": "资源源站暂时无法访问"}, status_code=503)
57
+ expected_prefix = f"https://huggingface.co/datasets/{store.DATASET_REPO_ID}/resolve/main/"
58
+ if not target_url.startswith(expected_prefix) or parsed.query or parsed.fragment:
59
+ return JSONResponse(content={"error": "无效的下载链接"}, status_code=400)
60
+ repo_path_encoded = target_url[len(expected_prefix):]
61
+ repo_path = urllib.parse.unquote(repo_path_encoded)
62
+ if not repo_path or any(part in ("", ".", "..") for part in repo_path.split("/")) or "\\" in repo_path:
63
+ return JSONResponse(content={"error": "无效的下载链接"}, status_code=400)
64
+
65
+ authorized_workflow_path(store, item, target_url)
66
+ cached_file_path = download(
67
+ repo_id=store.DATASET_REPO_ID,
68
+ repo_type="dataset",
69
+ filename=repo_path,
70
+ token=hf_token
71
+ )
72
+ if not os.path.exists(cached_file_path):
73
+ return JSONResponse(content={"error": "云端文件不存在,可能已被作者删除"}, status_code=404)
74
+
75
+ return {"status": "success", "message": "资源有效"}
76
+
77
+ except WorkflowSourceError as error:
78
+ return JSONResponse(content={"error": error.message}, status_code=error.status_code)
79
+ except urllib.error.HTTPError as e:
80
+ return JSONResponse(content={"error": f"资源探测失败,源站返回: {e.code}。请联系作者处理。"}, status_code=400)
81
+ except Exception as e:
82
+ error_id = f"ERR_{int(time.time())}_{uuid.uuid4().hex[:8]}"
83
+ upstream_status = getattr(getattr(e, "response", None), "status_code", None)
84
+ if not isinstance(upstream_status, int):
85
+ upstream_status = getattr(e, "code", None)
86
+ status_text = f" status={upstream_status}" if isinstance(upstream_status, int) and 100 <= upstream_status <= 599 else ""
87
+ logger.warning(f"RESOURCE_PROBE_FAIL | error_id={error_id} | error_type={type(e).__name__}{status_text}")
88
+ return JSONResponse(
89
+ status_code=502,
90
+ content=error_response(502, "资源源站暂时无法访��,请稍后重试", "upstream_error", error_id=error_id),
91
+ )
92
+
93
+ return JSONResponse(content={"error": "无效的下载链接"}, status_code=400)
resource_publication.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """持久发布身份先入SQL;JSON失败可恢复,同次重试不制造新资源。"""
2
+ import asyncio
3
+ import copy
4
+ import hashlib
5
+ import json
6
+ import logging
7
+ import re
8
+ from sqlalchemy.orm import Session
9
+ from models_sql import ResourcePublication
10
+ from resource_store import ResourceStoreError, _records
11
+
12
+
13
+ def _serialize(value):
14
+ return json.dumps(value, sort_keys=True, ensure_ascii=True, allow_nan=False)
15
+
16
+
17
+ CREATE_FIELDS = frozenset(('type', 'title', 'shortDesc', 'fullDesc', 'link', 'coverUrl', 'imageUrls',
18
+ 'author', 'price', 'github_token', 'netdisk_password', 'is_netdisk', 'is_original', 'allow_refund'))
19
+
20
+
21
+ def _request_receipt(request):
22
+ if not isinstance(request, dict) or set(request) - CREATE_FIELDS or not {'author', 'price', 'type'} <= set(request):
23
+ raise ValueError('invalid publication request')
24
+ return _serialize({'fields': sorted(request), 'sha256': hashlib.sha256(_serialize(request).encode()).hexdigest()})
25
+
26
+
27
+ def _receipt(row):
28
+ record = json.loads(row.payload)
29
+ request = json.loads(row.request_payload)
30
+ if (not isinstance(record, dict) or not isinstance(request, dict) or set(request) != {'fields', 'sha256'}
31
+ or not isinstance(request.get('fields'), list) or not all(isinstance(key, str) for key in request['fields'])
32
+ or len(set(request['fields'])) != len(request['fields']) or set(request['fields']) - CREATE_FIELDS
33
+ or not {'author', 'price', 'type'} <= set(request['fields'])
34
+ or not re.fullmatch(r'[0-9a-f]{64}', request.get('sha256') or '')
35
+ or not re.fullmatch(r'[0-9a-f]{64}', row.request_key or '')
36
+ or not re.fullmatch(r'[A-Za-z0-9_-]{1,100}', row.item_id or '')
37
+ or not isinstance(row.author, str) or not row.author.strip()
38
+ or record.get('id') != row.item_id or record.get('author') != row.author
39
+ or not isinstance(record.get('type'), str) or not record['type']
40
+ or type(record.get('price')) is not int or record['price'] < 0
41
+ or type(record.get('created_at')) is not int or type(row.projected) is not bool):
42
+ raise ValueError('invalid resource publication receipt')
43
+ if not row.projected:
44
+ original = {key: record[key] for key in request['fields']}
45
+ if _request_receipt(original) != _serialize(request):
46
+ raise ValueError('publication request differs from payload')
47
+ elif set(record) != {'id', 'author', 'type', 'price', 'created_at'}:
48
+ raise ValueError('invalid publication summary')
49
+ return copy.deepcopy(record), _serialize(request)
50
+
51
+
52
+ def _project(store, record, *, allow_insert=True):
53
+ from resource_deletion import resource_mutation_lock, assert_id_not_deleted
54
+ def select(records):
55
+ matches = [entry for entry in _records(records) if entry['id'] == record['id']]
56
+ if not matches:
57
+ return None
58
+ item = matches[0]
59
+ if any(item.get(key) != record.get(key) for key in ('author', 'type', 'created_at')):
60
+ raise ResourceStoreError(409, '资源发布记录冲突')
61
+ return copy.deepcopy(item)
62
+ with resource_mutation_lock:
63
+ assert_id_not_deleted(store, record['id'])
64
+ current = select(store.load_data('items.json', default_data=[], strict=True, skip_cache=True))
65
+ if current is not None:
66
+ return current, False
67
+ if not allow_insert:
68
+ raise ResourceStoreError(503, '已发布资源当前无法核实,请先核对个人作品')
69
+ result = None
70
+ def append(records):
71
+ nonlocal result
72
+ assert_id_not_deleted(store, record['id'])
73
+ result = select(records)
74
+ if result is None:
75
+ records.insert(0, copy.deepcopy(record))
76
+ result = copy.deepcopy(record)
77
+ try:
78
+ store.atomic_update('items.json', append, default_data=[], force_sync=bool(record.get('github_token')))
79
+ except Exception:
80
+ # 本地替换后远端同步可能失败,只在最新完整记录可核实时确认。
81
+ current = select(store.load_data('items.json', default_data=[], strict=True, skip_cache=True))
82
+ if current is None:
83
+ raise
84
+ return current, True
85
+ return result, True
86
+
87
+
88
+ def publish_once(bind, store, record, request_id, request):
89
+ if not isinstance(request_id, str) or not re.fullmatch(r'[0-9a-f]{32}', request_id):
90
+ raise ResourceStoreError(400, '发布请求编号无效')
91
+ author = record.get('author')
92
+ if not isinstance(author, str) or not author.strip():
93
+ raise ResourceStoreError(400, '发布作者无效')
94
+ key = hashlib.sha256(_serialize([author, request_id]).encode()).hexdigest()
95
+ try:
96
+ encoded_request = _request_receipt(request)
97
+ candidate = ResourcePublication(request_key=key, item_id=record['id'], author=author,
98
+ request_payload=encoded_request, payload=_serialize(record), projected=False)
99
+ if request.get('author') != author or _serialize({key: record.get(key) for key in request}) != _serialize(request):
100
+ raise ValueError('publication payload differs from request')
101
+ _receipt(candidate)
102
+ with Session(bind=bind, autoflush=False) as session:
103
+ try:
104
+ if session.get(ResourcePublication, key) is None:
105
+ session.add(candidate)
106
+ session.commit()
107
+ except Exception:
108
+ session.rollback()
109
+ # 唯一键并发或提交响应未知,下一独立读取核实持久结果。
110
+ with Session(bind=bind, autoflush=False) as session:
111
+ row = session.get(ResourcePublication, key)
112
+ if row is None:
113
+ raise ValueError('resource publication not confirmed')
114
+ saved, original_request = _receipt(row)
115
+ if type(row.projected) is not bool:
116
+ raise ValueError('invalid projection state')
117
+ projected = row.projected
118
+ if row.author != author or original_request != encoded_request:
119
+ raise ResourceStoreError(409, '同次发布内容已改变,请先核对原记录')
120
+ result, _ = _project(store, saved, allow_insert=not projected)
121
+ if not projected:
122
+ _confirm_projection(bind, key, saved['id'])
123
+ return result
124
+ except ResourceStoreError:
125
+ raise
126
+ except Exception:
127
+ raise ResourceStoreError(503, '资源发布结果暂时无法核实,请先核对个人作品') from None
128
+
129
+
130
+ def _confirm_projection(bind, key, item_id):
131
+ with Session(bind=bind, autoflush=False) as session:
132
+ try:
133
+ row = session.get(ResourcePublication, key)
134
+ if row is None or row.item_id != item_id:
135
+ raise ValueError('publication receipt missing')
136
+ record, _ = _receipt(row)
137
+ summary = _serialize({field: record[field] for field in ('id', 'author', 'type', 'price', 'created_at')})
138
+ count = session.query(ResourcePublication).filter(ResourcePublication.request_key == key,
139
+ ResourcePublication.item_id == item_id, ResourcePublication.payload == row.payload,
140
+ ResourcePublication.projected.is_(False)).update(
141
+ {'projected': True, 'payload': summary}, synchronize_session=False)
142
+ if count != 1:
143
+ session.expire_all()
144
+ confirmed = session.get(ResourcePublication, key)
145
+ if confirmed is None or confirmed.item_id != item_id or confirmed.projected is not True:
146
+ raise ValueError('publication projection confirmation changed')
147
+ _receipt(confirmed)
148
+ session.commit()
149
+ except Exception:
150
+ session.rollback()
151
+ raise
152
+
153
+
154
+ def repair_publications(bind, store, invalidate, cursor='', limit=50):
155
+ """轮转未确认投影,坏记录逐项隔离;已确认而后丢失的内容不猜测重建。"""
156
+ with Session(bind=bind, autoflush=False) as session:
157
+ rows = session.query(ResourcePublication).filter(ResourcePublication.projected.is_(False), ResourcePublication.request_key > cursor).order_by(
158
+ ResourcePublication.request_key).limit(limit).all()
159
+ snapshots = []
160
+ for row in rows:
161
+ try:
162
+ record, _ = _receipt(row)
163
+ snapshots.append((row.request_key, record))
164
+ except Exception:
165
+ logging.getLogger(__name__).warning('资源发布恢复跳过损坏记录')
166
+ next_cursor = rows[-1].request_key if rows else ''
167
+ for key, record in snapshots:
168
+ try:
169
+ _, changed = _project(store, record)
170
+ _confirm_projection(bind, key, record['id'])
171
+ if changed:
172
+ invalidate('items:')
173
+ except Exception:
174
+ logging.getLogger(__name__).warning('资源发布恢复暂未完成')
175
+ return next_cursor
176
+
177
+
178
+ async def publication_worker(bind, store, invalidate, stop, interval=30):
179
+ cursor = ''
180
+ while not stop.is_set():
181
+ try:
182
+ cursor = await asyncio.to_thread(repair_publications, bind, store, invalidate, cursor)
183
+ except Exception:
184
+ logging.getLogger(__name__).warning('资源发布恢复轮次暂未完成')
185
+ try:
186
+ await asyncio.wait_for(stop.wait(), timeout=interval)
187
+ except asyncio.TimeoutError:
188
+ pass
resource_store.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """资源发布/编辑的锁内局部修改与保存故障核实。"""
2
+ import copy
3
+ import logging
4
+ import re
5
+ import json
6
+ import datetime
7
+ from purchase_transaction import effective_resource_price
8
+
9
+
10
+ class ResourceStoreError(Exception):
11
+ def __init__(self, status_code, message):
12
+ self.status_code = status_code
13
+ self.message = message
14
+ super().__init__(message)
15
+
16
+
17
+ def _records(source):
18
+ if not isinstance(source, list):
19
+ raise ValueError('invalid resource source')
20
+ ids = []
21
+ for entry in source:
22
+ if not isinstance(entry, dict) or not isinstance(entry.get('id'), str) or not entry['id'].strip():
23
+ raise ValueError('invalid resource identity')
24
+ ids.append(entry['id'])
25
+ if len(ids) != len(set(ids)):
26
+ raise ValueError('ambiguous resource identities')
27
+ return source
28
+
29
+
30
+ def publish_resource(store, record):
31
+ if not isinstance(record, dict):
32
+ raise ResourceStoreError(400, '资源发布参数无效')
33
+ saved = copy.deepcopy(record)
34
+ serialized = json.dumps(saved, sort_keys=True, ensure_ascii=True, allow_nan=False)
35
+ def matches(entry):
36
+ return json.dumps(entry, sort_keys=True, ensure_ascii=True, allow_nan=False) == serialized
37
+ if (not isinstance(saved.get('id'), str) or not re.fullmatch(r'[A-Za-z0-9_-]{1,100}', saved['id'])
38
+ or not isinstance(saved.get('author'), str) or not saved['author'].strip()
39
+ or type(saved.get('price')) is not int or saved['price'] < 0):
40
+ raise ResourceStoreError(400, '资源发布参数无效')
41
+ def append(source):
42
+ records = _records(source)
43
+ from resource_deletion import assert_id_not_deleted
44
+ assert_id_not_deleted(store, saved['id'])
45
+ matches = [entry for entry in records if entry['id'] == saved['id']]
46
+ if matches:
47
+ if json.dumps(matches[0], sort_keys=True, ensure_ascii=True, allow_nan=False) == serialized:
48
+ return
49
+ raise ResourceStoreError(409, '资源编号冲突,请重新提交')
50
+ records.insert(0, copy.deepcopy(saved))
51
+ try:
52
+ store.atomic_update('items.json', append, default_data=[], force_sync=bool(saved.get('github_token')))
53
+ except Exception as error:
54
+ # 本地替换成功后,HF排队/同步也可能失败;只有完整确认已保存才报告成功。
55
+ try:
56
+ records = _records(store.load_data('items.json', default_data=[], strict=True, skip_cache=True))
57
+ committed = any(matches(entry) for entry in records)
58
+ except Exception:
59
+ committed = False
60
+ if not committed:
61
+ if isinstance(error, ResourceStoreError):
62
+ raise error
63
+ raise ResourceStoreError(503, '资源保存暂时无法核实,请稍后核对') from None
64
+ logging.getLogger(__name__).warning('资源本地已保存,发布后同步暂未确认')
65
+ return copy.deepcopy(saved)
66
+
67
+
68
+ def published_response(record):
69
+ response = copy.deepcopy(record)
70
+ response['has_private_token'] = bool(response.pop('github_token', None))
71
+ response.pop('netdisk_password', None)
72
+ return response
73
+
74
+
75
+ def edit_resource(store, item_id, account, changes):
76
+ fields = {'title', 'shortDesc', 'fullDesc', 'link', 'coverUrl', 'imageUrls', 'price',
77
+ 'github_token', 'netdisk_password', 'is_netdisk', 'is_original', 'allow_refund', 'expected_revision'}
78
+ if not isinstance(changes, dict) or set(changes) - fields:
79
+ raise ResourceStoreError(400, '资源编辑参数无效')
80
+ changes = {key: copy.deepcopy(value) for key, value in changes.items() if value is not None}
81
+ expected_revision = changes.pop('expected_revision', None)
82
+ if expected_revision is not None and (type(expected_revision) is not int or not 0 <= expected_revision <= 9007199254740991):
83
+ raise ResourceStoreError(400, '资源编辑版本无效')
84
+ if 'price' in changes and (type(changes['price']) is not int or changes['price'] < 0):
85
+ raise ResourceStoreError(400, '资源价格无效')
86
+ expected = None
87
+ result = None
88
+ def update(source):
89
+ nonlocal expected, result
90
+ records = _records(source)
91
+ index = next((index for index, record in enumerate(records) if record['id'] == item_id), None)
92
+ if index is None:
93
+ raise ResourceStoreError(404, '找不到该内容记录')
94
+ original = records[index]
95
+ author = original.get('author')
96
+ if not isinstance(author, str) or not author.strip():
97
+ raise ValueError('invalid resource author')
98
+ if author != account:
99
+ raise ResourceStoreError(403, '无权修改他人发布的内容')
100
+ revision = original.get('edit_revision', 0)
101
+ if type(revision) is not int or not 0 <= revision < 9007199254740991:
102
+ raise ValueError('invalid resource edit revision')
103
+ if expected_revision is not None and revision != expected_revision:
104
+ raise ResourceStoreError(409, 'resource_revision_conflict')
105
+ edited = copy.deepcopy(original)
106
+ edited['edit_revision'] = revision + 1
107
+ current_price = effective_resource_price(edited)
108
+ edited['price'] = current_price
109
+ pending = edited.get('pending_price')
110
+ effective = edited.get('pending_price_effective_at')
111
+ if type(pending) is int and pending == current_price and effective:
112
+ when = datetime.datetime.fromisoformat(effective)
113
+ now = datetime.datetime.now(when.tzinfo) if when.tzinfo else datetime.datetime.now()
114
+ if now >= when:
115
+ edited['pending_price'] = None
116
+ edited['pending_price_effective_at'] = None
117
+ price_change = None
118
+ for key, value in changes.items():
119
+ if key != 'price':
120
+ edited[key] = value
121
+ if 'price' in changes:
122
+ if changes['price'] != current_price:
123
+ effective_at = (datetime.datetime.now() + datetime.timedelta(hours=24)).isoformat()
124
+ edited.update(pending_price=changes['price'], pending_price_effective_at=effective_at)
125
+ price_change = dict(current_price=current_price, new_price=changes['price'], effective_at=effective_at)
126
+ else:
127
+ edited.update(pending_price=None, pending_price_effective_at=None)
128
+ result = dict(edit_revision=revision + 1, price_change_info=price_change,
129
+ source_changed=any(original.get(key) != edited.get(key) for key in ('link', 'github_token')))
130
+ expected = copy.deepcopy(edited)
131
+ records[index] = edited
132
+ return copy.deepcopy(result)
133
+ try:
134
+ return store.atomic_update('items.json', update, default_data=[], force_sync=bool(changes.get('github_token')))
135
+ except ResourceStoreError:
136
+ raise
137
+ except Exception:
138
+ if expected is not None:
139
+ try:
140
+ source = _records(store.load_data('items.json', default_data=[], strict=True, skip_cache=True))
141
+ serialized = json.dumps(expected, sort_keys=True, allow_nan=False)
142
+ confirmed = any(json.dumps(record, sort_keys=True, allow_nan=False) == serialized for record in source)
143
+ except Exception:
144
+ confirmed = False
145
+ if confirmed:
146
+ logging.getLogger(__name__).warning('资源编辑本地已保存,后续同步暂未确认')
147
+ return copy.deepcopy(result)
148
+ raise ResourceStoreError(503, '资源编辑保存暂时无法核实,请稍后核对') from None
response_privacy.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """浏览器/共享 HTTP 缓存的私有响应边界,无数据库或应用依赖。"""
2
+ import re
3
+
4
+ _PRIVATE_PATH = re.compile(
5
+ r"^/api/(wallet|messages|chats|admin|disputes|users)(?:/|$)"
6
+ r"|^/api/my-(posts|prompts|tasks)(?:/|$)"
7
+ r"|^/api/(tasks|prompts)/[^/]+$"
8
+ )
9
+
10
+
11
+ def requires_no_store(path, authorization=None):
12
+ # 含 Authorization 的响应即使路径公开也不交给共享 HTTP 缓存。
13
+ # 匿名访问私有路径的错误响应及登录返回的 Token 同样不能缓存。
14
+ return bool(authorization or _PRIVATE_PATH.search(path))
router_admin_plugins.py CHANGED
@@ -13,19 +13,22 @@
13
 
14
  import re
15
  import json
 
 
16
  import secrets
17
  import logging
18
  import datetime
19
  from typing import Optional, List
20
 
21
- from fastapi import APIRouter, Depends, HTTPException
 
22
  from fastapi.responses import JSONResponse
23
  from sqlalchemy.orm import Session
24
  from pydantic import BaseModel, Field, field_validator # 🧹 P2修复:pydantic v2 已废弃 v1 风格 validator
25
 
26
  from database_sql import get_db
27
  from models_sql import RegisteredPlugin
28
- from 安全认证 import require_auth, is_admin
29
 
30
  logger = logging.getLogger("ComfyUI-Ranking.AdminPlugins")
31
 
@@ -42,6 +45,13 @@ _PLUGIN_ID_PATTERN = re.compile(r"^[a-z0-9_]{3,50}$")
42
  _DEFAULT_PERMISSIONS = ["verify", "balance", "deduct", "refund"]
43
 
44
 
 
 
 
 
 
 
 
45
  def generate_plugin_key() -> str:
46
  """生成 plugin_key,格式: rk_plug_ + 32位hex随机串"""
47
  return f"rk_plug_{secrets.token_hex(16)}"
@@ -67,7 +77,7 @@ def _notify_registry_reload():
67
  reload_plugin_registry()
68
  logger.info("插件注册表热更新成功")
69
  except (ImportError, Exception) as e:
70
- logger.warning(f"插件注册表热更新失败: {e}")
71
 
72
 
73
  def _serialize_plugin(p: RegisteredPlugin, include_key: bool = False, raw_key: Optional[str] = None) -> dict:
@@ -178,7 +188,7 @@ async def list_plugins(
178
  except HTTPException:
179
  raise
180
  except Exception as e:
181
- logger.exception(f"列出插件失败: {e}")
182
  return JSONResponse(status_code=500, content={"success": False, "error": "服务器内部错误"})
183
 
184
 
@@ -197,7 +207,9 @@ async def get_plugin(
197
 
198
 
199
  @router.post("/api/admin/plugins")
 
200
  async def create_plugin(
 
201
  payload: PluginCreateRequest,
202
  account: str = Depends(require_auth),
203
  db: Session = Depends(get_db),
@@ -234,10 +246,10 @@ async def create_plugin(
234
  db.refresh(plugin)
235
  except Exception as e:
236
  db.rollback()
237
- logger.exception(f"创建插件失败: {e}")
238
  return JSONResponse(status_code=500, content={"success": False, "error": "创建插件失败"})
239
 
240
- logger.info(f"管理员 {account} 创建插件 plugin_id={payload.plugin_id}")
241
  _notify_registry_reload()
242
 
243
  return JSONResponse(status_code=201, content={
@@ -251,7 +263,9 @@ async def create_plugin(
251
 
252
 
253
  @router.put("/api/admin/plugins/{plugin_id}")
 
254
  async def update_plugin(
 
255
  plugin_id: str,
256
  payload: PluginUpdateRequest,
257
  account: str = Depends(require_auth),
@@ -282,10 +296,13 @@ async def update_plugin(
282
  db.refresh(plugin)
283
  except Exception as e:
284
  db.rollback()
285
- logger.exception(f"更新插件失败: {e}")
286
  return JSONResponse(status_code=500, content={"success": False, "error": "更新插件失败"})
287
 
288
- logger.info(f"管理员 {account} 更新插件 plugin_id={plugin_id}, fields={list(update_fields.keys())}")
 
 
 
289
  _notify_registry_reload()
290
 
291
  return JSONResponse(content={
@@ -296,7 +313,9 @@ async def update_plugin(
296
 
297
 
298
  @router.delete("/api/admin/plugins/{plugin_id}")
 
299
  async def delete_plugin(
 
300
  plugin_id: str,
301
  account: str = Depends(require_auth),
302
  db: Session = Depends(get_db),
@@ -313,17 +332,19 @@ async def delete_plugin(
313
  db.commit()
314
  except Exception as e:
315
  db.rollback()
316
- logger.exception(f"删除插件失败: {e}")
317
  return JSONResponse(status_code=500, content={"success": False, "error": "删除插件失败"})
318
 
319
- logger.info(f"管理员 {account} 删除插件 plugin_id={plugin_id}")
320
  _notify_registry_reload()
321
 
322
  return JSONResponse(content={"success": True, "message": "插件已删除", "plugin_id": plugin_id})
323
 
324
 
325
  @router.post("/api/admin/plugins/{plugin_id}/rotate-key")
 
326
  async def rotate_plugin_key(
 
327
  plugin_id: str,
328
  account: str = Depends(require_auth),
329
  db: Session = Depends(get_db),
@@ -346,10 +367,10 @@ async def rotate_plugin_key(
346
  db.refresh(plugin)
347
  except Exception as e:
348
  db.rollback()
349
- logger.exception(f"轮换 plugin_key 失败: {e}")
350
  return JSONResponse(status_code=500, content={"success": False, "error": "轮换 plugin_key 失败"})
351
 
352
- logger.info(f"管理员 {account} 轮换插件 plugin_id={plugin_id} 的 plugin_key")
353
  _notify_registry_reload()
354
 
355
  return JSONResponse(content={
@@ -362,7 +383,9 @@ async def rotate_plugin_key(
362
 
363
 
364
  @router.post("/api/admin/plugins/{plugin_id}/toggle")
 
365
  async def toggle_plugin(
 
366
  plugin_id: str,
367
  account: str = Depends(require_auth),
368
  db: Session = Depends(get_db),
@@ -381,10 +404,13 @@ async def toggle_plugin(
381
  db.refresh(plugin)
382
  except Exception as e:
383
  db.rollback()
384
- logger.exception(f"切换插件状态失败: {e}")
385
  return JSONResponse(status_code=500, content={"success": False, "error": "切换插件状态失败"})
386
 
387
- logger.info(f"管理员 {account} 切换插件 plugin_id={plugin_id} 状态为 enabled={plugin.enabled}")
 
 
 
388
  _notify_registry_reload()
389
 
390
  return JSONResponse(content={
 
13
 
14
  import re
15
  import json
16
+ import hmac
17
+ import hashlib
18
  import secrets
19
  import logging
20
  import datetime
21
  from typing import Optional, List
22
 
23
+ from fastapi import APIRouter, Depends, HTTPException, Request
24
+ from rate_limit import shared_limit
25
  from fastapi.responses import JSONResponse
26
  from sqlalchemy.orm import Session
27
  from pydantic import BaseModel, Field, field_validator # 🧹 P2修复:pydantic v2 已废弃 v1 风格 validator
28
 
29
  from database_sql import get_db
30
  from models_sql import RegisteredPlugin
31
+ from 安全认证 import require_auth, is_admin, JWT_SECRET
32
 
33
  logger = logging.getLogger("ComfyUI-Ranking.AdminPlugins")
34
 
 
45
  _DEFAULT_PERMISSIONS = ["verify", "balance", "deduct", "refund"]
46
 
47
 
48
+ def _audit_actor_id(account: str) -> str:
49
+ """稳定关联管理员审计事件,但不把可识别账号写入运行日志。"""
50
+ return hmac.new(
51
+ JWT_SECRET.encode("utf-8"), str(account).encode("utf-8"), hashlib.sha256
52
+ ).hexdigest()[:16]
53
+
54
+
55
  def generate_plugin_key() -> str:
56
  """生成 plugin_key,格式: rk_plug_ + 32位hex随机串"""
57
  return f"rk_plug_{secrets.token_hex(16)}"
 
77
  reload_plugin_registry()
78
  logger.info("插件注册表热更新成功")
79
  except (ImportError, Exception) as e:
80
+ logger.warning(f"插件注册表热更新失败: error_type={type(e).__name__}")
81
 
82
 
83
  def _serialize_plugin(p: RegisteredPlugin, include_key: bool = False, raw_key: Optional[str] = None) -> dict:
 
188
  except HTTPException:
189
  raise
190
  except Exception as e:
191
+ logger.error(f"列出插件失败: error_type={type(e).__name__}")
192
  return JSONResponse(status_code=500, content={"success": False, "error": "服务器内部错误"})
193
 
194
 
 
207
 
208
 
209
  @router.post("/api/admin/plugins")
210
+ @shared_limit(20)
211
  async def create_plugin(
212
+ request: Request,
213
  payload: PluginCreateRequest,
214
  account: str = Depends(require_auth),
215
  db: Session = Depends(get_db),
 
246
  db.refresh(plugin)
247
  except Exception as e:
248
  db.rollback()
249
+ logger.error(f"创建插件失败: error_type={type(e).__name__}")
250
  return JSONResponse(status_code=500, content={"success": False, "error": "创建插件失败"})
251
 
252
+ logger.info(f"ADMIN_PLUGIN_CREATE | actor={_audit_actor_id(account)} | plugin_id={payload.plugin_id}")
253
  _notify_registry_reload()
254
 
255
  return JSONResponse(status_code=201, content={
 
263
 
264
 
265
  @router.put("/api/admin/plugins/{plugin_id}")
266
+ @shared_limit(30)
267
  async def update_plugin(
268
+ request: Request,
269
  plugin_id: str,
270
  payload: PluginUpdateRequest,
271
  account: str = Depends(require_auth),
 
296
  db.refresh(plugin)
297
  except Exception as e:
298
  db.rollback()
299
+ logger.error(f"更新插件失败: error_type={type(e).__name__}")
300
  return JSONResponse(status_code=500, content={"success": False, "error": "更新插件失败"})
301
 
302
+ logger.info(
303
+ f"ADMIN_PLUGIN_UPDATE | actor={_audit_actor_id(account)} | plugin_id={plugin_id} | "
304
+ f"fields={','.join(sorted(update_fields.keys()))}"
305
+ )
306
  _notify_registry_reload()
307
 
308
  return JSONResponse(content={
 
313
 
314
 
315
  @router.delete("/api/admin/plugins/{plugin_id}")
316
+ @shared_limit(10)
317
  async def delete_plugin(
318
+ request: Request,
319
  plugin_id: str,
320
  account: str = Depends(require_auth),
321
  db: Session = Depends(get_db),
 
332
  db.commit()
333
  except Exception as e:
334
  db.rollback()
335
+ logger.error(f"删除插件失败: error_type={type(e).__name__}")
336
  return JSONResponse(status_code=500, content={"success": False, "error": "删除插件失败"})
337
 
338
+ logger.info(f"ADMIN_PLUGIN_DELETE | actor={_audit_actor_id(account)} | plugin_id={plugin_id}")
339
  _notify_registry_reload()
340
 
341
  return JSONResponse(content={"success": True, "message": "插件已删除", "plugin_id": plugin_id})
342
 
343
 
344
  @router.post("/api/admin/plugins/{plugin_id}/rotate-key")
345
+ @shared_limit(5)
346
  async def rotate_plugin_key(
347
+ request: Request,
348
  plugin_id: str,
349
  account: str = Depends(require_auth),
350
  db: Session = Depends(get_db),
 
367
  db.refresh(plugin)
368
  except Exception as e:
369
  db.rollback()
370
+ logger.error(f"轮换 plugin_key 失败: error_type={type(e).__name__}")
371
  return JSONResponse(status_code=500, content={"success": False, "error": "轮换 plugin_key 失败"})
372
 
373
+ logger.info(f"ADMIN_PLUGIN_ROTATE_KEY | actor={_audit_actor_id(account)} | plugin_id={plugin_id}")
374
  _notify_registry_reload()
375
 
376
  return JSONResponse(content={
 
383
 
384
 
385
  @router.post("/api/admin/plugins/{plugin_id}/toggle")
386
+ @shared_limit(20)
387
  async def toggle_plugin(
388
+ request: Request,
389
  plugin_id: str,
390
  account: str = Depends(require_auth),
391
  db: Session = Depends(get_db),
 
404
  db.refresh(plugin)
405
  except Exception as e:
406
  db.rollback()
407
+ logger.error(f"切换插件状态失败: error_type={type(e).__name__}")
408
  return JSONResponse(status_code=500, content={"success": False, "error": "切换插件状态失败"})
409
 
410
+ logger.info(
411
+ f"ADMIN_PLUGIN_TOGGLE | actor={_audit_actor_id(account)} | plugin_id={plugin_id} | "
412
+ f"enabled={bool(plugin.enabled)}"
413
+ )
414
  _notify_registry_reload()
415
 
416
  return JSONResponse(content={
router_comments.py CHANGED
@@ -4,19 +4,20 @@ import time
4
  import uuid
5
  from html import escape
6
  import 数据库连接 as db
 
7
  from notifications import add_notification
8
  from models import InteractionToggle, CommentCreate
9
  from 安全认证 import require_auth, is_admin
10
 
11
  # 🔒 频次限制(与 router_users_auth.py / router_wallet.py / router_prompts.py 同一写法)
12
- from slowapi import Limiter
13
- from slowapi.util import get_remote_address
14
- limiter = Limiter(key_func=get_remote_address)
15
 
16
  router = APIRouter()
17
 
18
  @router.post("/api/interactions/toggle")
19
- async def toggle_interaction(interaction: InteractionToggle, current_user: str = Depends(require_auth)):
 
20
  interaction.user_id = current_user # 强制使用当前用户身份,防止伪造
21
 
22
  list_key = f"{interaction.action_type}d_by"
@@ -54,6 +55,9 @@ async def toggle_interaction(interaction: InteractionToggle, current_user: str =
54
 
55
  if result_container.get("error"):
56
  raise HTTPException(status_code=404, detail=result_container["error"])
 
 
 
57
 
58
  # 通知在原子操作外发送(通知失败不影响主流程)
59
  if interaction.is_active:
@@ -62,7 +66,7 @@ async def toggle_interaction(interaction: InteractionToggle, current_user: str =
62
  return {"status": "success", "new_count": result_container["count"]}
63
 
64
  @router.post("/api/comments")
65
- @limiter.limit("10/minute") # 🔒 安全限流:防止刷评论洗版(正常用户不会一分钟发 10 条以上)
66
  async def post_comment(request: Request, comment: CommentCreate, current_user: str = Depends(require_auth)):
67
  comment.author = current_user # 强制使用认证用户身份,防止伪造
68
  # 评论内容长度校验
@@ -70,12 +74,10 @@ async def post_comment(request: Request, comment: CommentCreate, current_user: s
70
  raise HTTPException(status_code=400, detail="评论长度必须在1-5000字符之间")
71
  # HTML 转义,防止 XSS
72
  comment.content = escape(comment.content)
73
- comments_db = db.load_data("comments.json", default_data={})
74
  users_db = db.load_data("users.json", default_data={})
75
  author_info = users_db.get(comment.author, {})
76
  reply_name = users_db.get(comment.reply_to_user, {}).get("name", comment.reply_to_user) if comment.reply_to_user else None
77
 
78
- item_comments = comments_db.get(comment.item_id, [])
79
  new_comment = {
80
  "id": f"c_{int(time.time())}_{uuid.uuid4().hex[:6]}", "author": comment.author,
81
  "authorName": author_info.get("name", comment.author), "avatar": author_info.get("avatarDataUrl", ""),
@@ -83,122 +85,42 @@ async def post_comment(request: Request, comment: CommentCreate, current_user: s
83
  "isDeleted": False, "replies": [], "created_at": int(time.time())
84
  }
85
 
 
86
  if comment.parent_id:
87
- parent = next((c for c in item_comments if c["id"] == comment.parent_id), None)
88
- if parent:
89
- parent["replies"].append(new_comment)
90
- if comment.reply_to_user:
91
- add_notification(comment.reply_to_user, {"type": "reply", "from_user": comment.author, "target_item_id": comment.item_id, "target_item_title": "收到新的回复", "content": comment.content})
92
- else: raise HTTPException(status_code=404, detail="找不到要回复的评论")
93
  else:
94
- item_comments.append(new_comment)
95
  items_db = db.load_data("items.json", default_data=[])
96
  posts_db = db.load_data("posts.json", default_data=[])
97
  target_item = next((item for item in items_db if item["id"] == comment.item_id), None)
98
  target_post = next((post for post in posts_db if post["id"] == comment.item_id), None)
99
  if target_item:
100
- add_notification(target_item.get("author", ""), {"type": "comment", "from_user": comment.author, "target_item_id": comment.item_id, "target_item_title": target_item["title"], "content": comment.content})
101
  elif target_post:
102
- add_notification(target_post.get("author", ""), {"type": "comment", "from_user": comment.author, "target_item_id": comment.item_id, "target_item_title": target_post.get("title", "") or "", "content": comment.content})
103
  elif comment.item_id in users_db:
104
- add_notification(comment.item_id, {"type": "comment", "from_user": comment.author, "target_item_id": comment.item_id, "target_item_title": "您的个人留言板", "content": comment.content})
105
 
106
- comments_db[comment.item_id] = item_comments
107
- db.save_data("comments.json", comments_db)
 
108
  return {"status": "success", "data": new_comment}
109
 
110
  @router.delete("/api/comments/{item_id}/{comment_id}")
111
- async def soft_delete_comment(item_id: str, comment_id: str, account: str = Depends(require_auth)):
 
112
  """
113
  软删除评论
114
  权限规则:评论作者可删除自己的评论,帖子/内容作者可删除其帖子下的评论,管理员可删除任何评论
115
  """
116
- comments_db = db.load_data("comments.json", default_data={})
117
  items_db = db.load_data("items.json", default_data=[])
118
  posts_db = db.load_data("posts.json", default_data=[])
119
  users_db = db.load_data("users.json", default_data={})
120
  tasks_db = db.load_data("tasks.json", default_data=[])
121
-
122
- item_comments = comments_db.get(item_id, [])
123
- target_comment = None
124
-
125
- def find_comment(comments_list, depth=0):
126
- """查找目标评论"""
127
- if depth > 20: # 防止极端嵌套导致栈溢出
128
- return False
129
- nonlocal target_comment
130
- for c in comments_list:
131
- if c["id"] == comment_id:
132
- target_comment = c
133
- return True
134
- if "replies" in c and find_comment(c["replies"], depth + 1):
135
- return True
136
- return False
137
-
138
- if not find_comment(item_comments):
139
- raise HTTPException(status_code=404, detail="找不到该评论")
140
-
141
- # 权限检查
142
- is_comment_author = target_comment["author"] == account
143
- is_admin_user = is_admin(account)
144
-
145
- # 检查是否为内容作者(帖子作者或资源作者)
146
- is_content_author = False
147
- # 检查是否为帖子作者
148
- for post in posts_db:
149
- if post["id"] == item_id and post.get("author") == account:
150
- is_content_author = True
151
- break
152
- # 检查是否为资源作者
153
- if not is_content_author:
154
- for item in items_db:
155
- if item["id"] == item_id and item.get("author") == account:
156
- is_content_author = True
157
- break
158
- # 任务发布者
159
- if not is_content_author:
160
- for task in tasks_db:
161
- if task["id"] == item_id and task.get("publisher") == account:
162
- is_content_author = True
163
- break
164
- # 检查是否为个人主页留言板作者
165
- if not is_content_author:
166
- if item_id in users_db and item_id == account:
167
- is_content_author = True
168
-
169
- # 权限判断:评论作者、内容作者或管理员可删除
170
- if not (is_comment_author or is_content_author or is_admin_user):
171
- raise HTTPException(status_code=403, detail="无权删除此评论")
172
-
173
- # 执行软删除
174
- def mark_deleted(comments_list, depth=0):
175
- if depth > 20: # 防止极端嵌套导致栈溢出
176
- return False
177
- for c in comments_list:
178
- if c["id"] == comment_id:
179
- c["isDeleted"] = True
180
- c["content"] = ""
181
- return True
182
- if "replies" in c and mark_deleted(c["replies"], depth + 1):
183
- return True
184
- return False
185
-
186
- # 原子操作软删除,防止并发覆盖
187
- max_retries = 3
188
- for attempt in range(max_retries):
189
- try:
190
- comments_db = db.load_data("comments.json", default_data={})
191
- item_comments = comments_db.get(item_id, [])
192
- if not find_comment(item_comments):
193
- raise HTTPException(status_code=404, detail="找不到该评论")
194
- mark_deleted(item_comments)
195
- comments_db[item_id] = item_comments
196
- db.save_data("comments.json", comments_db)
197
- break
198
- except HTTPException:
199
- raise
200
- except Exception:
201
- if attempt == max_retries - 1:
202
- raise
203
-
204
- return {"status": "success"}
 
4
  import uuid
5
  from html import escape
6
  import 数据库连接 as db
7
+ from comment_store import append_comment, delete_comment
8
  from notifications import add_notification
9
  from models import InteractionToggle, CommentCreate
10
  from 安全认证 import require_auth, is_admin
11
 
12
  # 🔒 频次限制(与 router_users_auth.py / router_wallet.py / router_prompts.py 同一写法)
13
+ from rate_limit import shared_limit
14
+ from db_utils import sort_cache
 
15
 
16
  router = APIRouter()
17
 
18
  @router.post("/api/interactions/toggle")
19
+ @shared_limit(30)
20
+ async def toggle_interaction(request: Request, interaction: InteractionToggle, current_user: str = Depends(require_auth)):
21
  interaction.user_id = current_user # 强制使用当前用户身份,防止伪造
22
 
23
  list_key = f"{interaction.action_type}d_by"
 
55
 
56
  if result_container.get("error"):
57
  raise HTTPException(status_code=404, detail=result_container["error"])
58
+
59
+ # 统一互动接口也会改变资源排序,成功提交后失效派生排序缓存。
60
+ sort_cache.invalidate("items:")
61
 
62
  # 通知在原子操作外发送(通知失败不影响主流程)
63
  if interaction.is_active:
 
66
  return {"status": "success", "new_count": result_container["count"]}
67
 
68
  @router.post("/api/comments")
69
+ @shared_limit(10) # 🔒 安全限流:防止刷评论洗版(正常用户不会一分钟发 10 条以上)
70
  async def post_comment(request: Request, comment: CommentCreate, current_user: str = Depends(require_auth)):
71
  comment.author = current_user # 强制使用认证用户身份,防止伪造
72
  # 评论内容长度校验
 
74
  raise HTTPException(status_code=400, detail="评论长度必须在1-5000字符之间")
75
  # HTML 转义,防止 XSS
76
  comment.content = escape(comment.content)
 
77
  users_db = db.load_data("users.json", default_data={})
78
  author_info = users_db.get(comment.author, {})
79
  reply_name = users_db.get(comment.reply_to_user, {}).get("name", comment.reply_to_user) if comment.reply_to_user else None
80
 
 
81
  new_comment = {
82
  "id": f"c_{int(time.time())}_{uuid.uuid4().hex[:6]}", "author": comment.author,
83
  "authorName": author_info.get("name", comment.author), "avatar": author_info.get("avatarDataUrl", ""),
 
85
  "isDeleted": False, "replies": [], "created_at": int(time.time())
86
  }
87
 
88
+ notification = None
89
  if comment.parent_id:
90
+ if comment.reply_to_user:
91
+ notification = (comment.reply_to_user, {"type": "reply", "from_user": comment.author, "target_item_id": comment.item_id, "target_item_title": "收到新的回复", "content": comment.content})
 
 
 
 
92
  else:
 
93
  items_db = db.load_data("items.json", default_data=[])
94
  posts_db = db.load_data("posts.json", default_data=[])
95
  target_item = next((item for item in items_db if item["id"] == comment.item_id), None)
96
  target_post = next((post for post in posts_db if post["id"] == comment.item_id), None)
97
  if target_item:
98
+ notification = (target_item.get("author", ""), {"type": "comment", "from_user": comment.author, "target_item_id": comment.item_id, "target_item_title": target_item["title"], "content": comment.content})
99
  elif target_post:
100
+ notification = (target_post.get("author", ""), {"type": "comment", "from_user": comment.author, "target_item_id": comment.item_id, "target_item_title": target_post.get("title", "") or "", "content": comment.content})
101
  elif comment.item_id in users_db:
102
+ notification = (comment.item_id, {"type": "comment", "from_user": comment.author, "target_item_id": comment.item_id, "target_item_title": "您的个人留言板", "content": comment.content})
103
 
104
+ append_comment(comment.item_id, new_comment, parent_id=comment.parent_id)
105
+ if notification:
106
+ add_notification(*notification)
107
  return {"status": "success", "data": new_comment}
108
 
109
  @router.delete("/api/comments/{item_id}/{comment_id}")
110
+ @shared_limit(10)
111
+ async def soft_delete_comment(request: Request, item_id: str, comment_id: str, account: str = Depends(require_auth)):
112
  """
113
  软删除评论
114
  权限规则:评论作者可删除自己的评论,帖子/内容作者可删除其帖子下的评论,管理员可删除任何评论
115
  """
 
116
  items_db = db.load_data("items.json", default_data=[])
117
  posts_db = db.load_data("posts.json", default_data=[])
118
  users_db = db.load_data("users.json", default_data={})
119
  tasks_db = db.load_data("tasks.json", default_data=[])
120
+ is_content_author = any(entry.get("id") == item_id and entry.get("author") == account
121
+ for entry in posts_db + items_db)
122
+ is_content_author = is_content_author or any(entry.get("id") == item_id and entry.get("publisher") == account
123
+ for entry in tasks_db)
124
+ is_content_author = is_content_author or (item_id == account and item_id in users_db)
125
+ delete_comment(item_id, comment_id, account, can_moderate=is_content_author or is_admin(account))
126
+ return {"status": "success"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
router_items.py CHANGED
@@ -1,18 +1,25 @@
1
  # router_items.py
2
- from fastapi import APIRouter, HTTPException, Depends
 
3
  import time
4
  import uuid
5
  import datetime
6
  import os
 
7
  import urllib.request
8
  import urllib.error
9
  import json
10
  import asyncio
11
  import 数据库连接 as db
12
  from models import ItemCreate, ItemUpdate, RatingRequest
13
- from 安全认证 import require_auth, check_ownership
14
  from 数据库连接 import invalidate_cache
15
  from db_utils import record_view, sort_cache
 
 
 
 
 
16
 
17
  router = APIRouter()
18
 
@@ -30,18 +37,21 @@ def _get_version_str(versions_db: dict, item_id: str) -> str:
30
  return val or ""
31
 
32
  def _apply_effective_price(item: dict) -> None:
33
- """检查 pending_price 是否已过生效时间,若是则更新 price 字段供前端显示"""
34
- pending_price = item.get("pending_price")
35
- pending_effective = item.get("pending_price_effective_at")
36
- if pending_price is not None and pending_effective:
37
- try:
38
- effective_time = datetime.datetime.fromisoformat(pending_effective)
39
- if datetime.datetime.now() >= effective_time:
40
- item["price"] = pending_price
41
- item["pending_price"] = None
42
- item["pending_price_effective_at"] = None
43
- except (ValueError, TypeError):
44
- pass
 
 
 
45
 
46
  def get_last_6_months():
47
  res = []
@@ -94,17 +104,10 @@ async def get_items(type: str = "tool", sort: str = "time", limit: int = 50): #
94
 
95
  filtered_items = sort_cache.get_sorted(cache_key, filtered_items, sort_fn)
96
 
97
- price_updated = False
98
  result_items = []
99
  for item in filtered_items:
100
- # 💰 检查延迟价格是否已生效(在原始字典上执行,确保生效后可持久化)
101
- old_price = item.get("price", 0)
102
- _apply_effective_price(item)
103
- if item.get("price", 0) != old_price:
104
- price_updated = True
105
-
106
- # 🔒 P0数据修复:浅拷贝后再做下发加工,避免 pop/注入字段污染 items.json 源数据
107
  item_data = {**item}
 
108
  item_data["commentsData"] = comments_db.get(item_data["id"], [])
109
  item_data["comments"] = len(item_data["commentsData"])
110
  item_data["latest_version"] = _get_version_str(versions_db, item_data["id"])
@@ -116,11 +119,6 @@ async def get_items(type: str = "tool", sort: str = "time", limit: int = 50): #
116
  item_data.pop("viewed_by", None) # 👁️ 访问者列表不暴露给前端
117
  result_items.append(item_data)
118
 
119
- # 如果有价格生效了,持久化回 items.json
120
- if price_updated:
121
- db.save_data("items.json", items_db)
122
- invalidate_cache("items.json")
123
-
124
  return {"status": "success", "data": result_items[:limit]}
125
 
126
  def _build_creator_trend_data(account: str, u_items: list, months: list) -> dict:
@@ -327,18 +325,14 @@ async def get_creator_details(account: str):
327
  }
328
 
329
  @router.post("/api/items")
330
- async def create_item(item: ItemCreate, current_user: str = Depends(require_auth)):
 
331
  # 🔒 P0安全修复:使用 JWT 认证的用户账号作为作者,防止伪造
332
  item.author = current_user
333
- if not item.type:
334
- raise HTTPException(status_code=400, detail="资源类型不能为空")
335
- item.price = int(item.price or 0)
336
- if item.price < 0:
337
- raise HTTPException(status_code=400, detail="🚨 安全拦截:商品价格不能为负数")
338
-
339
- items_db = db.load_data("items.json", default_data=[])
340
  new_item = {
341
- "id": f"{item.type}_{int(time.time())}_{uuid.uuid4().hex[:6]}", "type": item.type, "title": item.title, "author": item.author,
342
  "shortDesc": item.shortDesc, "fullDesc": item.fullDesc, "link": item.link, "coverUrl": item.coverUrl,
343
  "imageUrls": item.imageUrls or [], # 🖼️ 效果展示图列表
344
  "price": item.price,
@@ -357,167 +351,80 @@ async def create_item(item: ItemCreate, current_user: str = Depends(require_auth
357
  "rating_dist": {"1": 0, "2": 0, "3": 0, "4": 0, "5": 0},
358
  "rated_by": {}
359
  }
360
- items_db.insert(0, new_item)
361
- db.save_data("items.json", items_db, force_sync=bool(item.github_token))
362
- # 🗂️ 清除排序缓存
363
- sort_cache.invalidate("items:")
 
 
 
 
 
 
 
 
 
 
 
364
 
365
  # 🚀 首次发布立即触发版本检测与预缓存
366
  try:
367
  link = item.link or ""
368
  if "github.com" in link:
369
- from 云端_定时版本检测引擎 import fetch_latest_github_hash, precache_github_zip
370
 
371
  async def _first_publish_precache():
372
  try:
373
- token = item.github_token or os.environ.get("GITHUB_PAT", "")
374
- latest_hash = await fetch_latest_github_hash(link, token)
375
- if latest_hash:
376
- # 写入 versions.json
377
- versions_db = db.load_data("versions.json", default_data={})
378
- versions_db[new_item["id"]] = {"hash": latest_hash}
379
- db.save_data("versions.json", versions_db)
380
- # 触发预缓存
381
- await precache_github_zip(link, token, new_item["id"], latest_hash)
382
- print(f"[缓存刷新] 首次发布预缓存完成: {new_item['id']}")
383
  except Exception as e:
384
- import traceback
385
- print(f"[缓存刷新] 首次发布预缓存失败(不影响发布): {e}")
386
- print(f"[缓存刷新] 详细堆栈: {traceback.format_exc()}")
387
 
388
  asyncio.create_task(_first_publish_precache())
389
  except Exception as e:
390
- print(f"[缓存刷新] 触发首次发布缓存异常(不影响发布): {e}")
391
 
392
- return {"status": "success", "data": new_item}
393
 
394
  @router.put("/api/items/{item_id}")
395
- async def update_item(item_id: str, update_data: ItemUpdate, current_user: str = Depends(require_auth)):
 
396
  """
397
  更新内容接口
398
  🔒 P0安全修复:使用 JWT Token 验证用户身份,而非前端传入的 author 参数
399
  🔄 P7后悔模式:价格修改延迟24小时生效
400
  🔧 Bug修复:使用 atomic_update 避免并发竞态条件
401
  """
402
- if update_data.price is not None:
403
- update_data.price = int(update_data.price)
404
- if update_data.price < 0:
405
- raise HTTPException(status_code=400, detail="🚨 安全拦截:商品价格不能为负数")
406
-
407
- result_holder = {}
408
-
409
- def updater(items_db):
410
- for item in items_db:
411
- if item["id"] == item_id:
412
- # 🔒 P0安全修复:使用 JWT 解析出的真实用户账号进行校验
413
- if item.get("author") != current_user:
414
- result_holder["error"] = "forbidden"
415
- return False
416
-
417
- price_change_info = None
418
-
419
- if update_data.title is not None: item["title"] = update_data.title
420
- if update_data.shortDesc is not None: item["shortDesc"] = update_data.shortDesc
421
- if update_data.fullDesc is not None: item["fullDesc"] = update_data.fullDesc
422
- old_link = item.get("link", "")
423
- if update_data.link is not None: item["link"] = update_data.link
424
- result_holder["link_changed"] = old_link != item.get("link", "")
425
- result_holder["new_link"] = item.get("link", "")
426
- if update_data.coverUrl is not None: item["coverUrl"] = update_data.coverUrl
427
- if update_data.imageUrls is not None: item["imageUrls"] = update_data.imageUrls # 🖼️ 效果展示图列表
428
-
429
- # 🔄 P7后悔模式:价格修改延迟24小时生效
430
- if update_data.price is not None:
431
- current_price = item.get("price", 0)
432
- new_price = update_data.price
433
-
434
- if current_price != new_price:
435
- # 设置待生效价格,24小时后生效
436
- import datetime
437
- effective_time = datetime.datetime.now() + datetime.timedelta(hours=24)
438
- item["pending_price"] = new_price
439
- item["pending_price_effective_at"] = effective_time.isoformat()
440
- price_change_info = {
441
- "current_price": current_price,
442
- "new_price": new_price,
443
- "effective_at": effective_time.isoformat()
444
- }
445
- # 不立即修改 price,等待24小时后生效
446
- else:
447
- # 价格未变,清除待生效价格
448
- item["pending_price"] = None
449
- item["pending_price_effective_at"] = None
450
-
451
- if update_data.github_token is not None: item["github_token"] = update_data.github_token
452
- if update_data.netdisk_password is not None: item["netdisk_password"] = update_data.netdisk_password # ☁️
453
- if update_data.is_netdisk is not None: item["is_netdisk"] = update_data.is_netdisk # ☁️
454
- if update_data.is_original is not None: item["is_original"] = update_data.is_original # 🎨
455
- if update_data.allow_refund is not None: item["allow_refund"] = update_data.allow_refund # 💸
456
-
457
- result_holder["success"] = True
458
- result_holder["price_change_info"] = price_change_info
459
- result_holder["current_price"] = price_change_info.get("current_price") if price_change_info else None
460
- result_holder["new_price"] = price_change_info.get("new_price") if price_change_info else None
461
- return True
462
-
463
- result_holder["error"] = "not_found"
464
- return False
465
-
466
- db.atomic_update("items.json", updater, default_data=[], force_sync=bool(update_data.github_token))
467
-
468
- if result_holder.get("error") == "forbidden":
469
- raise HTTPException(status_code=403, detail="无权修改他人发布的内容")
470
- if result_holder.get("error") == "not_found":
471
- raise HTTPException(status_code=404, detail="找不到该内容记录")
472
-
473
- # 🗂️ 清除排序缓存和主数据缓存
474
- sort_cache.invalidate("items:")
475
- invalidate_cache("items.json")
476
-
477
- # 🔄 [缓存刷新] 资源 link 变更时清除 ZIP 缓存元信息
478
- if result_holder.get("link_changed"):
479
  try:
480
- versions_db = db.load_data("versions.json", default_data={})
481
- entry = versions_db.get(item_id)
482
- if entry is not None:
483
- if isinstance(entry, str):
484
- entry = {"hash": entry}
485
- had_meta = "cached_at" in entry or "zip_size" in entry
486
- entry.pop("cached_at", None)
487
- entry.pop("zip_size", None)
488
- if had_meta:
489
- versions_db[item_id] = entry
490
- db.save_data("versions.json", versions_db)
491
- print(f"[缓存刷新] 已清除 item {item_id} 的 ZIP 缓存元信息")
492
-
493
- # 可选:触发异步预缓存
494
- new_link = result_holder.get("new_link", "")
495
- if new_link and new_link.startswith("https://github.com/"):
496
  try:
497
- from 云端_定时版本检测引擎 import precache_github_zip
498
- # 获取当前 item 的 token 和 version_hash
499
- items_db = db.load_data("items.json", default_data=[])
500
- token = None
501
- for it in items_db:
502
- if it["id"] == item_id:
503
- token = it.get("github_token")
504
- break
505
-
506
- versions_db = db.load_data("versions.json", default_data={})
507
- entry = versions_db.get(item_id, {})
508
- version_hash = entry.get("hash", "") if isinstance(entry, dict) else entry
509
-
510
- if version_hash:
511
- asyncio.create_task(precache_github_zip(new_link, token, item_id, version_hash))
512
- print(f"[缓存刷新] 已触发 item {item_id} 的异步预缓存")
513
- except ImportError:
514
- print(f"[缓存刷新] 预缓存模块导入失败,跳过异步预缓存")
515
- except Exception as e:
516
- print(f"[缓存刷新] 触发异步预缓存失败: {e}")
517
- except Exception as e:
518
- print(f"[缓存刷新] 缓存刷新失败(不影响主流程): {e}")
519
-
520
- result = {"status": "success"}
521
  if result_holder.get("price_change_info"):
522
  price_change_info = result_holder["price_change_info"]
523
  result["price_change"] = price_change_info
@@ -529,80 +436,25 @@ async def update_item(item_id: str, update_data: ItemUpdate, current_user: str =
529
  # 可通过外部调度器 (cron-job.org / GitHub Actions) 每日 02:00 触发
530
  # ==========================================
531
  @router.post("/api/check_updates")
532
- async def check_github_updates():
 
533
  """
534
  遍历所有 GitHub 类型的工具,获取最新 commit hash,存入 versions.json
535
  """
536
- items_db = db.load_data("items.json", default_data=[])
537
- versions_db = db.load_data("versions.json", default_data={})
538
-
539
  updated_count = 0
540
-
541
  for item in items_db:
542
- # 只处理 GitHub 仓库类型的工具
543
- link = item.get("link", "")
544
- if not link.startswith("https://github.com/"):
545
  continue
546
-
547
- item_id = item["id"]
548
-
549
  try:
550
- # 解析仓库信息
551
- repo_parts = link.rstrip("/").replace(".git", "").split("/")
552
- if len(repo_parts) < 2:
553
- continue
554
- owner, repo = repo_parts[-2], repo_parts[-1]
555
-
556
- # 获取创作者 Token 或使用全局兜底 Token
557
- creator_token = item.get("github_token")
558
- fallback_token = os.environ.get("GITHUB_PAT")
559
- active_token = creator_token if creator_token else fallback_token
560
-
561
- # 请求 GitHub API 获取最新 commit
562
- api_url = f"https://api.github.com/repos/{owner}/{repo}/commits?per_page=1"
563
- headers = {
564
- "Accept": "application/vnd.github.v3+json",
565
- "User-Agent": "ComfyUI-Ranking-VersionChecker"
566
- }
567
- if active_token:
568
- headers["Authorization"] = f"Bearer {active_token}"
569
-
570
- req = urllib.request.Request(api_url, headers=headers)
571
-
572
- # ⚡ 性能修复:urlopen 是同步阻塞调用(timeout=10),在 async 处理器里
573
- # 逐个 item 串行等待会占死唯一的事件循环(N 个仓库最坏 N×10 秒全站无响应);
574
- # 抽成同步小函数交给线程池执行,异常仍由下方 except 分支按原样捕获。
575
- def _fetch_commits():
576
- with urllib.request.urlopen(req, timeout=10) as response:
577
- return json.loads(response.read().decode("utf-8"))
578
-
579
- commits = await asyncio.to_thread(_fetch_commits)
580
- if commits and len(commits) > 0:
581
- latest_sha = commits[0].get("sha", "")[:7] # 取前7位作为版本标识
582
-
583
- # 如果版本有变化,更新 versions.json
584
- # 🧹 P2修复(格式统一):统一以新格式 dict 读写,保留 cached_at/zip_size 等缓存元字段;
585
- # 旧逻辑拿字符串与 dict 比较会把 dict 条目永远判为"有更新"并覆盖成字符串丢失元信息
586
- entry = versions_db.get(item_id)
587
- if isinstance(entry, str):
588
- entry = {"hash": entry}
589
- elif not isinstance(entry, dict):
590
- entry = {}
591
- old_version = entry.get("hash", "")
592
- if latest_sha and latest_sha != old_version:
593
- entry["hash"] = latest_sha
594
- versions_db[item_id] = entry
595
- updated_count += 1
596
- print(f"[版本更新] {item['title']}: {old_version} -> {latest_sha}")
597
-
598
- except urllib.error.HTTPError as e:
599
- print(f"[版本检查失败] {item.get('title', item_id)}: HTTP {e.code}")
600
- except Exception as e:
601
- print(f"[版本检查异常] {item.get('title', item_id)}: {str(e)}")
602
-
603
- # 保存更新后的版本库
604
- db.save_data("versions.json", versions_db)
605
-
606
  return {
607
  "status": "success",
608
  "message": f"版本检查完成,共更新 {updated_count} 个工具的版本号",
@@ -618,16 +470,8 @@ async def get_item_by_id(item_id: str):
618
 
619
  for item in items_db:
620
  if item["id"] == item_id:
621
- # 💰 检查延迟价格是否已生效(在原字典上操作,确保生效结果可持久化)
622
- old_price = item.get("price", 0)
623
- _apply_effective_price(item)
624
- if item.get("price", 0) != old_price:
625
- db.save_data("items.json", items_db)
626
- invalidate_cache("items.json")
627
-
628
- # 🔒 P2修复(与 get_items 同款):浅拷贝后再做下发加工,
629
- # 避免 pop 敏感字段污染 items.json 源数据/共享缓存(导致 github_token 永久丢失)
630
  item_data = {**item}
 
631
  # 添加关联数据
632
  item_data["commentsData"] = comments_db.get(item_id, [])
633
  item_data["comments"] = len(item_data["commentsData"])
@@ -650,56 +494,28 @@ async def get_item_version(item_id: str):
650
  return {"status": "success", "version": _get_version_str(versions_db, item_id)}
651
 
652
  @router.delete("/api/items/{item_id}")
653
- async def delete_item(item_id: str, current_user: str = Depends(require_auth)):
 
654
  """
655
  删除内容(仅作者或管理员可操作)
656
  🔧 Bug修复:使用 atomic_update 避免并发竞态条件
657
  """
658
- result_holder = {}
659
-
660
- def items_updater(items_db):
661
- for i, item in enumerate(items_db):
662
- if item["id"] == item_id:
663
- # 🔒 权限检查:仅作者或管理员可删除
664
- if not check_ownership(item, current_user, owner_field="author", allow_admin=True):
665
- result_holder["error"] = "forbidden"
666
- return False
667
-
668
- # 1. 从 items.json 中删除该条目
669
- items_db.pop(i)
670
- result_holder["item_deleted"] = True
671
- return True
672
-
673
- result_holder["error"] = "not_found"
674
- return False
675
-
676
- db.atomic_update("items.json", items_updater, default_data=[])
677
-
678
- if result_holder.get("error") == "forbidden":
679
- raise HTTPException(status_code=403, detail="无权删除他人发布的内容")
680
- if result_holder.get("error") == "not_found":
681
- raise HTTPException(status_code=404, detail="找不到该内容记录")
682
-
683
- # 2. 清理关联评论:从 comments.json 中删除该内容的所有评论
684
- def comments_updater(comments_db):
685
- if item_id in comments_db:
686
- del comments_db[item_id]
687
- return True
688
- return False
689
-
690
- db.atomic_update("comments.json", comments_updater, default_data={})
691
-
692
- # 3. 清理缓存:使 items.json 和 comments.json 的缓存失效
693
- invalidate_cache("items.json")
694
- invalidate_cache("comments.json")
695
- # 🗂️ 清除排序缓存
696
- sort_cache.invalidate("items:")
697
-
698
- return {"status": "success", "message": "内容已删除"}
699
 
700
 
701
  @router.post("/api/items/{item_id}/view")
702
- async def record_item_view(item_id: str, current_user: str = Depends(require_auth)):
 
703
  """
704
  记录资源访问量
705
  👁️ 需要用户认证,每个用户只计算一次总访问量,日访问量每次调用都增加
@@ -716,7 +532,8 @@ async def record_item_view(item_id: str, current_user: str = Depends(require_aut
716
 
717
 
718
  @router.post("/api/items/{item_id}/use")
719
- async def record_item_use(item_id: str, current_user: str = Depends(require_auth)):
 
720
  """
721
  记录资源使用量/下载量(原子操作,并发安全)
722
  📥 每个用户对同一资源只计一次,防止重复计数
@@ -760,12 +577,13 @@ async def record_item_use(item_id: str, current_user: str = Depends(require_auth
760
  # ==========================================
761
 
762
  @router.post("/api/items/{item_id}/rating")
763
- async def rate_item(item_id: str, request: RatingRequest, current_user: str = Depends(require_auth)):
 
764
  """
765
  为资源评分(原子操作,并发安全)
766
  ⭐ score: 1-5
767
  """
768
- score = request.score
769
  if score < 1 or score > 5:
770
  raise HTTPException(status_code=400, detail="评分必须在1-5之间")
771
 
@@ -834,7 +652,8 @@ async def rate_item(item_id: str, request: RatingRequest, current_user: str = De
834
 
835
 
836
  @router.post("/api/items/{item_id}/like")
837
- async def toggle_item_like(item_id: str, current_user: str = Depends(require_auth)):
 
838
  """
839
  点赞/取消点赞(原子操作,并发安全)
840
  """
@@ -869,7 +688,8 @@ async def toggle_item_like(item_id: str, current_user: str = Depends(require_aut
869
 
870
 
871
  @router.post("/api/items/{item_id}/favorite")
872
- async def toggle_item_favorite(item_id: str, current_user: str = Depends(require_auth)):
 
873
  """
874
  收藏/取消收藏(原子操作,并发安全)
875
  """
@@ -907,7 +727,8 @@ async def toggle_item_favorite(item_id: str, current_user: str = Depends(require
907
  # 🔄 手动触发预缓存刷新接口
908
  # ==========================================
909
  @router.post("/api/items/{item_id}/refresh_cache")
910
- async def refresh_item_cache(item_id: str, current_user: str = Depends(require_auth)):
 
911
  """手动触发指定资源的 ZIP 预缓存刷新,仅限作者调用"""
912
  items_db = db.load_data("items.json", default_data=[])
913
 
@@ -931,31 +752,16 @@ async def refresh_item_cache(item_id: str, current_user: str = Depends(require_a
931
  raise HTTPException(status_code=400, detail="仅支持 GitHub 仓库类型的资源")
932
 
933
  try:
934
- from 云端_定时版本检测引擎 import fetch_latest_github_hash, precache_github_zip
935
  except ImportError:
936
  raise HTTPException(status_code=500, detail="预缓存模块不可用")
937
 
938
- # 获取 token
939
- token = target_item.get("github_token") or os.environ.get("GITHUB_PAT", "")
940
-
941
- # 获取最新 hash
942
- latest_hash = await fetch_latest_github_hash(link, token)
943
- if not latest_hash:
944
- raise HTTPException(status_code=500, detail="获取最新版本号失败,请稍后重试")
945
-
946
- # 写入 versions.json
947
- versions_db = db.load_data("versions.json", default_data={})
948
- entry = versions_db.get(item_id, {})
949
- if isinstance(entry, str):
950
- entry = {"hash": entry}
951
- entry["hash"] = latest_hash
952
- versions_db[item_id] = entry
953
- db.save_data("versions.json", versions_db)
954
-
955
- # 执行预缓存
956
- success = await precache_github_zip(link, token, item_id, latest_hash)
957
-
958
  if success:
959
  return {"status": "success", "message": "缓存刷新完成"}
960
  else:
961
- return {"status": "error", "message": "缓存刷新失败,请查看云端日志获取详细信息"}
 
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 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
 
 
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) is int 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):
53
+ raise HTTPException(status_code=503, detail="资源价格暂时无法核实") from None
54
+
55
 
56
  def get_last_6_months():
57
  res = []
 
104
 
105
  filtered_items = sort_cache.get_sorted(cache_key, filtered_items, sort_fn)
106
 
 
107
  result_items = []
108
  for item in filtered_items:
 
 
 
 
 
 
 
109
  item_data = {**item}
110
+ _apply_effective_price(item_data)
111
  item_data["commentsData"] = comments_db.get(item_data["id"], [])
112
  item_data["comments"] = len(item_data["commentsData"])
113
  item_data["latest_version"] = _get_version_str(versions_db, item_data["id"])
 
119
  item_data.pop("viewed_by", None) # 👁️ 访问者列表不暴露给前端
120
  result_items.append(item_data)
121
 
 
 
 
 
 
122
  return {"status": "success", "data": result_items[:limit]}
123
 
124
  def _build_creator_trend_data(account: str, u_items: list, months: list) -> dict:
 
325
  }
326
 
327
  @router.post("/api/items")
328
+ @shared_limit(5)
329
+ async def create_item(request: Request, item: ItemCreate, current_user: str = Depends(require_auth)):
330
  # 🔒 P0安全修复:使用 JWT 认证的用户账号作为作者,防止伪造
331
  item.author = current_user
332
+ if not re.fullmatch(r'[A-Za-z0-9_-]{1,50}', item.type or ''):
333
+ raise HTTPException(status_code=400, detail="资源类型无效")
 
 
 
 
 
334
  new_item = {
335
+ "id": f"{item.type}_{int(time.time())}_{uuid.uuid4().hex}", "type": item.type, "title": item.title, "author": item.author,
336
  "shortDesc": item.shortDesc, "fullDesc": item.fullDesc, "link": item.link, "coverUrl": item.coverUrl,
337
  "imageUrls": item.imageUrls or [], # 🖼️ 效果展示图列表
338
  "price": item.price,
 
351
  "rating_dist": {"1": 0, "2": 0, "3": 0, "4": 0, "5": 0},
352
  "rated_by": {}
353
  }
354
+ try:
355
+ if item.request_id is not None:
356
+ from database_sql import engine
357
+ from resource_publication import publish_once
358
+ payload = {key: new_item[key] for key in item.model_dump(exclude={'request_id'})}
359
+ new_item = await asyncio.to_thread(publish_once, engine, db, new_item, item.request_id, payload)
360
+ else:
361
+ new_item = await asyncio.to_thread(publish_resource, db, new_item)
362
+ except ResourceStoreError as error:
363
+ raise HTTPException(status_code=error.status_code, detail=error.message) from None
364
+ # 已提交内容不因派生排序缓存故障而误报发布失败。
365
+ try:
366
+ sort_cache.invalidate("items:")
367
+ except Exception as error:
368
+ print(f"[资源发布] 缓存失效失败: error_type={type(error).__name__}")
369
 
370
  # 🚀 首次发布立即触发版本检测与预缓存
371
  try:
372
  link = item.link or ""
373
  if "github.com" in link:
374
+ from 云端_定时版本检测引擎 import precache_github_zip
375
 
376
  async def _first_publish_precache():
377
  try:
378
+ refreshed = await refresh_resource_version(db, new_item['id'])
379
+ if refreshed:
380
+ await precache_github_zip(refreshed['item']['link'], refreshed['token'], new_item['id'], refreshed['commit'])
 
 
 
 
 
 
 
381
  except Exception as e:
382
+ print(f"[缓存刷新] 首次发布预缓存失败(不影响发布): item_id={new_item['id']}, error_type={type(e).__name__}")
 
 
383
 
384
  asyncio.create_task(_first_publish_precache())
385
  except Exception as e:
386
+ print(f"[缓存刷新] 触发首次发布缓存异常(不影响发布): item_id={new_item['id']}, error_type={type(e).__name__}")
387
 
388
+ return {"status": "success", "data": published_response(new_item)}
389
 
390
  @router.put("/api/items/{item_id}")
391
+ @shared_limit(10)
392
+ async def update_item(request: Request, item_id: str, update_data: ItemUpdate, current_user: str = Depends(require_auth)):
393
  """
394
  更新内容接口
395
  🔒 P0安全修复:使用 JWT Token 验证用户身份,而非前端传入的 author 参数
396
  🔄 P7后悔模式:价格修改延迟24小时生效
397
  🔧 Bug修复:使用 atomic_update 避免并发竞态条件
398
  """
399
+ try:
400
+ result_holder = await asyncio.to_thread(edit_resource, db, item_id, current_user,
401
+ update_data.model_dump(exclude_unset=True))
402
+ except ResourceStoreError as error:
403
+ raise HTTPException(status_code=error.status_code, detail=error.message) from None
404
+ # 编辑已经提交,派生缓存故障不改写主操作的成功结果。
405
+ for invalidate, key in ((sort_cache.invalidate, "items:"), (invalidate_cache, "items.json")):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
406
  try:
407
+ invalidate(key)
408
+ except Exception as error:
409
+ print(f"[资源编辑] 缓存失效失败: error_type={type(error).__name__}")
410
+
411
+ # 来源变更撤销旧版本;后台重新检测当前资源,绝不用旧仓库hash请求新仓库。
412
+ if result_holder.get('source_changed'):
413
+ try:
414
+ await asyncio.to_thread(invalidate_source, db, item_id)
415
+ async def refresh_changed_source():
 
 
 
 
 
 
 
416
  try:
417
+ refreshed = await refresh_resource_version(db, item_id)
418
+ if refreshed:
419
+ from 云端_定时版本检测引擎 import precache_github_zip
420
+ await precache_github_zip(refreshed['item']['link'], refreshed['token'], item_id, refreshed['commit'])
421
+ except Exception as error:
422
+ print(f"[缓存刷新] 来源检测失败: error_type={type(error).__name__}")
423
+ asyncio.create_task(refresh_changed_source())
424
+ except Exception as error:
425
+ print(f"[缓存刷新] 来源撤销失败: error_type={type(error).__name__}")
426
+
427
+ result = {"status": "success", "edit_revision": result_holder["edit_revision"]}
 
 
 
 
 
 
 
 
 
 
 
 
 
428
  if result_holder.get("price_change_info"):
429
  price_change_info = result_holder["price_change_info"]
430
  result["price_change"] = price_change_info
 
436
  # 可通过外部调度器 (cron-job.org / GitHub Actions) 每日 02:00 触发
437
  # ==========================================
438
  @router.post("/api/check_updates")
439
+ @shared_limit(30)
440
+ async def check_github_updates(request: Request, current_user: str = Depends(require_auth)):
441
  """
442
  遍历所有 GitHub 类型的工具,获取最新 commit hash,存入 versions.json
443
  """
444
+ if not is_admin(current_user):
445
+ raise HTTPException(status_code=403, detail="无权限")
446
+ items_db = await asyncio.to_thread(db.load_data, "items.json", default_data=[], strict=True, skip_cache=True)
447
  updated_count = 0
 
448
  for item in items_db:
449
+ if not item.get('link', '').startswith('https://github.com/'):
 
 
450
  continue
 
 
 
451
  try:
452
+ refreshed = await refresh_resource_version(db, item['id'])
453
+ if refreshed and refreshed['changed']:
454
+ updated_count += 1
455
+ except Exception as error:
456
+ print(f"[版本检查异常] error_type={type(error).__name__}")
457
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
458
  return {
459
  "status": "success",
460
  "message": f"版本检查完成,共更新 {updated_count} 个工具的版本号",
 
470
 
471
  for item in items_db:
472
  if item["id"] == item_id:
 
 
 
 
 
 
 
 
 
473
  item_data = {**item}
474
+ _apply_effective_price(item_data)
475
  # 添加关联数据
476
  item_data["commentsData"] = comments_db.get(item_id, [])
477
  item_data["comments"] = len(item_data["commentsData"])
 
494
  return {"status": "success", "version": _get_version_str(versions_db, item_id)}
495
 
496
  @router.delete("/api/items/{item_id}")
497
+ @shared_limit(10)
498
+ async def delete_item(request: Request, item_id: str, current_user: str = Depends(require_auth)):
499
  """
500
  删除内容(仅作者或管理员可操作)
501
  🔧 Bug修复:使用 atomic_update 避免并发竞态条件
502
  """
503
+ try:
504
+ result = await asyncio.to_thread(delete_resource, db, item_id, current_user, is_admin(current_user))
505
+ except ResourceStoreError as error:
506
+ raise HTTPException(status_code=error.status_code, detail=error.message) from None
507
+ for invalidate, key in ((invalidate_cache, "items.json"), (invalidate_cache, "comments.json"),
508
+ (invalidate_cache, "versions.json"), (sort_cache.invalidate, "items:")):
509
+ try:
510
+ invalidate(key)
511
+ except Exception as error:
512
+ print(f"[资源删除] 缓存失效失败: error_type={type(error).__name__}")
513
+ return {"status": "success", "message": "内容已删除", **result}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
514
 
515
 
516
  @router.post("/api/items/{item_id}/view")
517
+ @shared_limit(60)
518
+ async def record_item_view(request: Request, item_id: str, current_user: str = Depends(require_auth)):
519
  """
520
  记录资源访问量
521
  👁️ 需要用户认证,每个用户只计算一次总访问量,日访问量每次调用都增加
 
532
 
533
 
534
  @router.post("/api/items/{item_id}/use")
535
+ @shared_limit(30)
536
+ async def record_item_use(request: Request, item_id: str, current_user: str = Depends(require_auth)):
537
  """
538
  记录资源使用量/下载量(原子操作,并发安全)
539
  📥 每个用户对同一资源只计一次,防止重复计数
 
577
  # ==========================================
578
 
579
  @router.post("/api/items/{item_id}/rating")
580
+ @shared_limit(20)
581
+ async def rate_item(request: Request, item_id: str, payload: RatingRequest, current_user: str = Depends(require_auth)):
582
  """
583
  为资源评分(原子操作,并发安全)
584
  ⭐ score: 1-5
585
  """
586
+ score = payload.score
587
  if score < 1 or score > 5:
588
  raise HTTPException(status_code=400, detail="评分必须在1-5之间")
589
 
 
652
 
653
 
654
  @router.post("/api/items/{item_id}/like")
655
+ @shared_limit(30)
656
+ async def toggle_item_like(request: Request, item_id: str, current_user: str = Depends(require_auth)):
657
  """
658
  点赞/取消点赞(原子操作,并发安全)
659
  """
 
688
 
689
 
690
  @router.post("/api/items/{item_id}/favorite")
691
+ @shared_limit(30)
692
+ async def toggle_item_favorite(request: Request, item_id: str, current_user: str = Depends(require_auth)):
693
  """
694
  收藏/取消收藏(原子操作,并发安全)
695
  """
 
727
  # 🔄 手动触发预缓存刷新接口
728
  # ==========================================
729
  @router.post("/api/items/{item_id}/refresh_cache")
730
+ @shared_limit(3)
731
+ async def refresh_item_cache(request: Request, item_id: str, current_user: str = Depends(require_auth)):
732
  """手动触发指定资源的 ZIP 预缓存刷新,仅限作者调用"""
733
  items_db = db.load_data("items.json", default_data=[])
734
 
 
752
  raise HTTPException(status_code=400, detail="仅支持 GitHub 仓库类型的资源")
753
 
754
  try:
755
+ from 云端_定时版本检测引擎 import precache_github_zip
756
  except ImportError:
757
  raise HTTPException(status_code=500, detail="预缓存模块不可用")
758
 
759
+ refreshed = await refresh_resource_version(db, item_id)
760
+ if not refreshed:
761
+ raise HTTPException(status_code=503, detail="资源版本暂时无法核实,请稍后重试")
762
+ success = await precache_github_zip(refreshed['item']['link'], refreshed['token'], item_id, refreshed['commit'])
763
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
764
  if success:
765
  return {"status": "success", "message": "缓存刷新完成"}
766
  else:
767
+ return {"status": "error", "message": "缓存刷新失败,请查看云端日志获取详细信息"}
router_messages.py CHANGED
@@ -1,5 +1,6 @@
1
  # router_messages.py
2
  from fastapi import APIRouter, HTTPException, Depends, Request
 
3
  from pydantic import BaseModel
4
  import asyncio
5
  import time
@@ -7,13 +8,12 @@ import uuid
7
  import subprocess
8
  import os
9
  import 数据库连接 as db
 
 
10
  from models import PrivateMessage
11
  from 安全认证 import require_auth, is_admin
12
 
13
  # 🔒 频次限制(与 router_users_auth.py / router_wallet.py / router_prompts.py 同一写法)
14
- from slowapi import Limiter
15
- from slowapi.util import get_remote_address
16
- limiter = Limiter(key_func=get_remote_address)
17
 
18
  router = APIRouter()
19
 
@@ -28,29 +28,13 @@ class SystemAnnouncement(BaseModel):
28
  # 新增:发布系统公告接口 (仅限管理员,使用JWT验证)
29
  # ==========================================
30
  @router.post("/api/system/announcement")
31
- async def publish_announcement(ann: SystemAnnouncement, current_user: str = Depends(require_auth)):
 
32
  # 🔒 P0安全修复:使用环境变量配置的管理员列表
33
  if not is_admin(current_user):
34
  raise HTTPException(status_code=403, detail="无权发布系统公告,仅管理员可操作")
35
 
36
- # 查询管理员信息
37
- users_db = db.load_data("users.json", default_data={})
38
- admin_info = users_db.get(current_user, {})
39
-
40
- announcements_db = db.load_data("announcements.json", default_data=[])
41
-
42
- new_ann = {
43
- "id": f"sys_{int(time.time())}_{uuid.uuid4().hex[:6]}",
44
- "type": "system",
45
- "from_user": current_user, # 使用真实的管理员账号
46
- "from_name": admin_info.get("name", current_user), # 使用真实昵称,fallback 为账号
47
- "from_avatar": admin_info.get("avatarDataUrl", ""), # 使用真实头像
48
- "content": ann.content,
49
- "created_at": int(time.time())
50
- }
51
-
52
- announcements_db.append(new_ann)
53
- db.save_data("announcements.json", announcements_db)
54
  return {"status": "success"}
55
 
56
 
@@ -71,7 +55,8 @@ ALLOWED_SCRIPTS = {
71
  }
72
 
73
  @router.post("/api/admin/run-script")
74
- async def run_admin_script(req: AdminScriptRequest, current_user: str = Depends(require_auth)):
 
75
  """
76
  管理员专属:执行指定的 Python 脚本
77
  🔒 P0安全修复:白名单 + 路径穿越防护
@@ -159,93 +144,24 @@ async def run_admin_script(req: AdminScriptRequest, current_user: str = Depends(
159
  # 原有功能:私信与聊天
160
  # ==========================================
161
  @router.post("/api/messages/private")
162
- @limiter.limit("30/minute") # 🔒 安全限流:防止私信骚扰/刷屏(聊天节奏快,阈值比发帖宽松)
163
  async def send_private_message(request: Request, msg: PrivateMessage, current_user: str = Depends(require_auth)):
164
- msg.sender = current_user # 强制发送者身份,防止伪造
165
-
166
- # 验证接收者存在
167
- users_db = db.load_data("users.json", default_data={})
168
- if msg.receiver not in users_db:
169
- raise HTTPException(status_code=404, detail="接收者不存在")
170
-
171
- chats_db = db.load_data("chats.json", default_data={})
172
- conv_id = f"{min(msg.sender, msg.receiver)}_{max(msg.sender, msg.receiver)}"
173
- if conv_id not in chats_db: chats_db[conv_id] = []
174
-
175
- chat_msg = {"id": f"chat_{int(time.time())}_{uuid.uuid4().hex[:6]}", "sender": msg.sender, "receiver": msg.receiver, "content": msg.content, "created_at": int(time.time()), "is_read": False}
176
- chats_db[conv_id].append(chat_msg)
177
- db.save_data("chats.json", chats_db)
178
-
179
- # 🔔 私信未读提醒改由聊天入口(✉️红点)承载(读取 chats.json 每会话未读数),
180
- # 不再向 messages.json 写入 private 铃铛通知,避免与聊天红点重复计数
181
  return {"status": "success"}
182
 
183
  @router.get("/api/chats/{account}")
184
  async def get_chat_list(account: str, current_user: str = Depends(require_auth)):
185
  if account != current_user:
186
  raise HTTPException(status_code=403, detail="无权访问他人私信")
187
- chats_db = db.load_data("chats.json", default_data={})
188
- users_db = db.load_data("users.json", default_data={})
189
-
190
- chat_list = []
191
- for conv_id, msgs in chats_db.items():
192
- if account in conv_id:
193
- if not msgs: continue
194
- # 🔧 P1修复:对方账户从消息的 sender/receiver 字段推导,而非对 conv_id 做 replace
195
- # (replace 会替换所有子串,若账户名互为子串或含下划线会解析出错误账户)
196
- other_account = None
197
- for m in reversed(msgs):
198
- if m.get("sender") != account:
199
- other_account = m.get("sender")
200
- break
201
- if m.get("receiver") != account:
202
- other_account = m.get("receiver")
203
- break
204
- if not other_account: continue
205
- last_msg = msgs[-1]
206
- unread_count = sum(1 for m in msgs if m["receiver"] == account and not m.get("is_read"))
207
- other_user = users_db.get(other_account, {})
208
- chat_list.append({
209
- "account": other_account,
210
- "name": other_user.get("name", other_account),
211
- "avatar": other_user.get("avatarDataUrl", ""),
212
- "last_message": last_msg["content"],
213
- "last_time": last_msg["created_at"],
214
- "unread_count": unread_count
215
- })
216
-
217
- chat_list.sort(key=lambda x: x["last_time"], reverse=True)
218
- return {"status": "success", "data": chat_list}
219
 
220
  @router.get("/api/chats/{account}/{target_account}")
221
  async def get_chat_history(account: str, target_account: str, current_user: str = Depends(require_auth)):
222
  if account != current_user:
223
  raise HTTPException(status_code=403, detail="无权访问他人聊天记录")
224
- chats_db = db.load_data("chats.json", default_data={})
225
- conv_id = f"{min(account, target_account)}_{max(account, target_account)}"
226
- msgs = chats_db.get(conv_id, [])
227
-
228
- now = int(time.time())
229
- seven_days = 7 * 24 * 3600
230
- valid_msgs = []
231
- modified = False
232
-
233
- for m in msgs:
234
- if not m.get("is_read") or (now - m.get("created_at", 0) < seven_days):
235
- valid_msgs.append(m)
236
- else:
237
- modified = True
238
-
239
- # 本次访问即为已读
240
- if m["receiver"] == account and not m.get("is_read"):
241
- m["is_read"] = True
242
- modified = True
243
-
244
- if modified or len(valid_msgs) != len(msgs):
245
- chats_db[conv_id] = valid_msgs
246
- db.save_data("chats.json", chats_db)
247
-
248
- return {"status": "success", "data": valid_msgs}
249
 
250
  # ==========================================
251
  # 改造:获取通知列表 (加入系统公告懒加载注入)
@@ -257,138 +173,16 @@ async def get_messages(account: str, count_only: bool = False, current_user: str
257
  # 🔒 P0安全加固:拦截越权读取他人消息(含 count_only 轮询)
258
  if account != current_user:
259
  raise HTTPException(status_code=403, detail="无权访问他人消息")
260
- # 🔥 count_only 模式:轻量级轮询,只返回未读数,不标记已读
261
- if count_only:
262
- messages_db = db.load_data("messages.json", default_data={})
263
- user_msgs = messages_db.get(account, [])
264
-
265
- now = int(time.time())
266
- seven_days = 7 * 24 * 3600
267
-
268
- # 🔔 只统计未过期的未读消息;私信提醒已迁移至聊天入口(✉️红点)、
269
- # 插件更新提醒已迁移至卡片徽章,铃铛未读数不再计入这两类,避免同一提醒重复出现
270
- unread = sum(1 for m in user_msgs
271
- if not m.get("is_read")
272
- and m.get("type") not in ("private", "plugin_update")
273
- and (now - m.get("created_at", 0) < seven_days))
274
- return {"status": "success", "unread_count": unread}
275
-
276
- # 公告是只读的,先加载
277
- announcements_db = db.load_data("announcements.json", default_data=[])
278
-
279
- # 🔥 性能优化:先用只读方式检查是否有实际变更需要写入
280
- messages_db = db.load_data("messages.json", default_data={})
281
- user_msgs = messages_db.get(account, [])
282
-
283
- now = int(time.time())
284
- seven_days = 7 * 24 * 3600
285
-
286
- # 检查三个条件判断是否需要写入
287
- # 1. 是否有新公告需要注入
288
- user_msg_ids = {m.get("id") for m in user_msgs}
289
- has_new_announcements = any(
290
- ann.get("id") not in user_msg_ids
291
- for ann in announcements_db
292
- )
293
-
294
- # 2. 是否有未读消息需要标记已读
295
- has_unread = any(not m.get("is_read") for m in user_msgs)
296
-
297
- # 3. 是否有已读超过7天的消息需要清理
298
- has_expired = any(
299
- m.get("is_read") and (now - m.get("created_at", 0) >= seven_days)
300
- for m in user_msgs
301
- )
302
-
303
- needs_update = has_new_announcements or has_unread or has_expired
304
-
305
- # 🔥 修复:在标记已读之前先计算真实的未读数
306
- unread_before_mark = sum(1 for m in user_msgs
307
- if not m.get("is_read")
308
- and (now - m.get("created_at", 0) < seven_days))
309
-
310
- if not needs_update:
311
- # 无变更,直接返回只读数据,不触发写入和HF上传
312
- return {
313
- "status": "success",
314
- "data": user_msgs,
315
- "unread_count": unread_before_mark # 🔥 修复:返回真实的未读数
316
- }
317
-
318
- # 有变更需要写入,使用 atomic_update 保证并发安全
319
- result_container = [None]
320
-
321
- def updater(data):
322
- user_msgs = data.get(account, [])
323
-
324
- # --- 核心改造区:瞬间比对并注入全局公告 ---
325
- user_msg_ids = {m.get("id") for m in user_msgs}
326
-
327
- injected = False
328
- for ann in announcements_db:
329
- if ann.get("id") not in user_msg_ids:
330
- new_sys_msg = dict(ann)
331
- new_sys_msg["is_read"] = False
332
- new_sys_msg["receiver"] = account
333
- user_msgs.append(new_sys_msg)
334
- injected = True
335
-
336
- if injected:
337
- # 重新按照时间倒序排列,让新公告置顶
338
- user_msgs.sort(key=lambda x: x.get("created_at", 0), reverse=True)
339
- # ----------------------------------------
340
-
341
- now = int(time.time())
342
- seven_days = 7 * 24 * 3600
343
- valid = []
344
-
345
- # 如果注入了新公告,则判定为需要回写数据库保存
346
- modified = injected
347
-
348
- for m in user_msgs:
349
- if not m.get("is_read") or (now - m.get("created_at", 0) < seven_days):
350
- valid.append(m)
351
- else:
352
- modified = True
353
-
354
- # 本次访问即为已读 - 将所有未读消息标记为已读
355
- if not m.get("is_read"):
356
- m["is_read"] = True
357
- modified = True
358
- # 原地修改 data,atomic_update 会自动保存
359
- data[account] = valid
360
-
361
- # 通过闭包返回结果
362
- result_container[0] = {
363
- "status": "success",
364
- "data": valid,
365
- "unread_count": unread_before_mark # 🔥 修复:返回标记已读前的真实未读数
366
- }
367
-
368
- db.atomic_update("messages.json", updater, default_data={})
369
-
370
- return result_container[0]
371
 
372
  @router.post("/api/messages/{account}/read")
373
- async def mark_messages_read(account: str, current_user: str = Depends(require_auth)):
 
374
  """
375
  标记消息为已读(原子操作,并发安全)
376
  """
377
  # 🔒 P0安全加固:补全鉴权,防止任意用户篡改他人消息已读状态
378
  if account != current_user:
379
  raise HTTPException(status_code=403, detail="无权操作他人消息")
380
- def updater(data):
381
- user_msgs = data.get(account, [])
382
- modified = False
383
- for m in user_msgs:
384
- if not m.get("is_read"):
385
- m["is_read"] = True
386
- modified = True
387
-
388
- # 原地修改 data,atomic_update 会自动保存
389
- if modified:
390
- data[account] = user_msgs
391
-
392
- db.atomic_update("messages.json", updater, default_data={})
393
-
394
- 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
  import asyncio
6
  import time
 
8
  import subprocess
9
  import os
10
  import 数据库连接 as db
11
+ import chat_store
12
+ import notification_store
13
  from models import PrivateMessage
14
  from 安全认证 import require_auth, is_admin
15
 
16
  # 🔒 频次限制(与 router_users_auth.py / router_wallet.py / router_prompts.py 同一写法)
 
 
 
17
 
18
  router = APIRouter()
19
 
 
28
  # 新增:发布系统公告接口 (仅限管理员,使用JWT验证)
29
  # ==========================================
30
  @router.post("/api/system/announcement")
31
+ @shared_limit(5)
32
+ async def publish_announcement(request: Request, ann: SystemAnnouncement, current_user: str = Depends(require_auth)):
33
  # 🔒 P0安全修复:使用环境变量配置的管理员列表
34
  if not is_admin(current_user):
35
  raise HTTPException(status_code=403, detail="无权发布系统公告,仅管理员可操作")
36
 
37
+ await asyncio.to_thread(notification_store.publish_announcement, current_user, ann.content)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  return {"status": "success"}
39
 
40
 
 
55
  }
56
 
57
  @router.post("/api/admin/run-script")
58
+ @shared_limit(3)
59
+ async def run_admin_script(request: Request, req: AdminScriptRequest, current_user: str = Depends(require_auth)):
60
  """
61
  管理员专属:执行指定的 Python 脚本
62
  🔒 P0安全修复:白名单 + 路径穿越防护
 
144
  # 原有功能:私信与聊天
145
  # ==========================================
146
  @router.post("/api/messages/private")
147
+ @shared_limit(30) # 🔒 安全限流:防止私信骚扰/刷屏(聊天节奏快,阈值比发帖宽松)
148
  async def send_private_message(request: Request, msg: PrivateMessage, current_user: str = Depends(require_auth)):
149
+ await asyncio.to_thread(chat_store.send_message, current_user, msg.receiver, msg.content)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  return {"status": "success"}
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
  # 改造:获取通知列表 (加入系统公告懒加载注入)
 
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"}
 
 
 
 
 
 
 
 
 
 
 
 
 
router_open_api.py CHANGED
@@ -1,740 +1,638 @@
1
- # router_open_api.py
2
- # ==========================================
3
- # 🌐 通用开放 API 模块(供第三方插件调用)
4
- # ==========================================
5
- # 作用:对外提供受 Plugin Key 保护的通用接口,
6
- # 供 NodeCraft AI 等第三方插件复用 ComfyUI-Ranking 账号体系。
7
- # 关联文件:
8
- # - 安全认证.py (verify_token_with_fallback, require_auth)
9
- # - database_sql.py (get_db, SessionLocal)
10
- # - models_sql.py (Wallet, Transaction)
11
- # - 数据库连接.py (users.json 读写)
12
- # 安全机制:
13
- # - X-Plugin-Key Header 鉴权
14
- # - 基于 plugin_key 的内存限流(每分钟重置)
15
- # - 所有扣款/退款操作记录到 Transaction 流水(含哈希链)
16
- # ==========================================
17
-
18
- import os
19
- import json
20
- import time
21
- import uuid
22
- import datetime
23
- import hashlib
24
- import logging
25
- import threading
26
- from datetime import date
27
- from typing import Optional
28
-
29
- from fastapi import APIRouter, Depends, Header, HTTPException, Query
30
- from fastapi.responses import JSONResponse
31
- from sqlalchemy.orm import Session
32
- from pydantic import BaseModel, Field
33
-
34
- from database_sql import get_db, SessionLocal
35
- from models_sql import Wallet, Transaction, RegisteredPlugin
36
- from 安全认证 import verify_token_with_fallback
37
- import 数据库连接 as json_db
38
-
39
- logger = logging.getLogger("ComfyUI-Ranking.OpenAPI")
40
-
41
- router = APIRouter(prefix="/api/open", tags=["open-api"])
42
-
43
-
44
- # ═══════════════ 插件注册表(从数据库动态加载) ═══════════════
45
- # 反向索引:plugin_key -> {id, key, name, rate_limit, permissions, max_deduct_per_tx, max_deduct_per_day}
46
- _PLUGIN_KEY_INDEX: dict = {}
47
-
48
-
49
- def load_plugins_from_db():
50
- """启动时从数据库加载所有启用的插件到内存索引"""
51
- global _PLUGIN_KEY_INDEX
52
-
53
- db = SessionLocal()
54
- try:
55
- # 首次启动时 seed 默认插件
56
- if not db.query(RegisteredPlugin).first():
57
- default_key = os.environ.get("NCA_PLUGIN_KEY", "nca_2026_nodecraft_ai_plugin")
58
- default_plugin = RegisteredPlugin(
59
- plugin_id="nodecraft_ai",
60
- plugin_key=default_key,
61
- name="NodeCraft AI",
62
- enabled=True,
63
- rate_limit=100,
64
- max_deduct_per_tx=100,
65
- max_deduct_per_day=1000,
66
- permissions='["verify","balance","deduct","refund"]',
67
- created_by="system",
68
- )
69
- db.add(default_plugin)
70
- db.commit()
71
- logger.info("✅ 已 seed 默认插件: NodeCraft AI")
72
-
73
- plugins = db.query(RegisteredPlugin).filter(RegisteredPlugin.enabled == True).all()
74
-
75
- # 构建内存索引
76
- new_index = {}
77
- for p in plugins:
78
- try:
79
- perms = json.loads(p.permissions) if p.permissions else ["verify", "balance", "deduct", "refund"]
80
- except Exception:
81
- perms = ["verify", "balance", "deduct", "refund"]
82
- new_index[p.plugin_key] = {
83
- "id": p.plugin_id,
84
- "key": p.plugin_key,
85
- "name": p.name,
86
- "rate_limit": p.rate_limit or 100,
87
- "permissions": perms,
88
- "max_deduct_per_tx": p.max_deduct_per_tx or 100,
89
- "max_deduct_per_day": p.max_deduct_per_day or 1000,
90
- }
91
-
92
- _PLUGIN_KEY_INDEX = new_index
93
- logger.info(f"✅ 已加载 {len(new_index)} 个插件到注册表")
94
- except Exception as e:
95
- logger.error(f"❌ 加载插件注册表失败: {e}")
96
- # 如果加载失败且索引为空,使用环境变量 fallback
97
- if not _PLUGIN_KEY_INDEX:
98
- fallback_key = os.environ.get("NCA_PLUGIN_KEY", "nca_2026_nodecraft_ai_plugin")
99
- _PLUGIN_KEY_INDEX = {
100
- fallback_key: {
101
- "id": "nodecraft_ai",
102
- "key": fallback_key,
103
- "name": "NodeCraft AI",
104
- "rate_limit": 100,
105
- "permissions": ["verify", "balance", "deduct", "refund"],
106
- "max_deduct_per_tx": 100,
107
- "max_deduct_per_day": 1000,
108
- }
109
- }
110
- logger.warning("⚠️ 使用 fallback 插件配置")
111
- finally:
112
- db.close()
113
-
114
-
115
- def reload_plugin_registry():
116
- """热更新插件注册表(管理操作后调用)"""
117
- load_plugins_from_db()
118
-
119
-
120
- # ==========================================
121
- # 💰 日扣款总额限制:内存计数器(每自然日重置)
122
- # ==========================================
123
- _daily_deduct_lock = threading.Lock()
124
- _daily_deduct_counters: dict = {} # {plugin_id: {"date": "2026-06-17", "total": 500}}
125
-
126
-
127
- def _peek_daily_used(plugin_id: str) -> int:
128
- """读取插件当日已使用的扣款总额(不修改计数)。跨日自动归零。"""
129
- today = str(date.today())
130
- with _daily_deduct_lock:
131
- counter = _daily_deduct_counters.get(plugin_id)
132
- if not counter or counter.get("date") != today:
133
- return 0
134
- return int(counter.get("total", 0))
135
-
136
-
137
- def _commit_daily_used(plugin_id: str, amount: int) -> int:
138
- """扣款成功后将本次金额累加到当日计数,返回累加后的总额。"""
139
- today = str(date.today())
140
- with _daily_deduct_lock:
141
- counter = _daily_deduct_counters.get(plugin_id)
142
- if not counter or counter.get("date") != today:
143
- counter = {"date": today, "total": 0}
144
- counter["total"] = int(counter.get("total", 0)) + int(amount)
145
- _daily_deduct_counters[plugin_id] = counter
146
- return counter["total"]
147
-
148
-
149
- def verify_plugin_key(plugin_key: str) -> dict:
150
- """
151
- 校验 Plugin Key 并返回插件配置。
152
-
153
- 参数:
154
- plugin_key: 请求头 X-Plugin-Key 的值
155
- 返回:
156
- 匹配到的插件配置字典(包含 id 字段)
157
- 异常:
158
- 401: 未提供 Plugin Key
159
- 403: Plugin Key 无效或未注册
160
- """
161
- if not plugin_key:
162
- raise HTTPException(status_code=401, detail="缺少 X-Plugin-Key Header")
163
- info = _PLUGIN_KEY_INDEX.get(plugin_key)
164
- if not info:
165
- logger.warning(f"OPEN_API_INVALID_KEY | key_prefix={plugin_key[:8]}")
166
- raise HTTPException(status_code=403, detail="Plugin Key 无效或未注册")
167
- return info
168
-
169
-
170
- # ==========================================
171
- # 🚦 限流:基于 plugin_id 的内存计数器(每分钟自动重置)
172
- # ==========================================
173
- _rate_lock = threading.Lock()
174
- _rate_buckets: dict = {} # {plugin_id: {"window_start": int, "count": int}}
175
-
176
-
177
- def _check_rate_limit(plugin_info: dict, permission: str) -> None:
178
- """
179
- 校验插件权限并执行限流。
180
-
181
- - 权限不足 -> 403
182
- - 触发限流 -> 429
183
- """
184
- plugin_id = plugin_info["id"]
185
- rate_limit = int(plugin_info.get("rate_limit", 60))
186
- perms = plugin_info.get("permissions", [])
187
- if permission not in perms:
188
- raise HTTPException(
189
- status_code=403,
190
- detail=f"插件 {plugin_id} 无权限调用 {permission} 接口",
191
- )
192
-
193
- now = int(time.time())
194
- window_start = now - (now % 60)
195
-
196
- with _rate_lock:
197
- bucket = _rate_buckets.get(plugin_id)
198
- if not bucket or bucket["window_start"] != window_start:
199
- bucket = {"window_start": window_start, "count": 0}
200
- _rate_buckets[plugin_id] = bucket
201
- if bucket["count"] >= rate_limit:
202
- raise HTTPException(
203
- status_code=429,
204
- detail=f"请求过于频繁,限流 {rate_limit}/分钟,请稍后再试",
205
- )
206
- bucket["count"] += 1
207
-
208
-
209
- def _require_plugin(plugin_key: Optional[str], permission: str) -> dict:
210
- """统一入口:校验 Plugin Key + 权限 + 限流"""
211
- info = verify_plugin_key(plugin_key or "")
212
- _check_rate_limit(info, permission)
213
- return info
214
-
215
-
216
- def _resolve_user_from_token(token: str) -> str:
217
- """从 Token 中解析出账号;失败时抛 401"""
218
- if not token:
219
- raise HTTPException(status_code=401, detail="缺少用户 Token")
220
- is_valid, account, error_msg = verify_token_with_fallback(token)
221
- if not is_valid or not account:
222
- raise HTTPException(status_code=401, detail=error_msg or "Token 无效")
223
- return account
224
-
225
-
226
- def _calc_tx_hash(tx_id: str, account: str, tx_type: str, amount: int, prev_hash: str) -> str:
227
- """计算交易哈希(与 router_wallet.calculate_tx_hash 算法一致)"""
228
- data = f"{tx_id}{account}{tx_type}{amount}{prev_hash}"
229
- return hashlib.sha256(data.encode()).hexdigest()
230
-
231
-
232
- # ==========================================
233
- # 📦 请求模型
234
- # ==========================================
235
- class VerifyTokenRequest(BaseModel):
236
- token: str = Field(..., description="待验证的用户 JWT Token")
237
-
238
-
239
- class DeductRequest(BaseModel):
240
- token: str = Field(..., description="用户 JWT Token")
241
- amount: int = Field(..., gt=0, description="扣款金额(正整数)")
242
- reason: str = Field(..., description="业务标识/扣款原因")
243
- reference_id: Optional[str] = Field(None, description="业务侧参考 ID")
244
-
245
-
246
- class RefundRequest(BaseModel):
247
- token: str = Field(..., description="用户 JWT Token")
248
- transaction_id: str = Field(..., description="原扣款交易 ID")
249
- amount: int = Field(..., gt=0, description="退款金额(正整数)")
250
-
251
-
252
- # ==========================================
253
- # 1️⃣ Token 验证:POST /api/open/verify-token
254
- # ==========================================
255
- @router.post("/verify-token")
256
- async def open_verify_token(
257
- req: VerifyTokenRequest,
258
- x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
259
- ):
260
- """
261
- 供第三方插件验证用户 Token 有效性。
262
- 成功返回账号与基础资料(昵称、头像)。
263
- """
264
- plugin_info = _require_plugin(x_plugin_key, "verify")
265
-
266
- is_valid, account, error_msg = verify_token_with_fallback(req.token)
267
- if not is_valid or not account:
268
- logger.info(
269
- f"OPEN_VERIFY_TOKEN_FAIL | plugin={plugin_info['id']} | reason={error_msg}"
270
- )
271
- return {"valid": False, "error": error_msg or "Token 无效"}
272
-
273
- users_db = json_db.load_data("users.json", default_data={})
274
- user = users_db.get(account, {}) or {}
275
-
276
- logger.info(f"OPEN_VERIFY_TOKEN | plugin={plugin_info['id']} | account={account}")
277
- return {
278
- "valid": True,
279
- "account": account,
280
- "name": user.get("name", account),
281
- "avatar": user.get("avatarDataUrl", "") or user.get("avatar", ""),
282
- }
283
-
284
-
285
- # ==========================================
286
- # 2️⃣ 余额查询:GET /api/open/balance
287
- # ==========================================
288
- @router.get("/balance")
289
- async def open_get_balance(
290
- authorization: Optional[str] = Header(None, alias="Authorization"),
291
- x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
292
- db: Session = Depends(get_db),
293
- ):
294
- """
295
- 查询用户钱包余额。
296
- 认证:Authorization: Bearer <user_token> + X-Plugin-Key
297
- """
298
- plugin_info = _require_plugin(x_plugin_key, "balance")
299
-
300
- if not authorization:
301
- raise HTTPException(status_code=401, detail="缺少 Authorization Header")
302
- parts = authorization.split(" ", 1)
303
- if len(parts) != 2 or parts[0].lower() != "bearer":
304
- raise HTTPException(status_code=401, detail="Authorization 格式错误,请使用 Bearer Token")
305
-
306
- account = _resolve_user_from_token(parts[1])
307
- wallet = db.query(Wallet).filter(Wallet.account == account).first()
308
-
309
- if not wallet:
310
- result = {
311
- "account": account,
312
- "balance": 0,
313
- "earn_balance": 0,
314
- "tip_balance": 0,
315
- "task_balance": 0,
316
- "frozen_balance": 0,
317
- }
318
- else:
319
- result = {
320
- "account": account,
321
- "balance": int(wallet.balance or 0),
322
- "earn_balance": int(wallet.earn_balance or 0),
323
- "tip_balance": int(wallet.tip_balance or 0),
324
- "task_balance": int(wallet.task_balance or 0),
325
- "frozen_balance": int(wallet.frozen_balance or 0),
326
- }
327
-
328
- logger.info(
329
- f"OPEN_BALANCE | plugin={plugin_info['id']} | account={account} | balance={result['balance']}"
330
- )
331
- return result
332
-
333
-
334
- # ==========================================
335
- # 3️⃣ 通用扣款:POST /api/open/deduct
336
- # ==========================================
337
- @router.post("/deduct")
338
- async def open_deduct(
339
- req: DeductRequest,
340
- x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
341
- db: Session = Depends(get_db),
342
- ):
343
- """
344
- 通用扣款接口(从 wallet.balance 扣减)。
345
- 流程:
346
- 1. 校验 Plugin Key + 权限 + 限流
347
- 2. 解析 Token -> account
348
- 3. 悲观锁锁定钱包,检查余额
349
- 4. 扣款 + 写入 Transaction(含哈希链)
350
- """
351
- plugin_info = _require_plugin(x_plugin_key, "deduct")
352
- account = _resolve_user_from_token(req.token)
353
- amount = int(req.amount)
354
-
355
- # 🛡️ 单笔扣款上限校验(在余额检查之前快速拒绝)
356
- max_per_tx = int(plugin_info.get("max_deduct_per_tx", 100))
357
- if amount > max_per_tx:
358
- logger.warning(
359
- f"OPEN_DEDUCT_TX_LIMIT | plugin={plugin_info['id']} | account={account} | "
360
- f"amount={amount} | max={max_per_tx}"
361
- )
362
- return JSONResponse(status_code=403, content={
363
- "error": "AMOUNT_EXCEEDS_LIMIT",
364
- "message": f"单笔扣款不能超过{max_per_tx}积分",
365
- "max": max_per_tx,
366
- })
367
-
368
- # 🛡️ 日扣款总额校验(仅检查不累加,扣款成功后再累加)
369
- max_daily = int(plugin_info.get("max_deduct_per_day", 1000))
370
- used_today = _peek_daily_used(plugin_info["id"])
371
- if used_today + amount > max_daily:
372
- logger.warning(
373
- f"OPEN_DEDUCT_DAILY_LIMIT | plugin={plugin_info['id']} | account={account} | "
374
- f"amount={amount} | used={used_today} | limit={max_daily}"
375
- )
376
- return JSONResponse(status_code=403, content={
377
- "error": "DAILY_LIMIT_EXCEEDED",
378
- "message": "今日扣款已达上限",
379
- "limit": max_daily,
380
- "used": used_today,
381
- })
382
-
383
- try:
384
- wallet = (
385
- db.query(Wallet)
386
- .filter(Wallet.account == account)
387
- .with_for_update()
388
- .first()
389
- )
390
- if not wallet or int(wallet.balance or 0) < amount:
391
- raise HTTPException(status_code=402, detail="余额不足")
392
-
393
- wallet.balance = int(wallet.balance or 0) - amount
394
-
395
- tx_id = f"OPEN_DEDUCT_{int(time.time())}_{uuid.uuid4().hex[:8]}"
396
- last_tx = (
397
- db.query(Transaction)
398
- .filter(Transaction.account == account)
399
- .order_by(Transaction.created_at.desc())
400
- .first()
401
- )
402
- prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
403
- tx_hash = _calc_tx_hash(tx_id, account, "OPEN_DEDUCT", -amount, prev_hash)
404
-
405
- new_tx = Transaction(
406
- tx_id=tx_id,
407
- account=account,
408
- tx_type="OPEN_DEDUCT",
409
- amount=-amount,
410
- related_account=plugin_info["id"],
411
- item_id=req.reference_id,
412
- prev_hash=prev_hash,
413
- tx_hash=tx_hash,
414
- description=f"[{plugin_info['name']}] {req.reason}",
415
- source_plugin=plugin_info["id"], # 🌐 来源插件标识,用于按工具结构化追踪
416
- )
417
- db.add(new_tx)
418
- db.commit()
419
- db.refresh(wallet)
420
- except HTTPException:
421
- db.rollback()
422
- raise
423
- except Exception as e:
424
- db.rollback()
425
- logger.error(
426
- f"OPEN_DEDUCT_FAIL | plugin={plugin_info['id']} | account={account} | err={e}"
427
- )
428
- raise HTTPException(status_code=500, detail=f"扣款失败: {e}")
429
-
430
- # ✅ 扣款成功后再累加日计数器,避免余额不足等失败场景占用额度
431
- daily_total = _commit_daily_used(plugin_info["id"], amount)
432
-
433
- logger.info(
434
- f"OPEN_DEDUCT | plugin={plugin_info['id']} | account={account} | "
435
- f"amount={amount} | tx={tx_id} | reason={req.reason} | ref={req.reference_id} | "
436
- f"daily_used={daily_total}/{max_daily}"
437
- )
438
- return {
439
- "success": True,
440
- "remaining_balance": int(wallet.balance or 0),
441
- "transaction_id": tx_id,
442
- }
443
-
444
-
445
- # ==========================================
446
- # 4️⃣ 退款:POST /api/open/refund
447
- # ==========================================
448
- @router.post("/refund")
449
- async def open_refund(
450
- req: RefundRequest,
451
- x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
452
- db: Session = Depends(get_db),
453
- ):
454
- """
455
- 退款接口:将之前 OPEN_DEDUCT 的金额(部分或全额)退还到用户钱包。
456
-
457
- 校验:
458
- - 原交易必须存在且 tx_type == OPEN_DEDUCT
459
- - 原交易归属当前 token 用户
460
- - 原交易由当前插件发起(related_account == plugin_id)
461
- - 累计退款金额 ≤ 原扣款金额
462
- """
463
- plugin_info = _require_plugin(x_plugin_key, "refund")
464
- account = _resolve_user_from_token(req.token)
465
- refund_amount = int(req.amount)
466
-
467
- original_tx = (
468
- db.query(Transaction).filter(Transaction.tx_id == req.transaction_id).first()
469
- )
470
- if not original_tx:
471
- raise HTTPException(status_code=404, detail="原交易不存在")
472
- if original_tx.account != account:
473
- raise HTTPException(status_code=403, detail="无权对该交易执行退款")
474
- if original_tx.tx_type != "OPEN_DEDUCT":
475
- raise HTTPException(status_code=400, detail="该交易类型不支持开放退款")
476
- if (original_tx.related_account or "") != plugin_info["id"]:
477
- raise HTTPException(status_code=403, detail="不允许跨插件退款")
478
-
479
- original_amount = abs(int(original_tx.amount or 0))
480
- if refund_amount > original_amount:
481
- raise HTTPException(status_code=400, detail="退款金额超过原扣款金额")
482
-
483
- # 累计已退金额校验(防止重复退款超额)
484
- refunded_records = (
485
- db.query(Transaction)
486
- .filter(
487
- Transaction.tx_type == "OPEN_REFUND",
488
- Transaction.related_account == plugin_info["id"],
489
- Transaction.item_id == req.transaction_id,
490
- )
491
- .all()
492
- )
493
- already_refunded = sum(abs(int(t.amount or 0)) for t in refunded_records)
494
- if already_refunded + refund_amount > original_amount:
495
- raise HTTPException(
496
- status_code=400,
497
- detail=f"退款金额超过可退余额(已退 {already_refunded}/{original_amount})",
498
- )
499
-
500
- try:
501
- wallet = (
502
- db.query(Wallet)
503
- .filter(Wallet.account == account)
504
- .with_for_update()
505
- .first()
506
- )
507
- if not wallet:
508
- wallet = Wallet(
509
- account=account,
510
- balance=0,
511
- earn_balance=0,
512
- tip_balance=0,
513
- frozen_balance=0,
514
- )
515
- db.add(wallet)
516
- db.flush()
517
- wallet.balance = int(wallet.balance or 0) + refund_amount
518
-
519
- tx_id = f"OPEN_REFUND_{int(time.time())}_{uuid.uuid4().hex[:8]}"
520
- last_tx = (
521
- db.query(Transaction)
522
- .filter(Transaction.account == account)
523
- .order_by(Transaction.created_at.desc())
524
- .first()
525
- )
526
- prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
527
- tx_hash = _calc_tx_hash(tx_id, account, "OPEN_REFUND", refund_amount, prev_hash)
528
-
529
- new_tx = Transaction(
530
- tx_id=tx_id,
531
- account=account,
532
- tx_type="OPEN_REFUND",
533
- amount=refund_amount,
534
- related_account=plugin_info["id"],
535
- item_id=req.transaction_id, # 关联原扣款交易
536
- prev_hash=prev_hash,
537
- tx_hash=tx_hash,
538
- description=f"[{plugin_info['name']}] 退款 {req.transaction_id}",
539
- source_plugin=plugin_info["id"], # 🌐 来源插件标识
540
- )
541
- db.add(new_tx)
542
- db.commit()
543
- db.refresh(wallet)
544
- except HTTPException:
545
- db.rollback()
546
- raise
547
- except Exception as e:
548
- db.rollback()
549
- logger.error(
550
- f"OPEN_REFUND_FAIL | plugin={plugin_info['id']} | account={account} | err={e}"
551
- )
552
- raise HTTPException(status_code=500, detail=f"退款失败: {e}")
553
-
554
- logger.info(
555
- f"OPEN_REFUND | plugin={plugin_info['id']} | account={account} | "
556
- f"amount={refund_amount} | tx={tx_id} | original={req.transaction_id}"
557
- )
558
- return {
559
- "success": True,
560
- "remaining_balance": int(wallet.balance or 0),
561
- "transaction_id": tx_id,
562
- }
563
-
564
-
565
- # ==========================================
566
- # 5️⃣ 消费记录查询:GET /api/open/transactions
567
- # ==========================================
568
- @router.get("/transactions")
569
- async def open_list_transactions(
570
- authorization: Optional[str] = Header(None, alias="Authorization"),
571
- x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
572
- page: int = Query(1, ge=1, description="页码(从 1 开始)"),
573
- page_size: int = Query(20, ge=1, le=100, description="每页条数(1-100)"),
574
- start_time: Optional[int] = Query(None, description="起始时间(unix 秒)"),
575
- end_time: Optional[int] = Query(None, description="结束时间(unix 秒)"),
576
- tx_type: Optional[str] = Query(None, description="交易类型,如 OPEN_DEDUCT / OPEN_REFUND"),
577
- db: Session = Depends(get_db),
578
- ):
579
- """
580
- 查询当前用户由当前插件产生的消费/退款记录。
581
-
582
- 认证:Authorization: Bearer <user_token> + X-Plugin-Key
583
- 安全:仅返回 source_plugin == 当前 plugin_id 的记录,防止跨插件读取。
584
- """
585
- plugin_info = _require_plugin(x_plugin_key, "verify")
586
-
587
- if not authorization:
588
- raise HTTPException(status_code=401, detail="缺少 Authorization Header")
589
- parts = authorization.split(" ", 1)
590
- if len(parts) != 2 or parts[0].lower() != "bearer":
591
- raise HTTPException(status_code=401, detail="Authorization 格式错误,请使用 Bearer Token")
592
- account = _resolve_user_from_token(parts[1])
593
-
594
- plugin_id = plugin_info["id"]
595
-
596
- # 构造查询:按 source_plugin 过滤;兼容历史 NULL 数据走 related_account 兜底
597
- q = db.query(Transaction).filter(
598
- Transaction.account == account,
599
- ((Transaction.source_plugin == plugin_id)
600
- | ((Transaction.source_plugin.is_(None)) & (Transaction.related_account == plugin_id))),
601
- )
602
-
603
- if tx_type:
604
- q = q.filter(Transaction.tx_type == tx_type)
605
- if start_time is not None:
606
- q = q.filter(Transaction.created_at >= datetime.datetime.utcfromtimestamp(int(start_time)))
607
- if end_time is not None:
608
- q = q.filter(Transaction.created_at <= datetime.datetime.utcfromtimestamp(int(end_time)))
609
-
610
- total = q.count()
611
- rows = (
612
- q.order_by(Transaction.created_at.desc())
613
- .offset((page - 1) * page_size)
614
- .limit(page_size)
615
- .all()
616
- )
617
-
618
- items = []
619
- for t in rows:
620
- created_ts = int(t.created_at.timestamp()) if t.created_at else 0
621
- # reason 从 description 中解析(去掉 [插件名] 前缀),兼容旧记录
622
- reason = (t.description or "").replace(f"[{plugin_info['name']}] ", "").strip() or None
623
- items.append({
624
- "id": t.tx_id,
625
- "type": t.tx_type,
626
- "amount": int(t.amount or 0),
627
- "reason": reason,
628
- "reference_id": t.item_id,
629
- "created_at": created_ts,
630
- "description": t.description or "",
631
- })
632
-
633
- logger.info(
634
- f"OPEN_LIST_TX | plugin={plugin_id} | account={account} | "
635
- f"page={page}/{page_size} | total={total}"
636
- )
637
- return {
638
- "success": True,
639
- "transactions": items,
640
- "total": total,
641
- "page": page,
642
- "page_size": page_size,
643
- }
644
-
645
-
646
- # ==========================================
647
- # 6️⃣ 消费汇总:GET /api/open/usage-summary
648
- # ==========================================
649
- @router.get("/usage-summary")
650
- async def open_usage_summary(
651
- x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
652
- account: Optional[str] = Query(None, description="指定用户账号,不传则统计所有用户"),
653
- period: str = Query("daily", description="聚合粒度:daily / monthly / total"),
654
- start_time: Optional[int] = Query(None, description="起始时间(unix 秒)"),
655
- end_time: Optional[int] = Query(None, description="结束时间(unix 秒)"),
656
- db: Session = Depends(get_db),
657
- ):
658
- """
659
- 本插件维度的消费汇总统计。
660
-
661
- 返回:总扣款 / 总退款 / 总笔数 / 净消费 + 按 period 分桶的明细。
662
- 安全:仅统计 source_plugin == 当前 plugin_id 的记录。
663
- """
664
- plugin_info = _require_plugin(x_plugin_key, "verify")
665
- plugin_id = plugin_info["id"]
666
-
667
- period = (period or "daily").lower()
668
- if period not in ("daily", "monthly", "total"):
669
- raise HTTPException(status_code=400, detail="period 仅支持 daily / monthly / total")
670
-
671
- base = db.query(Transaction).filter(
672
- ((Transaction.source_plugin == plugin_id)
673
- | ((Transaction.source_plugin.is_(None)) & (Transaction.related_account == plugin_id))),
674
- Transaction.tx_type.in_(["OPEN_DEDUCT", "OPEN_REFUND"]),
675
- )
676
- if account:
677
- base = base.filter(Transaction.account == account)
678
- if start_time is not None:
679
- base = base.filter(Transaction.created_at >= datetime.datetime.utcfromtimestamp(int(start_time)))
680
- if end_time is not None:
681
- base = base.filter(Transaction.created_at <= datetime.datetime.utcfromtimestamp(int(end_time)))
682
-
683
- rows = base.all()
684
-
685
- total_deduct = 0
686
- total_refund = 0
687
- total_count = len(rows)
688
- bucket: dict = {} # key -> {"deduct":int,"refund":int,"count":int}
689
-
690
- for t in rows:
691
- amt = int(t.amount or 0)
692
- if t.tx_type == "OPEN_DEDUCT":
693
- total_deduct += abs(amt)
694
- elif t.tx_type == "OPEN_REFUND":
695
- total_refund += abs(amt)
696
-
697
- if period == "total":
698
- continue
699
- if not t.created_at:
700
- continue
701
- if period == "daily":
702
- key = t.created_at.strftime("%Y-%m-%d")
703
- else: # monthly
704
- key = t.created_at.strftime("%Y-%m")
705
-
706
- b = bucket.setdefault(key, {"deduct": 0, "refund": 0, "count": 0})
707
- if t.tx_type == "OPEN_DEDUCT":
708
- b["deduct"] += abs(amt)
709
- elif t.tx_type == "OPEN_REFUND":
710
- b["refund"] += abs(amt)
711
- b["count"] += 1
712
-
713
- breakdown = []
714
- if period != "total":
715
- for key in sorted(bucket.keys(), reverse=True):
716
- v = bucket[key]
717
- label = "date" if period == "daily" else "month"
718
- breakdown.append({
719
- label: key,
720
- "deduct": v["deduct"],
721
- "refund": v["refund"],
722
- "count": v["count"],
723
- })
724
-
725
- logger.info(
726
- f"OPEN_USAGE_SUMMARY | plugin={plugin_id} | account={account or '*'} | "
727
- f"period={period} | tx_count={total_count}"
728
- )
729
- return {
730
- "success": True,
731
- "plugin_id": plugin_id,
732
- "plugin_name": plugin_info.get("name", plugin_id),
733
- "summary": {
734
- "total_deduct": total_deduct,
735
- "total_refund": total_refund,
736
- "total_transactions": total_count,
737
- "net_consumption": total_deduct - total_refund,
738
- },
739
- "breakdown": breakdown,
740
- }
 
1
+ import asyncio
2
+ # router_open_api.py
3
+ # ==========================================
4
+ # 🌐 通用开放 API 模块(供第三方插件调用)
5
+ # ==========================================
6
+ # 作用:对外提供受 Plugin Key 保护的通用接口,
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
+ # 安全机制:
14
+ # - X-Plugin-Key Header 鉴权
15
+ # - 基于 plugin_id 的 SQL 持久化限流(每分钟窗口,多 worker 共享)
16
+ # - 所有扣款/退款操作记录到 Transaction 流水(含哈希链)
17
+ # ==========================================
18
+
19
+ import os
20
+ import json
21
+ import time
22
+ import uuid
23
+ import datetime
24
+ 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 tip_store import run_tip_effect
38
+ from 安全认证 import verify_token_with_fallback
39
+ import 数据库连接 as json_db
40
+
41
+ logger = logging.getLogger("ComfyUI-Ranking.OpenAPI")
42
+
43
+ router = APIRouter(prefix="/api/open", tags=["open-api"])
44
+
45
+
46
+ # ═══════════════ 插件注册表(从数据库动态加载) ═══════════════
47
+ # 反向索引:plugin_key -> {id, key, name, rate_limit, permissions, max_deduct_per_tx, max_deduct_per_day}
48
+ _PLUGIN_KEY_INDEX: dict = {}
49
+ _INSECURE_LEGACY_PLUGIN_KEYS = frozenset({"nca_2026_nodecraft_ai_plugin"})
50
+
51
+
52
+ def load_plugins_from_db():
53
+ """启动时从数据库加载所有启用的插件到内存索引"""
54
+ global _PLUGIN_KEY_INDEX
55
+
56
+ db = None
57
+ try:
58
+ db = SessionLocal()
59
+ # 首次启动时仅在显式配置高熵 Key 时 seed 默认插件,不再使用源码内固定凭证。
60
+ if not db.query(RegisteredPlugin).first():
61
+ default_key = os.environ.get("NCA_PLUGIN_KEY", "").strip()
62
+ if len(default_key) >= 32 and default_key not in _INSECURE_LEGACY_PLUGIN_KEYS:
63
+ default_plugin = RegisteredPlugin(
64
+ plugin_id="nodecraft_ai",
65
+ plugin_key=default_key,
66
+ name="NodeCraft AI",
67
+ enabled=True,
68
+ rate_limit=100,
69
+ max_deduct_per_tx=100,
70
+ max_deduct_per_day=1000,
71
+ permissions='["verify","balance","deduct","refund"]',
72
+ created_by="system",
73
+ )
74
+ db.add(default_plugin)
75
+ db.commit()
76
+ logger.info("✅ 已 seed 默认插件: NodeCraft AI")
77
+ else:
78
+ logger.warning("NCA_PLUGIN_KEY 缺失或不符合安全要求,跳过默认插件 seed")
79
+
80
+ plugins = db.query(RegisteredPlugin).filter(RegisteredPlugin.enabled == True).all()
81
+
82
+ # 构建内存索引
83
+ new_index = {}
84
+ for p in plugins:
85
+ # 固定旧 Key 已随源代码泄露,不能继续接受;管理员轮换成新 Key 后即可恢复。
86
+ if p.plugin_key in _INSECURE_LEGACY_PLUGIN_KEYS:
87
+ logger.critical(f"拒绝加载已泄露的默认 Plugin Key: plugin_id={p.plugin_id}")
88
+ continue
89
+ try:
90
+ perms = json.loads(p.permissions) if p.permissions else ["verify", "balance", "deduct", "refund"]
91
+ except Exception:
92
+ perms = ["verify", "balance", "deduct", "refund"]
93
+ new_index[p.plugin_key] = {
94
+ "id": p.plugin_id,
95
+ "key": p.plugin_key,
96
+ "name": p.name,
97
+ "rate_limit": p.rate_limit or 100,
98
+ "permissions": perms,
99
+ "max_deduct_per_tx": p.max_deduct_per_tx or 100,
100
+ "max_deduct_per_day": p.max_deduct_per_day or 1000,
101
+ }
102
+
103
+ _PLUGIN_KEY_INDEX = new_index
104
+ logger.info(f"✅ 已加载 {len(new_index)} 个插件到注册表")
105
+ except Exception as e:
106
+ # 数据源不可信/不可用时 fail closed:清空内存索引,撤销旧 Key 的继续访问能力。
107
+ _PLUGIN_KEY_INDEX = {}
108
+ logger.error(f"❌ 加载插件注册表失败: error_type={type(e).__name__}; 插件 API 已 fail closed")
109
+ finally:
110
+ if db is not None:
111
+ try:
112
+ db.close()
113
+ except Exception as e:
114
+ _PLUGIN_KEY_INDEX = {}
115
+ logger.error(
116
+ f"关闭插件注册表会话失败: error_type={type(e).__name__}; 插件 API 已 fail closed"
117
+ )
118
+
119
+
120
+ def reload_plugin_registry():
121
+ """热更新插件注册表(管理操作后调用)"""
122
+ load_plugins_from_db()
123
+
124
+
125
+ # ==========================================
126
+ def verify_plugin_key(plugin_key: str) -> dict:
127
+ """
128
+ 校验 Plugin Key 并返回插件配置。
129
+
130
+ 参数:
131
+ plugin_key: 请求头 X-Plugin-Key 的值
132
+ 返回:
133
+ 匹配到的插件配置字典(包含 id 字段)
134
+ 异常:
135
+ 401: 未提供 Plugin Key
136
+ 403: Plugin Key 无效或未注册
137
+ """
138
+ if not plugin_key:
139
+ raise HTTPException(status_code=401, detail="缺少 X-Plugin-Key Header")
140
+ info = _PLUGIN_KEY_INDEX.get(plugin_key)
141
+ if not info:
142
+ logger.warning("OPEN_API_INVALID_KEY")
143
+ raise HTTPException(status_code=403, detail="Plugin Key 无效或未注册")
144
+ return info
145
+
146
+
147
+ # ==========================================
148
+ # 🚦 限流:SQL 分钟计数器,多 worker 共享
149
+ # ==========================================
150
+ def _check_rate_limit(plugin_info: dict, permission: str, db: Session) -> None:
151
+ """
152
+ 校验插件权限并执行限流。
153
+
154
+ - 权限不足 -> 403
155
+ - 触发限流 -> 429
156
+ """
157
+ plugin_id = plugin_info["id"]
158
+
159
+ now = int(time.time())
160
+ window_start = now - (now % 60)
161
+ try:
162
+ # 对配置列执行 no-op UPDATE:PostgreSQL 获取行锁,SQLite 也会取得写事务锁;
163
+ # 再 SELECT FOR UPDATE,保证首次建桶和窗口递增串行,而不是只依赖进程锁。
164
+ db.execute(
165
+ text("UPDATE registered_plugins SET rate_limit = rate_limit WHERE plugin_id = :plugin_id"),
166
+ {"plugin_id": plugin_id},
167
+ )
168
+ plugin_row = (db.query(RegisteredPlugin)
169
+ .filter(RegisteredPlugin.plugin_id == plugin_id)
170
+ .populate_existing()
171
+ .with_for_update()
172
+ .first())
173
+ validate_live_plugin(plugin_row, plugin_info.get("key"), permission)
174
+ rate_limit = positive_integer(plugin_row.rate_limit)
175
+ bucket = (db.query(OpenApiRateLimitBucket)
176
+ .filter(OpenApiRateLimitBucket.plugin_id == plugin_id)
177
+ .with_for_update()
178
+ .first())
179
+ if not bucket:
180
+ bucket = OpenApiRateLimitBucket(
181
+ plugin_id=plugin_id, window_start=window_start, request_count=0
182
+ )
183
+ db.add(bucket)
184
+ db.flush()
185
+ if bucket.window_start != window_start:
186
+ bucket.window_start = window_start
187
+ bucket.request_count = 0
188
+ if bucket.request_count >= rate_limit:
189
+ db.rollback()
190
+ raise HTTPException(
191
+ status_code=429,
192
+ detail=f"请求过于频繁,限流 {rate_limit}/分钟,请稍后再试",
193
+ )
194
+ bucket.request_count += 1
195
+ db.commit()
196
+ except HTTPException:
197
+ db.rollback()
198
+ raise
199
+ except Exception as exc:
200
+ db.rollback()
201
+ logger.error(f"OPEN_API_RATE_LIMIT_DB_FAIL | plugin={plugin_id} | error_type={type(exc).__name__}")
202
+ raise HTTPException(status_code=503, detail="插件限流服务暂不可用")
203
+
204
+
205
+ def _require_plugin(plugin_key: Optional[str], permission: str, db: Session) -> dict:
206
+ """统一入口:校验 Plugin Key + 权限 + 限流"""
207
+ info = verify_plugin_key(plugin_key or "")
208
+ _check_rate_limit(info, permission, db)
209
+ return info
210
+
211
+
212
+ async def _resolve_user_from_token(token: str) -> str:
213
+ """从 Token 中解析出账号;失败时抛 401"""
214
+ if not token:
215
+ raise HTTPException(status_code=401, detail="缺少用户 Token")
216
+ is_valid, account, error_msg = await asyncio.to_thread(verify_token_with_fallback, token)
217
+ if not is_valid or not account:
218
+ raise HTTPException(status_code=401, detail=error_msg or "Token 无效")
219
+ return account
220
+
221
+
222
+ def _calc_tx_hash(tx_id: str, account: str, tx_type: str, amount: int, prev_hash: str) -> str:
223
+ """计算交易哈希(与 router_wallet.calculate_tx_hash 算法一致)"""
224
+ data = f"{tx_id}{account}{tx_type}{amount}{prev_hash}"
225
+ return hashlib.sha256(data.encode()).hexdigest()
226
+
227
+
228
+ # ==========================================
229
+ # 📦 请求模型
230
+ # ==========================================
231
+ class VerifyTokenRequest(BaseModel):
232
+ token: str = Field(..., description="待验证的用户 JWT Token")
233
+
234
+
235
+ class DeductRequest(BaseModel):
236
+ token: str = Field(..., description="用户 JWT Token")
237
+ amount: int = Field(..., gt=0, strict=True, description="扣款金额(正整数)")
238
+ reason: str = Field(..., description="业务标识/扣款原因")
239
+ reference_id: Optional[str] = Field(None, description="业务侧参考 ID")
240
+
241
+
242
+ class RefundRequest(BaseModel):
243
+ token: str = Field(..., description="用户 JWT Token")
244
+ transaction_id: str = Field(..., description="原扣款交易 ID")
245
+ amount: int = Field(..., gt=0, strict=True, description="退款金额(正整数)")
246
+
247
+
248
+ # ==========================================
249
+ # 1️⃣ Token 验证:POST /api/open/verify-token
250
+ # ==========================================
251
+ @router.post("/verify-token")
252
+ async def open_verify_token(
253
+ req: VerifyTokenRequest,
254
+ x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
255
+ db: Session = Depends(get_db),
256
+ ):
257
+ """
258
+ 供第三方插件验证用户 Token 有效性。
259
+ 成功返回账号与基础资料(昵称、头像)。
260
+ """
261
+ plugin_info = _require_plugin(x_plugin_key, "verify", db)
262
+
263
+ is_valid, account, error_msg = await asyncio.to_thread(verify_token_with_fallback, req.token)
264
+ if not is_valid or not account:
265
+ logger.info(f"OPEN_VERIFY_TOKEN_FAIL | plugin={plugin_info['id']} | category=invalid_token")
266
+ return {"valid": False, "error": error_msg or "Token 无效"}
267
+
268
+ users_db = json_db.load_data("users.json", default_data={})
269
+ user = users_db.get(account, {}) or {}
270
+
271
+ logger.info(f"OPEN_VERIFY_TOKEN | plugin={plugin_info['id']}")
272
+ return {
273
+ "valid": True,
274
+ "account": account,
275
+ "name": user.get("name", account),
276
+ "avatar": user.get("avatarDataUrl", "") or user.get("avatar", ""),
277
+ }
278
+
279
+
280
+ # ==========================================
281
+ # 2️⃣ 余额查询:GET /api/open/balance
282
+ # ==========================================
283
+ @router.get("/balance")
284
+ async def open_get_balance(
285
+ authorization: Optional[str] = Header(None, alias="Authorization"),
286
+ x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
287
+ db: Session = Depends(get_db),
288
+ ):
289
+ """
290
+ 查询用户钱包余额。
291
+ 认证:Authorization: Bearer <user_token> + X-Plugin-Key
292
+ """
293
+ plugin_info = _require_plugin(x_plugin_key, "balance", db)
294
+
295
+ if not authorization:
296
+ raise HTTPException(status_code=401, detail="缺少 Authorization Header")
297
+ parts = authorization.split(" ", 1)
298
+ if len(parts) != 2 or parts[0].lower() != "bearer":
299
+ raise HTTPException(status_code=401, detail="Authorization 格式错误,请使用 Bearer Token")
300
+
301
+ account = await _resolve_user_from_token(parts[1])
302
+ wallet = db.query(Wallet).filter(Wallet.account == account).first()
303
+
304
+ if not wallet:
305
+ result = {
306
+ "account": account,
307
+ "balance": 0,
308
+ "earn_balance": 0,
309
+ "tip_balance": 0,
310
+ "task_balance": 0,
311
+ "frozen_balance": 0,
312
+ }
313
+ else:
314
+ result = {
315
+ "account": account,
316
+ "balance": int(wallet.balance or 0),
317
+ "earn_balance": int(wallet.earn_balance or 0),
318
+ "tip_balance": int(wallet.tip_balance or 0),
319
+ "task_balance": int(wallet.task_balance or 0),
320
+ "frozen_balance": int(wallet.frozen_balance or 0),
321
+ }
322
+
323
+ logger.info(f"OPEN_BALANCE | plugin={plugin_info['id']}")
324
+ return result
325
+
326
+
327
+ # ==========================================
328
+ # 3️⃣ 通用扣款:POST /api/open/deduct
329
+ # ==========================================
330
+ @router.post("/deduct")
331
+ async def open_deduct(
332
+ req: DeductRequest,
333
+ x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
334
+ db: Session = Depends(get_db),
335
+ ):
336
+ """
337
+ 通用扣款接口(从 wallet.balance 扣减)。
338
+ 流程:
339
+ 1. 校验 Plugin Key + 权限 + 限流
340
+ 2. 解析 Token -> account
341
+ 3. 共用写锁锁定钱包与实时插件配置,检查额度/余额
342
+ 4. 扣款 + 写入 Transaction(含哈希链)
343
+ """
344
+ plugin_info = _require_plugin(x_plugin_key, "deduct", db)
345
+ account = await _resolve_user_from_token(req.token)
346
+ amount = req.amount
347
+ if isinstance(amount, bool) or not isinstance(amount, int) or amount <= 0:
348
+ raise HTTPException(status_code=422, detail="扣款金额必须为正整数")
349
+ try:
350
+ wallet, plugin_row = lock_open_wallet(db, account, plugin_info["id"], x_plugin_key, "deduct")
351
+ max_per_tx = positive_integer(plugin_row.max_deduct_per_tx)
352
+ max_daily = positive_integer(plugin_row.max_deduct_per_day)
353
+ if amount > max_per_tx:
354
+ db.rollback()
355
+ return JSONResponse(status_code=403, content={
356
+ "error": "AMOUNT_EXCEEDS_LIMIT", "message": f"单笔扣款不能超过{max_per_tx}积分", "max": max_per_tx})
357
+ now = datetime.datetime.utcnow()
358
+ day_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
359
+ used_today = db.query(func.coalesce(func.sum(func.abs(Transaction.amount)), 0)).filter(
360
+ Transaction.tx_type == "OPEN_DEDUCT", Transaction.source_plugin == plugin_info["id"],
361
+ Transaction.created_at >= day_start,
362
+ Transaction.created_at < day_start + datetime.timedelta(days=1)).scalar()
363
+ used_today = quota_total(used_today)
364
+ if used_today + amount > max_daily:
365
+ db.rollback()
366
+ return JSONResponse(status_code=403, content={
367
+ "error": "DAILY_LIMIT_EXCEEDED", "message": "今日扣款已达上限", "limit": max_daily, "used": used_today})
368
+ if wallet is None:
369
+ raise HTTPException(status_code=402, detail="余额不足")
370
+ if isinstance(wallet.balance, bool) or not isinstance(wallet.balance, int):
371
+ raise ValueError("invalid wallet balance")
372
+ if wallet.balance < amount:
373
+ raise HTTPException(status_code=402, detail="余额不足")
374
+ wallet.balance -= amount
375
+ tx_id = f"OPEN_DEDUCT_{uuid.uuid4().hex}"
376
+ last_tx = db.query(Transaction).filter(Transaction.account == account).order_by(
377
+ Transaction.created_at.desc(), Transaction.tx_id.desc()).with_for_update().first()
378
+ prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
379
+ db.add(Transaction(tx_id=tx_id, account=account, tx_type="OPEN_DEDUCT", amount=-amount,
380
+ related_account=plugin_info["id"], item_id=req.reference_id, prev_hash=prev_hash,
381
+ tx_hash=_calc_tx_hash(tx_id, account, "OPEN_DEDUCT", -amount, prev_hash),
382
+ description=f"[{plugin_row.name}] {req.reason}", source_plugin=plugin_info["id"]))
383
+ response = {"success": True, "remaining_balance": wallet.balance, "transaction_id": tx_id}
384
+ db.commit()
385
+ except HTTPException:
386
+ db.rollback()
387
+ raise
388
+ except Exception as error:
389
+ db.rollback()
390
+ logger.error("OPEN_DEDUCT_FAIL | error_type=%s", type(error).__name__)
391
+ raise HTTPException(status_code=500, detail="扣款失败,请稍后重试")
392
+ run_tip_effect(lambda: logger.info("OPEN_DEDUCT_COMPLETED"))
393
+ return response
394
+
395
+
396
+ # ==========================================
397
+ # 4️⃣ 退款:POST /api/open/refund
398
+ # ==========================================
399
+ @router.post("/refund")
400
+ async def open_refund(
401
+ req: RefundRequest,
402
+ x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
403
+ db: Session = Depends(get_db),
404
+ ):
405
+ """
406
+ 退款接口:将之前 OPEN_DEDUCT 的金额(部分或全额)退还到用户钱包��
407
+
408
+ 校验:
409
+ - 原交易必须存在且 tx_type == OPEN_DEDUCT
410
+ - 原交易归属当前 token 用户
411
+ - 原交易由当前插件发起(related_account == plugin_id)
412
+ - 累计退款金额 ≤ 原扣款金额
413
+ """
414
+ plugin_info = _require_plugin(x_plugin_key, "refund", db)
415
+ account = await _resolve_user_from_token(req.token)
416
+ refund_amount = req.amount
417
+ if isinstance(refund_amount, bool) or not isinstance(refund_amount, int) or refund_amount <= 0:
418
+ raise HTTPException(status_code=422, detail="退款金额必须为正整数")
419
+ try:
420
+ wallet, plugin_row = lock_open_wallet(db, account, plugin_info["id"], x_plugin_key, "refund")
421
+ original_tx = db.query(Transaction).filter(Transaction.tx_id == req.transaction_id).populate_existing().with_for_update().first()
422
+ if original_tx is None:
423
+ raise HTTPException(status_code=404, detail="原交易不存在")
424
+ if original_tx.account != account:
425
+ raise HTTPException(status_code=403, detail="无权对该交易执行退款")
426
+ if original_tx.tx_type != "OPEN_DEDUCT":
427
+ raise HTTPException(status_code=400, detail="该交易类型不支持开放退款")
428
+ if original_tx.related_account != plugin_info["id"] or original_tx.source_plugin not in (None, plugin_info["id"]):
429
+ raise HTTPException(status_code=403, detail="不允许跨插件退款")
430
+ if isinstance(original_tx.amount, bool) or not isinstance(original_tx.amount, int) or original_tx.amount >= 0:
431
+ raise HTTPException(status_code=503, detail="原交易金额暂时无法核实")
432
+ original_amount = -original_tx.amount
433
+ # 校验全部关联退款,不能用 abs() 把损坏的负退款/跨账号记录视为合法。
434
+ refunded = db.query(Transaction).filter(
435
+ Transaction.tx_type == "OPEN_REFUND", Transaction.item_id == req.transaction_id).all()
436
+ if any(row.account != account or row.related_account != plugin_info["id"] or
437
+ row.source_plugin not in (None, plugin_info["id"]) or isinstance(row.amount, bool) or
438
+ not isinstance(row.amount, int) or row.amount <= 0 for row in refunded):
439
+ raise HTTPException(status_code=503, detail="退款流水暂时无法核实")
440
+ already_refunded = sum(row.amount for row in refunded)
441
+ if already_refunded + refund_amount > original_amount:
442
+ raise HTTPException(status_code=400, detail="退款金额超过可退余额")
443
+ if wallet is None:
444
+ raise HTTPException(status_code=503, detail="钱包记录暂时无法核实")
445
+ if isinstance(wallet.balance, bool) or not isinstance(wallet.balance, int):
446
+ raise ValueError("invalid wallet balance")
447
+ wallet.balance += refund_amount
448
+ tx_id = f"OPEN_REFUND_{uuid.uuid4().hex}"
449
+ last_tx = db.query(Transaction).filter(Transaction.account == account).order_by(
450
+ Transaction.created_at.desc(), Transaction.tx_id.desc()).with_for_update().first()
451
+ prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
452
+ db.add(Transaction(tx_id=tx_id, account=account, tx_type="OPEN_REFUND", amount=refund_amount,
453
+ related_account=plugin_info["id"], item_id=req.transaction_id, prev_hash=prev_hash,
454
+ tx_hash=_calc_tx_hash(tx_id, account, "OPEN_REFUND", refund_amount, prev_hash),
455
+ description=f"[{plugin_row.name}] 退款 {req.transaction_id}", source_plugin=plugin_info["id"]))
456
+ response = {"success": True, "remaining_balance": wallet.balance, "transaction_id": tx_id}
457
+ db.commit()
458
+ except HTTPException:
459
+ db.rollback()
460
+ raise
461
+ except Exception as error:
462
+ db.rollback()
463
+ logger.error("OPEN_REFUND_FAIL | error_type=%s", type(error).__name__)
464
+ raise HTTPException(status_code=500, detail="退款失败,请稍后重试")
465
+ run_tip_effect(lambda: logger.info("OPEN_REFUND_COMPLETED"))
466
+ return response
467
+
468
+
469
+ # ==========================================
470
+ # 5️⃣ 消费记录查询:GET /api/open/transactions
471
+ # ==========================================
472
+ @router.get("/transactions")
473
+ async def open_list_transactions(
474
+ authorization: Optional[str] = Header(None, alias="Authorization"),
475
+ x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
476
+ page: int = Query(1, ge=1, description="页码(从 1 开始)"),
477
+ page_size: int = Query(20, ge=1, le=100, description="每页条数(1-100)"),
478
+ start_time: Optional[int] = Query(None, description="起始时间(unix 秒)"),
479
+ end_time: Optional[int] = Query(None, description="结束时间(unix 秒)"),
480
+ tx_type: Optional[str] = Query(None, description="交易类型,如 OPEN_DEDUCT / OPEN_REFUND"),
481
+ db: Session = Depends(get_db),
482
+ ):
483
+ """
484
+ 查询当前用户由当前插件产生的消费/退款记录。
485
+
486
+ 认证:Authorization: Bearer <user_token> + X-Plugin-Key
487
+ 安全:仅返回 source_plugin == 当前 plugin_id 的记录,防止跨插件读取。
488
+ """
489
+ plugin_info = _require_plugin(x_plugin_key, "verify", db)
490
+
491
+ if not authorization:
492
+ raise HTTPException(status_code=401, detail="缺少 Authorization Header")
493
+ parts = authorization.split(" ", 1)
494
+ if len(parts) != 2 or parts[0].lower() != "bearer":
495
+ raise HTTPException(status_code=401, detail="Authorization 格式错误,请使用 Bearer Token")
496
+ account = await _resolve_user_from_token(parts[1])
497
+
498
+ plugin_id = plugin_info["id"]
499
+
500
+ # 构造查询:按 source_plugin 过滤;兼容历史 NULL 数据走 related_account 兜底
501
+ q = db.query(Transaction).filter(
502
+ Transaction.account == account,
503
+ ((Transaction.source_plugin == plugin_id)
504
+ | ((Transaction.source_plugin.is_(None)) & (Transaction.related_account == plugin_id))),
505
+ )
506
+
507
+ if tx_type:
508
+ q = q.filter(Transaction.tx_type == tx_type)
509
+ if start_time is not None:
510
+ q = q.filter(Transaction.created_at >= datetime.datetime.utcfromtimestamp(int(start_time)))
511
+ if end_time is not None:
512
+ q = q.filter(Transaction.created_at <= datetime.datetime.utcfromtimestamp(int(end_time)))
513
+
514
+ total = q.count()
515
+ rows = (
516
+ q.order_by(Transaction.created_at.desc())
517
+ .offset((page - 1) * page_size)
518
+ .limit(page_size)
519
+ .all()
520
+ )
521
+
522
+ items = []
523
+ for t in rows:
524
+ created_ts = int(t.created_at.timestamp()) if t.created_at else 0
525
+ # reason 从 description 中解析(去掉 [插件名] 前缀),兼容旧记录
526
+ reason = (t.description or "").replace(f"[{plugin_info['name']}] ", "").strip() or None
527
+ items.append({
528
+ "id": t.tx_id,
529
+ "type": t.tx_type,
530
+ "amount": int(t.amount or 0),
531
+ "reason": reason,
532
+ "reference_id": t.item_id,
533
+ "created_at": created_ts,
534
+ "description": t.description or "",
535
+ })
536
+
537
+ logger.info(f"OPEN_LIST_TX | plugin={plugin_id} | page={page}/{page_size} | total={total}")
538
+ return {
539
+ "success": True,
540
+ "transactions": items,
541
+ "total": total,
542
+ "page": page,
543
+ "page_size": page_size,
544
+ }
545
+
546
+
547
+ # ==========================================
548
+ # 6️⃣ 消费汇总:GET /api/open/usage-summary
549
+ # ==========================================
550
+ @router.get("/usage-summary")
551
+ async def open_usage_summary(
552
+ x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
553
+ account: Optional[str] = Query(None, description="指定用户账号,不传则统计所有用户"),
554
+ period: str = Query("daily", description="聚合粒度:daily / monthly / total"),
555
+ start_time: Optional[int] = Query(None, description="起始时间(unix 秒)"),
556
+ end_time: Optional[int] = Query(None, description="结束时间(unix 秒)"),
557
+ db: Session = Depends(get_db),
558
+ ):
559
+ """
560
+ 本插件维度的消费汇总统计。
561
+
562
+ 返回:总扣款 / 总退款 / 总笔数 / 净消费 + 按 period 分桶的明细。
563
+ 安全:仅统计 source_plugin == 当前 plugin_id 的记录。
564
+ """
565
+ plugin_info = _require_plugin(x_plugin_key, "verify", db)
566
+ plugin_id = plugin_info["id"]
567
+
568
+ period = (period or "daily").lower()
569
+ if period not in ("daily", "monthly", "total"):
570
+ raise HTTPException(status_code=400, detail="period 仅支持 daily / monthly / total")
571
+
572
+ base = db.query(Transaction).filter(
573
+ ((Transaction.source_plugin == plugin_id)
574
+ | ((Transaction.source_plugin.is_(None)) & (Transaction.related_account == plugin_id))),
575
+ Transaction.tx_type.in_(["OPEN_DEDUCT", "OPEN_REFUND"]),
576
+ )
577
+ if account:
578
+ base = base.filter(Transaction.account == account)
579
+ if start_time is not None:
580
+ base = base.filter(Transaction.created_at >= datetime.datetime.utcfromtimestamp(int(start_time)))
581
+ if end_time is not None:
582
+ base = base.filter(Transaction.created_at <= datetime.datetime.utcfromtimestamp(int(end_time)))
583
+
584
+ rows = base.all()
585
+
586
+ total_deduct = 0
587
+ total_refund = 0
588
+ total_count = len(rows)
589
+ bucket: dict = {} # key -> {"deduct":int,"refund":int,"count":int}
590
+
591
+ for t in rows:
592
+ amt = int(t.amount or 0)
593
+ if t.tx_type == "OPEN_DEDUCT":
594
+ total_deduct += abs(amt)
595
+ elif t.tx_type == "OPEN_REFUND":
596
+ total_refund += abs(amt)
597
+
598
+ if period == "total":
599
+ continue
600
+ if not t.created_at:
601
+ continue
602
+ if period == "daily":
603
+ key = t.created_at.strftime("%Y-%m-%d")
604
+ else: # monthly
605
+ key = t.created_at.strftime("%Y-%m")
606
+
607
+ b = bucket.setdefault(key, {"deduct": 0, "refund": 0, "count": 0})
608
+ if t.tx_type == "OPEN_DEDUCT":
609
+ b["deduct"] += abs(amt)
610
+ elif t.tx_type == "OPEN_REFUND":
611
+ b["refund"] += abs(amt)
612
+ b["count"] += 1
613
+
614
+ breakdown = []
615
+ if period != "total":
616
+ for key in sorted(bucket.keys(), reverse=True):
617
+ v = bucket[key]
618
+ label = "date" if period == "daily" else "month"
619
+ breakdown.append({
620
+ label: key,
621
+ "deduct": v["deduct"],
622
+ "refund": v["refund"],
623
+ "count": v["count"],
624
+ })
625
+
626
+ logger.info(f"OPEN_USAGE_SUMMARY | plugin={plugin_id} | period={period} | tx_count={total_count}")
627
+ return {
628
+ "success": True,
629
+ "plugin_id": plugin_id,
630
+ "plugin_name": plugin_info.get("name", plugin_id),
631
+ "summary": {
632
+ "total_deduct": total_deduct,
633
+ "total_refund": total_refund,
634
+ "total_transactions": total_count,
635
+ "net_consumption": total_deduct - total_refund,
636
+ },
637
+ "breakdown": breakdown,
638
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
router_posts.py CHANGED
@@ -1,3 +1,4 @@
 
1
  # 云端Space代码/router_posts.py
2
  # ==========================================
3
  # 💬 讨论区API路由(小红书风格图文社区)
@@ -9,24 +10,41 @@ from fastapi import APIRouter, HTTPException, Depends, Request
9
  from sqlalchemy.orm import Session
10
  from models import PostCreate, PostUpdate, RatingRequest
11
  import 数据库连接 as db
 
12
  from 安全认证 import require_auth
13
  from db_utils import record_view, sort_cache
14
  from database_sql import get_db
15
  from models_sql import Wallet, Transaction
16
  from notifications import add_notification
 
 
17
  import time
 
 
18
  import uuid
19
  import hashlib
20
  import datetime
21
  import logging
22
 
23
  # 🔒 频次限制(与 router_users_auth.py / router_wallet.py / router_prompts.py 同一写法)
24
- from slowapi import Limiter
25
- from slowapi.util import get_remote_address
26
- limiter = Limiter(key_func=get_remote_address)
27
 
28
  router = APIRouter()
29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  # ==========================================
31
  # 📝 帖子CRUD接口
32
  # ==========================================
@@ -87,6 +105,7 @@ async def get_posts(page: int = 1, limit: int = 20, sort: str = "latest"):
87
  post_data.pop("viewed_by", None)
88
  result.append(post_data)
89
 
 
90
  return {
91
  "status": "success",
92
  "data": result,
@@ -115,6 +134,7 @@ async def get_my_posts(current_user: str = Depends(require_auth)):
115
  post_data.pop("viewed_by", None)
116
  result.append(post_data)
117
 
 
118
  return {
119
  "status": "success",
120
  "data": result
@@ -141,6 +161,7 @@ async def get_post_detail(post_id: str):
141
  }
142
  # 过滤敏感字段
143
  post_data.pop("viewed_by", None)
 
144
  return {
145
  "status": "success",
146
  "data": post_data
@@ -149,13 +170,16 @@ async def get_post_detail(post_id: str):
149
  raise HTTPException(status_code=404, detail="帖子不存在")
150
 
151
  @router.post("/api/posts")
152
- @limiter.limit("5/minute") # 🔒 安全限流:防止刷帖洗版(一次发布含多图上传,但建帖只调一次)
153
  async def create_post(request: Request, post: PostCreate, current_user: str = Depends(require_auth)):
154
  """
155
  发布帖子
156
  """
157
- posts_db = db.load_data("posts.json", default_data=[])
158
-
 
 
 
159
  # 限制图片数量
160
  images = (post.images or [])[:9]
161
  post_type = post.post_type if hasattr(post, "post_type") else "image"
@@ -166,7 +190,7 @@ async def create_post(request: Request, post: PostCreate, current_user: str = De
166
  images = []
167
 
168
  new_post = {
169
- "id": f"post_{int(time.time())}_{uuid.uuid4().hex[:6]}",
170
  "title": post.title,
171
  "content": post.content,
172
  "cover_image": post.cover_image,
@@ -193,91 +217,43 @@ async def create_post(request: Request, post: PostCreate, current_user: str = De
193
  "rated_by": {}
194
  }
195
 
196
- posts_db.insert(0, new_post)
197
- db.save_data("posts.json", posts_db)
198
- # 🗂️ 清除排序缓存
199
- sort_cache.invalidate("posts:")
200
 
201
  return {"status": "success", "data": new_post}
202
 
203
  @router.put("/api/posts/{post_id}")
204
- async def update_post(post_id: str, update_data: PostUpdate, current_user: str = Depends(require_auth)):
 
205
  """
206
  更新帖子(仅作者可操作,原子操作,并发安全)
207
  """
208
- result_holder = {}
209
-
210
- def updater(data):
211
- for post in data:
212
- if post["id"] == post_id:
213
- if post.get("author") != current_user:
214
- result_holder["error"] = "forbidden"
215
- return False
216
- # 更新字段
217
- if update_data.title is not None:
218
- post["title"] = update_data.title
219
- if update_data.content is not None:
220
- post["content"] = update_data.content
221
- if update_data.cover_image is not None:
222
- post["cover_image"] = update_data.cover_image
223
- if update_data.images is not None:
224
- post["images"] = update_data.images[:9]
225
- if update_data.is_original is not None:
226
- post["is_original"] = update_data.is_original # 🎨 更新原创作品标记
227
- if update_data.post_type is not None:
228
- post["post_type"] = update_data.post_type
229
- # 如果切换为视频帖,清空图片列表
230
- if update_data.post_type == "video":
231
- post["images"] = []
232
- if update_data.video_url is not None:
233
- post["video_url"] = update_data.video_url
234
- result_holder["post"] = post
235
- return True
236
- result_holder["error"] = "not_found"
237
- return False
238
-
239
- db.atomic_update("posts.json", updater, default_data=[])
240
-
241
- if result_holder.get("error") == "forbidden":
242
- raise HTTPException(status_code=403, detail="无权修改他人帖子")
243
- if result_holder.get("error") == "not_found":
244
- raise HTTPException(status_code=404, detail="帖子不存在")
245
-
246
- # 🗂️ 清除排序缓存
247
- sort_cache.invalidate("posts:")
248
  return {"status": "success"}
249
 
250
  @router.delete("/api/posts/{post_id}")
251
- async def delete_post(post_id: str, current_user: str = Depends(require_auth)):
252
- """
253
- 删除帖子(仅作者可操作)
254
- """
255
- posts_db = db.load_data("posts.json", default_data=[])
256
-
257
- for i, post in enumerate(posts_db):
258
- if post["id"] == post_id:
259
- if post.get("author") != current_user:
260
- raise HTTPException(status_code=403, detail="无权删除他人帖子")
261
-
262
- posts_db.pop(i)
263
- db.save_data("posts.json", posts_db)
264
- # 🗂️ 清除排序缓存
265
- sort_cache.invalidate("posts:")
266
- return {"status": "success"}
267
-
268
- raise HTTPException(status_code=404, detail="帖子不存在")
269
 
270
  # ==========================================
271
  # ❤️ 互动接口(点赞/收藏)
272
  # ==========================================
273
 
274
  @router.post("/api/posts/{post_id}/rating")
275
- async def rate_post(post_id: str, request: RatingRequest, current_user: str = Depends(require_auth)):
 
276
  """
277
  为帖子评分(原子操作,并发安全)
278
  ⭐ score: 1-5
279
  """
280
- score = request.score
281
  if score < 1 or score > 5:
282
  raise HTTPException(status_code=400, detail="评分必须在1-5之间")
283
 
@@ -346,7 +322,8 @@ async def rate_post(post_id: str, request: RatingRequest, current_user: str = De
346
 
347
 
348
  @router.post("/api/posts/{post_id}/like")
349
- async def toggle_like(post_id: str, current_user: str = Depends(require_auth)):
 
350
  """
351
  点赞/取消点赞(原子操作,并发安全)
352
  """
@@ -380,7 +357,8 @@ async def toggle_like(post_id: str, current_user: str = Depends(require_auth)):
380
  return result_container[0]
381
 
382
  @router.post("/api/posts/{post_id}/favorite")
383
- async def toggle_favorite(post_id: str, current_user: str = Depends(require_auth)):
 
384
  """
385
  收藏/取消收藏(原子操作,并发安全)
386
  """
@@ -425,7 +403,8 @@ def calculate_tx_hash(tx_id, account, tx_type, amount, prev_hash):
425
  return hashlib.sha256(data.encode()).hexdigest()
426
 
427
  @router.post("/api/posts/{post_id}/tip")
428
- async def tip_post(post_id: str, amount: int, is_anon: bool = False, current_user: str = Depends(require_auth), db_session: Session = Depends(get_db)):
 
429
  """
430
  打赏帖子(原子操作,并发安全)- 使用 SQL Wallet 系统
431
  """
@@ -451,6 +430,8 @@ async def tip_post(post_id: str, amount: int, is_anon: bool = False, current_use
451
  # 💳 使用 SQL Wallet 系统处理余额转账(先扣款)
452
  try:
453
 
 
 
454
  # 🔒 P1幂等性防护:检查最近5秒内是否存在相同交易
455
  recent_cutoff = datetime.datetime.utcnow() - datetime.timedelta(seconds=5)
456
  duplicate_tx = db_session.query(Transaction).filter(
@@ -459,20 +440,14 @@ async def tip_post(post_id: str, amount: int, is_anon: bool = False, current_use
459
  Transaction.amount == -amount,
460
  Transaction.related_account == author,
461
  Transaction.created_at >= recent_cutoff
462
- ).first()
463
  if duplicate_tx:
 
464
  return {"status": "success", "message": "打赏已处理(重复请求)"}
465
 
466
- # 🔒 并发安全:使用悲观锁获取双方钱包
467
- tipper_wallet = db_session.query(Wallet).filter(Wallet.account == current_user).with_for_update().first()
468
- author_wallet = db_session.query(Wallet).filter(Wallet.account == author).with_for_update().first()
469
-
470
  if not tipper_wallet or tipper_wallet.balance < amount:
471
  raise HTTPException(status_code=400, detail="余额不足")
472
- if not author_wallet:
473
- # 如果作者钱包不存在,创建一个
474
- author_wallet = Wallet(account=author, balance=0, earn_balance=0, tip_balance=0, frozen_balance=0)
475
- db_session.add(author_wallet)
476
 
477
  # 执行转账
478
  tipper_wallet.balance -= amount
@@ -533,8 +508,17 @@ async def tip_post(post_id: str, amount: int, is_anon: bool = False, current_use
533
  db_session.commit()
534
 
535
  # 📝 审计日志
536
- logger.info(f"POST_TIP | from={current_user} | to={author} | amount={amount} | post={post_id} | anon={is_anon}")
537
 
 
 
 
 
 
 
 
 
 
538
  # 🔔 打赏通知(考虑匿名)
539
  if not is_anon:
540
  add_notification(author, {
@@ -552,18 +536,10 @@ async def tip_post(post_id: str, amount: int, is_anon: bool = False, current_use
552
  "target_item_title": "",
553
  "content": f"您收到了一份 {amount} 积分的匿名帖子打赏"
554
  })
555
-
556
- # 🗂️ 清除排序缓存(打赏可能影响排序)
557
- sort_cache.invalidate("posts:")
558
-
559
- except HTTPException:
560
- db_session.rollback()
561
- raise
562
- except Exception as e:
563
- db_session.rollback()
564
- logger.error(f"POST_TIP_ERROR | from={current_user} | post={post_id} | amount={amount} | error={str(e)}")
565
- raise HTTPException(status_code=500, detail="打赏处理失败,请稍后重试")
566
 
 
 
 
567
  # 🏆 扣款成功后再写打赏榜(写榜失败仅记录日志,不影响资金安全)
568
  def board_updater(data):
569
  for post in data:
@@ -581,7 +557,7 @@ async def tip_post(post_id: str, amount: int, is_anon: bool = False, current_use
581
  try:
582
  db.atomic_update("posts.json", board_updater, default_data=[])
583
  except Exception as e:
584
- logger.error(f"POST_TIP_BOARD_ERROR | post={post_id} | error={str(e)}")
585
 
586
  return {"status": "success", "message": f"成功打赏 {amount} 积分"}
587
 
@@ -616,7 +592,8 @@ async def get_post_comments(post_id: str):
616
  return {"status": "success", "data": result}
617
 
618
  @router.post("/api/posts/{post_id}/comments")
619
- async def add_post_comment(post_id: str, content: str, current_user: str = Depends(require_auth)):
 
620
  """
621
  添加帖子评论
622
  """
@@ -624,7 +601,6 @@ async def add_post_comment(post_id: str, content: str, current_user: str = Depen
624
  raise HTTPException(status_code=400, detail="评论内容不能为空")
625
 
626
  posts_db = db.load_data("posts.json", default_data=[])
627
- comments_db = db.load_data("comments.json", default_data={})
628
 
629
  # 检查帖子是否存在
630
  post_exists = any(p["id"] == post_id for p in posts_db)
@@ -638,27 +614,18 @@ async def add_post_comment(post_id: str, content: str, current_user: str = Depen
638
  "created_at": int(time.time())
639
  }
640
 
641
- # comments_db 是 {item_id: [comments]} 格式
642
- post_comments = comments_db.get(post_id, [])
643
- post_comments.insert(0, new_comment)
644
- comments_db[post_id] = post_comments
645
- db.save_data("comments.json", comments_db)
646
-
647
  # 🗂️ 清除排序缓存(评论数变化可能影响排序)
648
  sort_cache.invalidate("posts:")
649
 
650
- # 更新帖子评论数
651
- for post in posts_db:
652
- if post["id"] == post_id:
653
- post["comments"] = post.get("comments", 0) + 1
654
- break
655
- db.save_data("posts.json", posts_db)
656
-
657
  return {"status": "success", "data": new_comment}
658
 
659
 
660
  @router.post("/api/posts/{post_id}/view")
661
- async def record_post_view(post_id: str, current_user: str = Depends(require_auth)):
 
662
  """
663
  记录帖子访问量
664
  👁️ 需要用户认证,每个用户只计算一次总访问量,日访问量每次调用都增加
 
1
+ from publication_identity import publication_identity
2
  # 云端Space代码/router_posts.py
3
  # ==========================================
4
  # 💬 讨论区API路由(小红书风格图文社区)
 
10
  from sqlalchemy.orm import Session
11
  from models import PostCreate, PostUpdate, RatingRequest
12
  import 数据库连接 as db
13
+ from comment_store import append_comment, increment_comment_count, attach_comment_counts
14
  from 安全认证 import require_auth
15
  from db_utils import record_view, sort_cache
16
  from database_sql import get_db
17
  from models_sql import Wallet, Transaction
18
  from notifications import add_notification
19
+ from tip_transaction import lock_tip_wallets
20
+ from tip_store import run_tip_effect
21
  import time
22
+ import asyncio
23
+ from post_store import publish_post, edit_post, delete_post as remove_post, PostStoreError
24
  import uuid
25
  import hashlib
26
  import datetime
27
  import logging
28
 
29
  # 🔒 频次限制(与 router_users_auth.py / router_wallet.py / router_prompts.py 同一写法)
30
+ from rate_limit import shared_limit
 
 
31
 
32
  router = APIRouter()
33
 
34
+
35
+ async def _post_mutation(operation, *args):
36
+ try:
37
+ return await asyncio.to_thread(operation, *args)
38
+ except PostStoreError as error:
39
+ raise HTTPException(status_code=error.status_code, detail=error.message) from None
40
+
41
+
42
+ def _invalidate_posts():
43
+ try:
44
+ sort_cache.invalidate("posts:")
45
+ except Exception as error:
46
+ logging.getLogger(__name__).warning("帖子排序缓存失效失败: error_type=%s", type(error).__name__)
47
+
48
  # ==========================================
49
  # 📝 帖子CRUD接口
50
  # ==========================================
 
105
  post_data.pop("viewed_by", None)
106
  result.append(post_data)
107
 
108
+ attach_comment_counts(result)
109
  return {
110
  "status": "success",
111
  "data": result,
 
134
  post_data.pop("viewed_by", None)
135
  result.append(post_data)
136
 
137
+ attach_comment_counts(result)
138
  return {
139
  "status": "success",
140
  "data": result
 
161
  }
162
  # 过滤敏感字段
163
  post_data.pop("viewed_by", None)
164
+ attach_comment_counts([post_data])
165
  return {
166
  "status": "success",
167
  "data": post_data
 
170
  raise HTTPException(status_code=404, detail="帖子不存在")
171
 
172
  @router.post("/api/posts")
173
+ @shared_limit(5) # 🔒 安全限流:防止刷帖洗版(一次发布含多图上传,但建帖只调一次)
174
  async def create_post(request: Request, post: PostCreate, current_user: str = Depends(require_auth)):
175
  """
176
  发布帖子
177
  """
178
+ try:
179
+ stable_id = publication_identity('post', current_user, getattr(request, 'headers', {}).get('Idempotency-Key'))
180
+ except ValueError:
181
+ raise HTTPException(status_code=400, detail='发布请求身份无效') from None
182
+
183
  # 限制图片数量
184
  images = (post.images or [])[:9]
185
  post_type = post.post_type if hasattr(post, "post_type") else "image"
 
190
  images = []
191
 
192
  new_post = {
193
+ "id": stable_id or f"post_{int(time.time())}_{uuid.uuid4().hex}",
194
  "title": post.title,
195
  "content": post.content,
196
  "cover_image": post.cover_image,
 
217
  "rated_by": {}
218
  }
219
 
220
+ new_post = await _post_mutation(publish_post, db, new_post, stable_id is not None)
221
+ _invalidate_posts()
 
 
222
 
223
  return {"status": "success", "data": new_post}
224
 
225
  @router.put("/api/posts/{post_id}")
226
+ @shared_limit(10)
227
+ async def update_post(request: Request, post_id: str, update_data: PostUpdate, current_user: str = Depends(require_auth)):
228
  """
229
  更新帖子(仅作者可操作,原子操作,并发安全)
230
  """
231
+ changes = {key: getattr(update_data, key) for key in (
232
+ "title", "content", "cover_image", "images", "is_original", "post_type", "video_url")}
233
+ await _post_mutation(edit_post, db, post_id, current_user, changes)
234
+ _invalidate_posts()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  return {"status": "success"}
236
 
237
  @router.delete("/api/posts/{post_id}")
238
+ @shared_limit(10)
239
+ async def delete_post(request: Request, post_id: str, current_user: str = Depends(require_auth)):
240
+ """删除帖子;锁内确认最新作者,只删除目标主记录。"""
241
+ await _post_mutation(remove_post, db, post_id, current_user)
242
+ _invalidate_posts()
243
+ return {"status": "success"}
 
 
 
 
 
 
 
 
 
 
 
 
244
 
245
  # ==========================================
246
  # ❤️ 互动接口(点赞/收藏)
247
  # ==========================================
248
 
249
  @router.post("/api/posts/{post_id}/rating")
250
+ @shared_limit(20)
251
+ async def rate_post(request: Request, post_id: str, payload: RatingRequest, current_user: str = Depends(require_auth)):
252
  """
253
  为帖子评分(原子操作,并发安全)
254
  ⭐ score: 1-5
255
  """
256
+ score = payload.score
257
  if score < 1 or score > 5:
258
  raise HTTPException(status_code=400, detail="评分必须在1-5之间")
259
 
 
322
 
323
 
324
  @router.post("/api/posts/{post_id}/like")
325
+ @shared_limit(30)
326
+ async def toggle_like(request: Request, post_id: str, current_user: str = Depends(require_auth)):
327
  """
328
  点赞/取消点赞(原子操作,并发安全)
329
  """
 
357
  return result_container[0]
358
 
359
  @router.post("/api/posts/{post_id}/favorite")
360
+ @shared_limit(30)
361
+ async def toggle_favorite(request: Request, post_id: str, current_user: str = Depends(require_auth)):
362
  """
363
  收藏/取消收藏(原子操作,并发安全)
364
  """
 
403
  return hashlib.sha256(data.encode()).hexdigest()
404
 
405
  @router.post("/api/posts/{post_id}/tip")
406
+ @shared_limit(10)
407
+ async def tip_post(request: Request, post_id: str, amount: int, is_anon: bool = False, current_user: str = Depends(require_auth), db_session: Session = Depends(get_db)):
408
  """
409
  打赏帖子(原子操作,并发安全)- 使用 SQL Wallet 系统
410
  """
 
430
  # 💳 使用 SQL Wallet 系统处理余额转账(先扣款)
431
  try:
432
 
433
+ # 🔒 并发安全:使用悲观锁获取双方钱包
434
+ tipper_wallet, author_wallet = lock_tip_wallets(db_session, current_user, author)
435
  # 🔒 P1幂等性防护:检查最近5秒内是否存在相同交易
436
  recent_cutoff = datetime.datetime.utcnow() - datetime.timedelta(seconds=5)
437
  duplicate_tx = db_session.query(Transaction).filter(
 
440
  Transaction.amount == -amount,
441
  Transaction.related_account == author,
442
  Transaction.created_at >= recent_cutoff
443
+ ).with_for_update().first()
444
  if duplicate_tx:
445
+ db_session.rollback() # 重复请求释放锁,不执行任何后续操作
446
  return {"status": "success", "message": "打赏已处理(重复请求)"}
447
 
 
 
 
 
448
  if not tipper_wallet or tipper_wallet.balance < amount:
449
  raise HTTPException(status_code=400, detail="余额不足")
450
+
 
 
 
451
 
452
  # 执行转账
453
  tipper_wallet.balance -= amount
 
508
  db_session.commit()
509
 
510
  # 📝 审计日志
511
+ logger.info(f"POST_TIP | post={post_id}")
512
 
513
+ except HTTPException:
514
+ db_session.rollback()
515
+ raise
516
+ except Exception as e:
517
+ db_session.rollback()
518
+ logger.error(f"POST_TIP_ERROR | post={post_id} | error_type={type(e).__name__}")
519
+ raise HTTPException(status_code=500, detail="打赏处理失败,请稍后重试")
520
+
521
+ def notify_tip():
522
  # 🔔 打赏通知(考虑匿名)
523
  if not is_anon:
524
  add_notification(author, {
 
536
  "target_item_title": "",
537
  "content": f"您收到了一份 {amount} 积分的匿名帖子打赏"
538
  })
 
 
 
 
 
 
 
 
 
 
 
539
 
540
+ run_tip_effect(notify_tip)
541
+ run_tip_effect(lambda: sort_cache.invalidate("posts:"))
542
+
543
  # 🏆 扣款成功后再写打赏榜(写榜失败仅记录日志,不影响资金安全)
544
  def board_updater(data):
545
  for post in data:
 
557
  try:
558
  db.atomic_update("posts.json", board_updater, default_data=[])
559
  except Exception as e:
560
+ logger.error(f"POST_TIP_BOARD_ERROR | post={post_id} | error_type={type(e).__name__}")
561
 
562
  return {"status": "success", "message": f"成功打赏 {amount} 积分"}
563
 
 
592
  return {"status": "success", "data": result}
593
 
594
  @router.post("/api/posts/{post_id}/comments")
595
+ @shared_limit(10)
596
+ async def add_post_comment(request: Request, post_id: str, content: str, current_user: str = Depends(require_auth)):
597
  """
598
  添加帖子评论
599
  """
 
601
  raise HTTPException(status_code=400, detail="评论内容不能为空")
602
 
603
  posts_db = db.load_data("posts.json", default_data=[])
 
604
 
605
  # 检查帖子是否存在
606
  post_exists = any(p["id"] == post_id for p in posts_db)
 
614
  "created_at": int(time.time())
615
  }
616
 
617
+ append_comment(post_id, new_comment, prepend=True)
618
+ increment_comment_count("posts.json", post_id)
619
+
 
 
 
620
  # 🗂️ 清除排序缓存(评论数变化可能影响排序)
621
  sort_cache.invalidate("posts:")
622
 
 
 
 
 
 
 
 
623
  return {"status": "success", "data": new_comment}
624
 
625
 
626
  @router.post("/api/posts/{post_id}/view")
627
+ @shared_limit(60)
628
+ async def record_post_view(request: Request, post_id: str, current_user: str = Depends(require_auth)):
629
  """
630
  记录帖子访问量
631
  👁️ 需要用户认证,每个用户只计算一次总访问量,日访问量每次调用都增加
router_prompts.py CHANGED
@@ -1,3 +1,4 @@
 
1
  # 云端Space代码/router_prompts.py
2
  # ==========================================
3
  # ✨ 提示词市场API路由(图像/视频提示词)
@@ -14,23 +15,29 @@
14
 
15
  from fastapi import APIRouter, HTTPException, Depends, Request
16
  from sqlalchemy.orm import Session
17
- from models import PromptCreate, PromptUpdate, PromptCategoryAdd
 
18
  import 数据库连接 as db
 
19
  from 安全认证 import require_auth, optional_auth, is_admin
20
  from db_utils import record_view, sort_cache
21
  from database_sql import get_db
22
  from models_sql import Wallet, Transaction, Ownership
23
  from notifications import add_notification
 
 
 
24
  import time
 
 
 
25
  import uuid
26
  import hashlib
27
  import datetime
28
  import logging
29
 
30
  # 🔒 P0安全优化:API限流(与 router_wallet.py 保持一致)
31
- from slowapi import Limiter
32
- from slowapi.util import get_remote_address
33
- limiter = Limiter(key_func=get_remote_address)
34
 
35
  router = APIRouter()
36
 
@@ -95,35 +102,18 @@ def _sanitize_tags(tags) -> list:
95
  return cleaned
96
 
97
 
98
- def _load_categories() -> dict:
99
- """读取分类数据,不存在时用内置默认值初始化并持久化"""
100
- categories = db.load_data("prompt_categories.json", default_data=None)
101
- if not isinstance(categories, dict) or "image" not in categories or "video" not in categories:
102
- categories = {
103
- "image": list(DEFAULT_CATEGORIES["image"]),
104
- "video": list(DEFAULT_CATEGORIES["video"])
105
- }
106
- db.save_data("prompt_categories.json", categories)
107
- # 存量分类文件补全 music 分组(历史数据只有 image/video 键)
108
- if "music" not in categories:
109
- categories["music"] = list(DEFAULT_CATEGORIES["music"])
110
- db.save_data("prompt_categories.json", categories)
111
- return categories
112
-
113
-
114
- def _apply_effective_price(prompt: dict) -> None:
115
- """检查 pending_price 是否已过生效时间,若是则更新 price 字段供前端显示(与 router_items.py 同构)"""
116
- pending_price = prompt.get("pending_price")
117
- pending_effective = prompt.get("pending_price_effective_at")
118
- if pending_price is not None and pending_effective:
119
- try:
120
- effective_time = datetime.datetime.fromisoformat(pending_effective)
121
- if datetime.datetime.now() >= effective_time:
122
- prompt["price"] = pending_price
123
- prompt["pending_price"] = None
124
- prompt["pending_price_effective_at"] = None
125
- except (ValueError, TypeError):
126
- pass
127
 
128
 
129
  def _user_owns_prompt(db_session: Session, account: str, prompt_id: str) -> bool:
@@ -161,14 +151,14 @@ async def get_prompt_categories(prompt_type: str = None):
161
  获取提示词分类列表
162
  - prompt_type: image / video / music,不传则返回全部
163
  """
164
- categories = _load_categories()
165
  if prompt_type in VALID_PROMPT_TYPES:
166
  return {"status": "success", "data": categories.get(prompt_type, [])}
167
  return {"status": "success", "data": categories}
168
 
169
 
170
  @router.post("/api/prompt-categories")
171
- @limiter.limit("5/minute") # 🔒 限流:防止分类刷屏
172
  async def add_prompt_category(request: Request, body: PromptCategoryAdd, current_user: str = Depends(require_auth)):
173
  """
174
  添加提示词分类(仅管理员,入口在云端管理界面,重名去重)
@@ -184,38 +174,13 @@ async def add_prompt_category(request: Request, body: PromptCategoryAdd, current
184
  if len(name) > MAX_CATEGORY_LEN:
185
  raise HTTPException(status_code=400, detail=f"分类名称不能超过{MAX_CATEGORY_LEN}个字")
186
 
187
- result_holder = {}
188
-
189
- def updater(data):
190
- if not isinstance(data, dict):
191
- data = {
192
- "image": list(DEFAULT_CATEGORIES["image"]),
193
- "video": list(DEFAULT_CATEGORIES["video"])
194
- }
195
- cat_list = data.get(body.prompt_type, [])
196
- if name in cat_list:
197
- result_holder["error"] = "duplicate"
198
- return
199
- if len(cat_list) >= MAX_CATEGORY_PER_TYPE:
200
- result_holder["error"] = "too_many"
201
- return
202
- cat_list.append(name)
203
- data[body.prompt_type] = cat_list
204
- result_holder["data"] = cat_list
205
-
206
- db.atomic_update("prompt_categories.json", updater, default_data={})
207
-
208
- if result_holder.get("error") == "duplicate":
209
- raise HTTPException(status_code=400, detail="该分类已存在")
210
- if result_holder.get("error") == "too_many":
211
- raise HTTPException(status_code=400, detail=f"分类数量已达上限({MAX_CATEGORY_PER_TYPE})")
212
-
213
- logger.info(f"PROMPT_CATEGORY_ADD | user={current_user} | type={body.prompt_type} | name={name}")
214
- return {"status": "success", "data": result_holder["data"]}
215
 
216
 
217
  @router.delete("/api/prompt-categories")
218
- @limiter.limit("5/minute") # 🔒 限流:与添加分类一致
219
  async def delete_prompt_category(request: Request, prompt_type: str = None, name: str = None,
220
  current_user: str = Depends(require_auth)):
221
  """
@@ -233,27 +198,9 @@ async def delete_prompt_category(request: Request, prompt_type: str = None, name
233
  if not name:
234
  raise HTTPException(status_code=400, detail="分类名称不能为空")
235
 
236
- result_holder = {}
237
-
238
- def updater(data):
239
- if not isinstance(data, dict):
240
- result_holder["error"] = "not_found"
241
- return
242
- cat_list = data.get(prompt_type, [])
243
- if name not in cat_list:
244
- result_holder["error"] = "not_found"
245
- return
246
- cat_list.remove(name)
247
- data[prompt_type] = cat_list
248
- result_holder["data"] = cat_list
249
-
250
- db.atomic_update("prompt_categories.json", updater, default_data={})
251
-
252
- if result_holder.get("error") == "not_found":
253
- raise HTTPException(status_code=404, detail="分类不存在")
254
-
255
- logger.info(f"PROMPT_CATEGORY_DELETE | user={current_user} | type={prompt_type} | name={name}")
256
- return {"status": "success", "data": result_holder["data"]}
257
 
258
 
259
  # ==========================================
@@ -285,7 +232,7 @@ async def get_prompts(prompt_type: str = None, category: str = None,
285
  - sort: latest / likes / favorites / views / daily_views / tips
286
  ⚠️ 列表接口永不下发 prompt_text(付费内容脱敏)
287
  """
288
- prompts_db = db.load_data("prompts.json", default_data=[])
289
  users_db = db.load_data("users.json", default_data={})
290
  user_map = users_db
291
 
@@ -322,13 +269,11 @@ async def get_prompts(prompt_type: str = None, category: str = None,
322
 
323
  # 附加作者信息,并过滤敏感字段
324
  result = []
325
- price_updated = False
326
  for prompt in paged_prompts:
327
- # 💰 检查延迟价格是否已生效(与资源列表同构)
328
- old_price = prompt.get("price", 0)
329
- _apply_effective_price(prompt)
330
- if prompt.get("price", 0) != old_price:
331
- price_updated = True
332
  author_info = user_map.get(prompt.get("author"), {})
333
  prompt_data = {
334
  **prompt,
@@ -340,9 +285,7 @@ async def get_prompts(prompt_type: str = None, category: str = None,
340
  prompt_data.pop("viewed_by", None)
341
  result.append(prompt_data)
342
 
343
- if price_updated:
344
- db.save_data("prompts.json", prompts_db)
345
-
346
  return {
347
  "status": "success",
348
  "data": result,
@@ -358,26 +301,22 @@ async def get_my_prompts(current_user: str = Depends(require_auth)):
358
  获取我发布的提示词(个人中心用)
359
  作者本人可见自己的 prompt_text
360
  """
361
- prompts_db = db.load_data("prompts.json", default_data=[])
362
 
363
  my_prompts = [p for p in prompts_db if p.get("author") == current_user]
364
  my_prompts = sorted(my_prompts, key=lambda x: x.get("created_at", 0), reverse=True)
365
 
366
  result = []
367
- price_updated = False
368
  for prompt in my_prompts:
369
- # 💰 检查延迟价格是否已生效
370
- old_price = prompt.get("price", 0)
371
- _apply_effective_price(prompt)
372
- if prompt.get("price", 0) != old_price:
373
- price_updated = True
374
  prompt_data = dict(prompt)
375
  prompt_data.pop("viewed_by", None)
376
  result.append(prompt_data)
377
 
378
- if price_updated:
379
- db.save_data("prompts.json", prompts_db)
380
-
381
  return {"status": "success", "data": result}
382
 
383
 
@@ -388,42 +327,54 @@ async def get_prompt_detail(prompt_id: str, current_user: str = Depends(optional
388
  获取提示词详情
389
  🔒 仅当 ①免费 ②作者本人 ③已购买 时下发 prompt_text
390
  """
391
- prompts_db = db.load_data("prompts.json", default_data=[])
 
 
 
 
 
392
  users_db = db.load_data("users.json", default_data={})
393
  user_map = users_db
394
 
395
  for prompt in prompts_db:
396
- if prompt["id"] == prompt_id:
397
- # 💰 检查延迟价格是否已生效
398
- old_price = prompt.get("price", 0)
399
- _apply_effective_price(prompt)
400
- if prompt.get("price", 0) != old_price:
401
- db.save_data("prompts.json", prompts_db)
402
 
403
  author_info = user_map.get(prompt.get("author"), {})
404
  prompt_data = {
405
  **prompt,
 
406
  "author_name": author_info.get("name", prompt.get("author")),
407
  "author_avatar": author_info.get("avatarDataUrl", "")
408
  }
409
  prompt_data.pop("viewed_by", None)
410
 
411
- price = int(prompt.get("price", 0) or 0)
412
  is_author = current_user and current_user == prompt.get("author")
413
  can_view = price <= 0 or is_author or _user_owns_prompt(db_session, current_user, prompt_id)
414
  prompt_data["owned"] = bool(can_view)
 
415
  _mask_prompt_text(prompt_data, can_view)
416
 
 
417
  return {"status": "success", "data": prompt_data}
418
 
419
  raise HTTPException(status_code=404, detail="提示词不存在")
420
 
421
 
422
  @router.post("/api/prompts")
423
- async def create_prompt(prompt: PromptCreate, current_user: str = Depends(require_auth)):
 
424
  """
425
  发布提示词
426
  """
 
 
 
 
 
427
  # 参数校验
428
  if prompt.prompt_type not in VALID_PROMPT_TYPES:
429
  raise HTTPException(status_code=400, detail="提示词类型必须是 image、video 或 music")
@@ -443,12 +394,12 @@ async def create_prompt(prompt: PromptCreate, current_user: str = Depends(requir
443
  raise HTTPException(status_code=400, detail="请选择分类")
444
 
445
  # 分类必须存在于分类库
446
- categories = _load_categories()
447
  if category not in categories.get(prompt.prompt_type, []):
448
  raise HTTPException(status_code=400, detail="所选分类不存在")
449
 
450
- price = int(prompt.price or 0)
451
- if price < 0 or price > MAX_PRICE:
452
  raise HTTPException(status_code=400, detail=f"定价必须在0-{MAX_PRICE}之间")
453
 
454
  media_type = prompt.media_type if prompt.media_type in ("image", "video") else "image"
@@ -466,7 +417,7 @@ async def create_prompt(prompt: PromptCreate, current_user: str = Depends(requir
466
  audio_url = None
467
 
468
  new_prompt = {
469
- "id": f"prompt_{int(time.time())}_{uuid.uuid4().hex[:6]}",
470
  "prompt_type": prompt.prompt_type,
471
  "category": category,
472
  "title": title,
@@ -495,106 +446,18 @@ async def create_prompt(prompt: PromptCreate, current_user: str = Depends(requir
495
  "tags": _sanitize_tags(prompt.tags)
496
  }
497
 
498
- prompts_db = db.load_data("prompts.json", default_data=[])
499
- prompts_db.insert(0, new_prompt)
500
- db.save_data("prompts.json", prompts_db)
501
- # 🗂️ 清除排序缓存
502
- sort_cache.invalidate("prompts:")
503
 
504
  return {"status": "success", "data": new_prompt}
505
 
506
 
507
  @router.put("/api/prompts/{prompt_id}")
508
- async def update_prompt(prompt_id: str, update_data: PromptUpdate, current_user: str = Depends(require_auth)):
 
509
  """
510
  更新提示词(仅作者可操作,原子操作,并发安全)
511
  """
512
- result_holder = {}
513
-
514
- def updater(data):
515
- for prompt in data:
516
- if prompt["id"] == prompt_id:
517
- if prompt.get("author") != current_user:
518
- result_holder["error"] = "forbidden"
519
- return False
520
- # 更新字段
521
- if update_data.title is not None:
522
- title = update_data.title.strip()
523
- if not title or len(title) > MAX_TITLE_LEN:
524
- result_holder["error"] = "invalid_title"
525
- return False
526
- prompt["title"] = title
527
- if update_data.content is not None:
528
- content = update_data.content.strip()
529
- if len(content) > MAX_CONTENT_LEN:
530
- result_holder["error"] = "invalid_content"
531
- return False
532
- prompt["content"] = content
533
- if update_data.prompt_text is not None:
534
- prompt_text = update_data.prompt_text.strip()
535
- if not prompt_text or len(prompt_text) > MAX_PROMPT_TEXT_LEN:
536
- result_holder["error"] = "invalid_prompt_text"
537
- return False
538
- prompt["prompt_text"] = prompt_text
539
- if update_data.category is not None:
540
- category = update_data.category.strip()
541
- categories = _load_categories()
542
- if category not in categories.get(prompt.get("prompt_type"), []):
543
- result_holder["error"] = "invalid_category"
544
- return False
545
- prompt["category"] = category
546
- if update_data.cover_image is not None:
547
- prompt["cover_image"] = update_data.cover_image
548
- if update_data.price is not None:
549
- price = int(update_data.price)
550
- if price < 0 or price > MAX_PRICE:
551
- result_holder["error"] = "invalid_price"
552
- return False
553
- current_price = int(prompt.get("price", 0) or 0)
554
- if current_price != price:
555
- # 设置待生效价格,24小时后生效(与资源改价机制同构;提示词不支持退款,无需禁购配套)
556
- effective_time = datetime.datetime.now() + datetime.timedelta(hours=24)
557
- prompt["pending_price"] = price
558
- prompt["pending_price_effective_at"] = effective_time.isoformat()
559
- # 不立即修改 price,等待24小时后生效
560
- else:
561
- # 价格未变,清除待生效价格
562
- prompt["pending_price"] = None
563
- prompt["pending_price_effective_at"] = None
564
- if update_data.media_type is not None:
565
- if update_data.media_type not in ("image", "video"):
566
- result_holder["error"] = "invalid_media_type"
567
- return False
568
- prompt["media_type"] = update_data.media_type
569
- if update_data.media_type == "image":
570
- # 图像类型不保留视频(与 create 逻辑一致)
571
- prompt["video_url"] = None
572
- if update_data.video_url is not None:
573
- prompt["video_url"] = update_data.video_url
574
- if update_data.video_url:
575
- prompt["media_type"] = "video"
576
- if update_data.audio_url is not None:
577
- # 🎵 音频仅音乐提示词保留;传空字符串/null 表示清除
578
- audio_url = (update_data.audio_url or "").strip() or None
579
- prompt["audio_url"] = audio_url if prompt.get("prompt_type") == "music" else None
580
- # 类型变更后补正关联字段(pydantic 无法区分未传与 null,统一在此收尾)
581
- if prompt.get("media_type") == "image":
582
- prompt["video_url"] = None
583
- else:
584
- prompt["images"] = []
585
- if update_data.images is not None:
586
- prompt["images"] = _sanitize_images(update_data.images)
587
- if update_data.is_original is not None:
588
- prompt["is_original"] = bool(update_data.is_original)
589
- if update_data.tags is not None:
590
- prompt["tags"] = _sanitize_tags(update_data.tags)
591
- result_holder["prompt"] = prompt
592
- return True
593
- result_holder["error"] = "not_found"
594
- return False
595
-
596
- db.atomic_update("prompts.json", updater, default_data=[])
597
-
598
  error_map = {
599
  "forbidden": (403, "无权修改他人提示词"),
600
  "not_found": (404, "提示词不存在"),
@@ -605,35 +468,87 @@ async def update_prompt(prompt_id: str, update_data: PromptUpdate, current_user:
605
  "invalid_price": (400, f"定价必须在0-{MAX_PRICE}之间"),
606
  "invalid_media_type": (400, "媒体类型必须是 image 或 video")
607
  }
608
- error = result_holder.get("error")
609
- if error:
610
- code, detail = error_map.get(error, (400, "参数错误"))
611
- raise HTTPException(status_code=code, detail=detail)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
612
 
613
- # 🗂️ 清除排序缓存
614
- sort_cache.invalidate("prompts:")
615
  return {"status": "success"}
616
 
617
 
618
  @router.delete("/api/prompts/{prompt_id}")
619
- async def delete_prompt(prompt_id: str, current_user: str = Depends(require_auth)):
 
620
  """
621
  删除提示词(仅作者可操作)
622
  """
623
- prompts_db = db.load_data("prompts.json", default_data=[])
624
-
625
- for i, prompt in enumerate(prompts_db):
626
- if prompt["id"] == prompt_id:
627
- if prompt.get("author") != current_user:
628
- raise HTTPException(status_code=403, detail="无权删除他人提示词")
629
-
630
- prompts_db.pop(i)
631
- db.save_data("prompts.json", prompts_db)
632
- # 🗂️ 清除排序缓存
633
- sort_cache.invalidate("prompts:")
634
- return {"status": "success"}
635
-
636
- raise HTTPException(status_code=404, detail="提示词不存在")
637
 
638
 
639
  # ==========================================
@@ -641,7 +556,8 @@ async def delete_prompt(prompt_id: str, current_user: str = Depends(require_auth
641
  # ==========================================
642
 
643
  @router.post("/api/prompts/{prompt_id}/like")
644
- async def toggle_like(prompt_id: str, current_user: str = Depends(require_auth)):
 
645
  """
646
  点赞/取消点赞(原子操作,并发安全)
647
  """
@@ -676,7 +592,8 @@ async def toggle_like(prompt_id: str, current_user: str = Depends(require_auth))
676
 
677
 
678
  @router.post("/api/prompts/{prompt_id}/favorite")
679
- async def toggle_favorite(prompt_id: str, current_user: str = Depends(require_auth)):
 
680
  """
681
  收藏/取消收藏(原子操作,并发安全)
682
  """
@@ -715,7 +632,8 @@ async def toggle_favorite(prompt_id: str, current_user: str = Depends(require_au
715
  # ==========================================
716
 
717
  @router.post("/api/prompts/{prompt_id}/tip")
718
- async def tip_prompt(prompt_id: str, amount: int, is_anon: bool = False,
 
719
  current_user: str = Depends(require_auth), db_session: Session = Depends(get_db)):
720
  """
721
  打赏提示词(原子操作,并发安全)- 使用 SQL Wallet 系统
@@ -742,6 +660,8 @@ async def tip_prompt(prompt_id: str, amount: int, is_anon: bool = False,
742
  # 💳 使用 SQL Wallet 系统处理余额转账(先扣款)
743
  try:
744
 
 
 
745
  # 🔒 P1幂等性防护:检查最近5秒内是否存在相同交易
746
  recent_cutoff = datetime.datetime.utcnow() - datetime.timedelta(seconds=5)
747
  duplicate_tx = db_session.query(Transaction).filter(
@@ -750,20 +670,13 @@ async def tip_prompt(prompt_id: str, amount: int, is_anon: bool = False,
750
  Transaction.amount == -amount,
751
  Transaction.related_account == author,
752
  Transaction.created_at >= recent_cutoff
753
- ).first()
754
  if duplicate_tx:
 
755
  return {"status": "success", "message": "打赏已处理(重复请求)"}
756
 
757
- # 🔒 并发安全:使用悲观锁获取双方钱包
758
- tipper_wallet = db_session.query(Wallet).filter(Wallet.account == current_user).with_for_update().first()
759
- author_wallet = db_session.query(Wallet).filter(Wallet.account == author).with_for_update().first()
760
-
761
  if not tipper_wallet or tipper_wallet.balance < amount:
762
  raise HTTPException(status_code=400, detail="余额不足")
763
- if not author_wallet:
764
- # 如果作者钱包不存在,创建一个
765
- author_wallet = Wallet(account=author, balance=0, earn_balance=0, tip_balance=0, frozen_balance=0)
766
- db_session.add(author_wallet)
767
 
768
  # 执行转账
769
  tipper_wallet.balance -= amount
@@ -824,8 +737,17 @@ async def tip_prompt(prompt_id: str, amount: int, is_anon: bool = False,
824
  db_session.commit()
825
 
826
  # 📝 审计日志
827
- logger.info(f"PROMPT_TIP | from={current_user} | to={author} | amount={amount} | prompt={prompt_id} | anon={is_anon}")
 
 
 
 
 
 
 
 
828
 
 
829
  # 🔔 打赏通知(考虑匿名)
830
  if not is_anon:
831
  add_notification(author, {
@@ -844,16 +766,8 @@ async def tip_prompt(prompt_id: str, amount: int, is_anon: bool = False,
844
  "content": f"您收到了一份 {amount} 积分的匿名提示词打赏"
845
  })
846
 
847
- # 🗂️ 清除排序缓存(打赏可能影响排序)
848
- sort_cache.invalidate("prompts:")
849
-
850
- except HTTPException:
851
- db_session.rollback()
852
- raise
853
- except Exception as e:
854
- db_session.rollback()
855
- logger.error(f"PROMPT_TIP_ERROR | from={current_user} | prompt={prompt_id} | amount={amount} | error={str(e)}")
856
- raise HTTPException(status_code=500, detail="打赏处理失败,请稍后重试")
857
 
858
  # 🏆 扣款成功后再写打赏榜(写榜失败仅记录日志,不影响资金安全)
859
  def board_updater(data):
@@ -872,7 +786,7 @@ async def tip_prompt(prompt_id: str, amount: int, is_anon: bool = False,
872
  try:
873
  db.atomic_update("prompts.json", board_updater, default_data=[])
874
  except Exception as e:
875
- logger.error(f"PROMPT_TIP_BOARD_ERROR | prompt={prompt_id} | error={str(e)}")
876
 
877
  return {"status": "success", "message": f"成功打赏 {amount} 积分"}
878
 
@@ -882,164 +796,108 @@ async def tip_prompt(prompt_id: str, amount: int, is_anon: bool = False,
882
  # ==========================================
883
 
884
  @router.post("/api/prompts/{prompt_id}/purchase")
885
- @limiter.limit("10/minute") # 🔒 P0安全优化:购买每分钟最多10次
886
  async def purchase_prompt(request: Request, prompt_id: str,
 
887
  current_user: str = Depends(require_auth), db_session: Session = Depends(get_db)):
888
- """
889
- 购买提示词解锁正文(与 /api/wallet/purchase 逻辑对齐)
890
- - 扣费、所有权、PURCHASE/SALE 交易记录、通知一步到位
891
- - 成功后直接返回 prompt_text,前端立即展示
892
- """
893
- prompts_db = db.load_data("prompts.json", default_data=[])
894
- prompt = next((p for p in prompts_db if p["id"] == prompt_id), None)
895
-
896
- if not prompt:
897
- raise HTTPException(status_code=404, detail="提示词不存在")
898
-
899
- # 💰 应用延迟生效价格(与 /api/wallet/purchase 同构),确保扣费价与前端显示价一致
900
- old_price = prompt.get("price", 0)
901
- _apply_effective_price(prompt)
902
- if prompt.get("price", 0) != old_price:
903
- db.save_data("prompts.json", prompts_db)
904
-
905
- price = int(prompt.get("price", 0) or 0)
906
- seller_account = prompt.get("author")
907
-
908
- if price <= 0 or current_user == seller_account:
909
- # ☁️ 免费提示词或作者本人,确保 Ownership 记录存在
910
- existing_ownership = db_session.query(Ownership).filter(
911
- Ownership.account == current_user,
912
- Ownership.item_id == prompt_id,
913
- Ownership.is_refunded == False
914
- ).first()
915
- if not existing_ownership:
916
- new_ownership = Ownership(
917
- account=current_user,
918
- item_id=prompt_id,
919
- price_paid=0
920
- )
921
- db_session.add(new_ownership)
922
- db_session.commit()
923
- return {
924
- "status": "success",
925
- "already_owned": True,
926
- "prompt_text": prompt.get("prompt_text", "")
927
- }
928
-
929
- # 检查是否已拥有(排除已退款的记录)
930
- owned = db_session.query(Ownership).filter(
931
- Ownership.account == current_user,
932
- Ownership.item_id == prompt_id,
933
- Ownership.is_refunded == False
934
- ).first()
935
- if owned:
936
- return {
937
- "status": "success",
938
- "already_owned": True,
939
- "prompt_text": prompt.get("prompt_text", "")
940
- }
941
 
942
- # 🔒 P1并发安全:使用悲观锁+异常处理+事务回滚防止双花问题
943
  try:
944
- buyer_wallet = db_session.query(Wallet).filter(Wallet.account == current_user).with_for_update().first()
 
 
 
 
 
 
945
 
946
- # 🔒 锁内二次检查:串行化后重查 Ownership,彻底消除并发重复购买竞态
947
- owned_locked = db_session.query(Ownership).filter(
 
 
 
 
948
  Ownership.account == current_user,
949
  Ownership.item_id == prompt_id,
950
  Ownership.is_refunded == False
951
  ).first()
952
- if owned_locked:
953
  db_session.rollback()
954
- return {
955
- "status": "success",
956
- "already_owned": True,
957
- "prompt_text": prompt.get("prompt_text", "")
958
- }
959
-
960
- if not buyer_wallet or buyer_wallet.balance < price:
 
 
 
 
 
 
961
  raise HTTPException(status_code=402, detail="余额不足,请先充值")
962
-
963
- seller_wallet = db_session.query(Wallet).filter(Wallet.account == seller_account).with_for_update().first()
964
- if not seller_wallet:
965
- seller_wallet = Wallet(account=seller_account, balance=0, earn_balance=0, tip_balance=0, frozen_balance=0)
966
- db_session.add(seller_wallet)
967
-
968
- buyer_wallet.balance = int(buyer_wallet.balance or 0) - int(price)
969
- seller_wallet.balance = int(seller_wallet.balance or 0) + int(price) # 实际收入进统一余额
970
- seller_wallet.earn_balance = int(seller_wallet.earn_balance or 0) + int(price) # 累计销售收益统计(只增不减)
971
-
972
- # 记录所有权与实付价格
973
- new_ownership = Ownership(account=current_user, item_id=prompt_id, price_paid=price)
974
- db_session.add(new_ownership)
975
-
976
- tx_id = f"BUY_{int(time.time())}_{uuid.uuid4().hex[:6]}"
977
- last_tx = db_session.query(Transaction).filter(Transaction.account == current_user).order_by(Transaction.created_at.desc()).first()
978
- prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
979
- tx_hash = calculate_tx_hash(tx_id, current_user, "PURCHASE", -price, prev_hash)
980
-
981
- # 获取卖家用户名
982
- users_db = db.load_data("users.json", default_data={})
983
- seller_info = users_db.get(seller_account, {})
984
- seller_user_name = seller_info.get("name", seller_account)
985
-
986
- # 创建交易记录 (字段名为 related_account,与 models_sql.py 中 Transaction 模型保持一致)
987
- new_tx = Transaction(
988
- tx_id=tx_id, account=current_user, tx_type="PURCHASE", amount=-price,
989
- related_account=seller_account, item_id=prompt_id, prev_hash=prev_hash, tx_hash=tx_hash,
990
- description=f"购买提示词 {prompt.get('title', '未知提示词')}",
991
- item_title=prompt.get('title', ''),
992
- item_type="prompt",
993
- related_user_name=seller_user_name
994
- )
995
- db_session.add(new_tx)
996
-
997
- # 为卖家生成销售收入记录
998
- buyer_info = users_db.get(current_user, {})
999
- buyer_user_name = buyer_info.get("name", current_user)
1000
- seller_tx_id = f"SALE_{int(time.time())}_{uuid.uuid4().hex[:6]}"
1001
- seller_tx = Transaction(
1002
- tx_id=seller_tx_id,
1003
- account=seller_account,
1004
- tx_type="SALE",
1005
- amount=price,
1006
- related_account=current_user,
1007
- item_id=prompt_id,
1008
- item_title=prompt.get('title', ''),
1009
- item_type="prompt",
1010
- related_user_name=buyer_user_name,
1011
- description=f"销售收入: {prompt.get('title', prompt_id)}"
1012
- )
1013
- db_session.add(seller_tx)
1014
  db_session.commit()
1015
-
1016
- # 📝 P2优化:购买审计日志
1017
- logger.info(f"PROMPT_PURCHASE | buyer={current_user} | seller={seller_account} | prompt={prompt_id} | amount={price} | tx={tx_id}")
1018
-
1019
- # 🔔 通知卖家提示词被购买
1020
- if seller_account and seller_account != current_user:
1021
- add_notification(seller_account, {
1022
- "type": "purchase",
1023
- "from_user": current_user,
1024
- "target_item_id": prompt_id,
1025
- "target_item_title": prompt.get("title", ""),
1026
- "content": f"您的提示词《{prompt.get('title', '')}》已被购买"
1027
- })
1028
-
1029
- # 购买成功后直接返回提示词正文
1030
- return {
1031
- "status": "success",
1032
- "already_owned": False,
1033
- "prompt_text": prompt.get("prompt_text", "")
1034
- }
1035
  except HTTPException:
1036
  db_session.rollback()
1037
  raise
1038
- except Exception as e:
1039
  db_session.rollback()
1040
- logger.error(f"PROMPT_PURCHASE_ERROR | buyer={current_user} | prompt={prompt_id} | error={str(e)}")
1041
  raise HTTPException(status_code=500, detail="购买处理失败,请稍后重试")
1042
 
 
 
 
 
 
 
 
 
1043
 
1044
  # ==========================================
1045
  # 💬 评论接口(复用通用评论存储 comments.json)
@@ -1071,7 +929,8 @@ async def get_prompt_comments(prompt_id: str):
1071
 
1072
 
1073
  @router.post("/api/prompts/{prompt_id}/comments")
1074
- async def add_prompt_comment(prompt_id: str, content: str, current_user: str = Depends(require_auth)):
 
1075
  """
1076
  添加提示词评论
1077
  """
@@ -1079,7 +938,6 @@ async def add_prompt_comment(prompt_id: str, content: str, current_user: str = D
1079
  raise HTTPException(status_code=400, detail="评论内容不能为空")
1080
 
1081
  prompts_db = db.load_data("prompts.json", default_data=[])
1082
- comments_db = db.load_data("comments.json", default_data={})
1083
 
1084
  # 检查提示词是否存在
1085
  prompt_exists = any(p["id"] == prompt_id for p in prompts_db)
@@ -1093,27 +951,18 @@ async def add_prompt_comment(prompt_id: str, content: str, current_user: str = D
1093
  "created_at": int(time.time())
1094
  }
1095
 
1096
- # comments_db 是 {item_id: [comments]} 格式
1097
- prompt_comments = comments_db.get(prompt_id, [])
1098
- prompt_comments.insert(0, new_comment)
1099
- comments_db[prompt_id] = prompt_comments
1100
- db.save_data("comments.json", comments_db)
1101
 
1102
  # 🗂️ 清除排序缓存(评论数变化可能影响排序)
1103
  sort_cache.invalidate("prompts:")
1104
 
1105
- # 更新提示词评论数
1106
- for prompt in prompts_db:
1107
- if prompt["id"] == prompt_id:
1108
- prompt["comments"] = prompt.get("comments", 0) + 1
1109
- break
1110
- db.save_data("prompts.json", prompts_db)
1111
-
1112
  return {"status": "success", "data": new_comment}
1113
 
1114
 
1115
  @router.post("/api/prompts/{prompt_id}/view")
1116
- async def record_prompt_view(prompt_id: str, current_user: str = Depends(require_auth)):
 
1117
  """
1118
  记录提示词访问量
1119
  👁️ 需要用户认证,每个用户只计算一次总访问量,日访问量每次调用都增加
 
1
+ from publication_identity import publication_identity
2
  # 云端Space代码/router_prompts.py
3
  # ==========================================
4
  # ✨ 提示词市场API路由(图像/视频提示词)
 
15
 
16
  from fastapi import APIRouter, HTTPException, Depends, Request
17
  from sqlalchemy.orm import Session
18
+ from models import PromptCreate, PromptUpdate, PromptCategoryAdd, PromptPurchaseRequest
19
+ from typing import Optional
20
  import 数据库连接 as db
21
+ from comment_store import append_comment, increment_comment_count, attach_comment_counts
22
  from 安全认证 import require_auth, optional_auth, is_admin
23
  from db_utils import record_view, sort_cache
24
  from database_sql import get_db
25
  from models_sql import Wallet, Transaction, Ownership
26
  from notifications import add_notification
27
+ from tip_transaction import lock_tip_wallets
28
+ from purchase_transaction import effective_resource_price, lock_resource_purchase_wallets
29
+ from tip_store import run_tip_effect
30
  import time
31
+ import asyncio
32
+ from prompt_store import (PromptStoreError, publish_prompt, edit_prompt,
33
+ delete_prompt as remove_prompt, project_price, load_categories, modify_category, read_prompts)
34
  import uuid
35
  import hashlib
36
  import datetime
37
  import logging
38
 
39
  # 🔒 P0安全优化:API限流(与 router_wallet.py 保持一致)
40
+ from rate_limit import shared_limit
 
 
41
 
42
  router = APIRouter()
43
 
 
102
  return cleaned
103
 
104
 
105
+ async def _prompt_operation(operation, *args, **kwargs):
106
+ try:
107
+ return await asyncio.to_thread(operation, *args, **kwargs)
108
+ except PromptStoreError as error:
109
+ raise HTTPException(status_code=error.status_code, detail=error.message) from None
110
+
111
+
112
+ def _invalidate_prompts():
113
+ try:
114
+ sort_cache.invalidate("prompts:")
115
+ except Exception as error:
116
+ logger.warning("提示词排序缓存失效失败: error_type=%s", type(error).__name__)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
 
118
 
119
  def _user_owns_prompt(db_session: Session, account: str, prompt_id: str) -> bool:
 
151
  获取提示词分类列表
152
  - prompt_type: image / video / music,不传则返回全部
153
  """
154
+ categories = await _prompt_operation(load_categories, db, DEFAULT_CATEGORIES)
155
  if prompt_type in VALID_PROMPT_TYPES:
156
  return {"status": "success", "data": categories.get(prompt_type, [])}
157
  return {"status": "success", "data": categories}
158
 
159
 
160
  @router.post("/api/prompt-categories")
161
+ @shared_limit(5) # 🔒 限流:防止分类刷屏
162
  async def add_prompt_category(request: Request, body: PromptCategoryAdd, current_user: str = Depends(require_auth)):
163
  """
164
  添加提示词分类(仅管理员,入口在云端管理界面,重名去重)
 
174
  if len(name) > MAX_CATEGORY_LEN:
175
  raise HTTPException(status_code=400, detail=f"分类名称不能超过{MAX_CATEGORY_LEN}个字")
176
 
177
+ values = await _prompt_operation(modify_category, db, DEFAULT_CATEGORIES, body.prompt_type, name, maximum=MAX_CATEGORY_PER_TYPE)
178
+ logger.info("PROMPT_CATEGORY_ADD | type=%s", body.prompt_type)
179
+ return {"status": "success", "data": values}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
 
181
 
182
  @router.delete("/api/prompt-categories")
183
+ @shared_limit(5) # 🔒 限流:与添加分类一致
184
  async def delete_prompt_category(request: Request, prompt_type: str = None, name: str = None,
185
  current_user: str = Depends(require_auth)):
186
  """
 
198
  if not name:
199
  raise HTTPException(status_code=400, detail="分类名称不能为空")
200
 
201
+ values = await _prompt_operation(modify_category, db, DEFAULT_CATEGORIES, prompt_type, name, delete=True)
202
+ logger.info("PROMPT_CATEGORY_DELETE | type=%s", prompt_type)
203
+ return {"status": "success", "data": values}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
 
205
 
206
  # ==========================================
 
232
  - sort: latest / likes / favorites / views / daily_views / tips
233
  ⚠️ 列表接口永不下发 prompt_text(付费内容脱敏)
234
  """
235
+ prompts_db = await _prompt_operation(read_prompts, db)
236
  users_db = db.load_data("users.json", default_data={})
237
  user_map = users_db
238
 
 
269
 
270
  # 附加作者信息,并过滤敏感字段
271
  result = []
 
272
  for prompt in paged_prompts:
273
+ try:
274
+ prompt = project_price(prompt)
275
+ except Exception:
276
+ raise HTTPException(status_code=503, detail="提示词暂时不可读取") from None
 
277
  author_info = user_map.get(prompt.get("author"), {})
278
  prompt_data = {
279
  **prompt,
 
285
  prompt_data.pop("viewed_by", None)
286
  result.append(prompt_data)
287
 
288
+ attach_comment_counts(result)
 
 
289
  return {
290
  "status": "success",
291
  "data": result,
 
301
  获取我发布的提示词(个人中心用)
302
  作者本人可见自己的 prompt_text
303
  """
304
+ prompts_db = await _prompt_operation(read_prompts, db)
305
 
306
  my_prompts = [p for p in prompts_db if p.get("author") == current_user]
307
  my_prompts = sorted(my_prompts, key=lambda x: x.get("created_at", 0), reverse=True)
308
 
309
  result = []
 
310
  for prompt in my_prompts:
311
+ try:
312
+ prompt = project_price(prompt)
313
+ except Exception:
314
+ raise HTTPException(status_code=503, detail="提示词暂时不可读取") from None
 
315
  prompt_data = dict(prompt)
316
  prompt_data.pop("viewed_by", None)
317
  result.append(prompt_data)
318
 
319
+ attach_comment_counts(result)
 
 
320
  return {"status": "success", "data": result}
321
 
322
 
 
327
  获取提示词详情
328
  🔒 仅当 ①免费 ②作者本人 ③已购买 时下发 prompt_text
329
  """
330
+ try:
331
+ prompts_db = db.load_data("prompts.json", default_data=[], strict=True, skip_cache=True)
332
+ if not isinstance(prompts_db, list):
333
+ raise ValueError("invalid prompt collection")
334
+ except Exception:
335
+ raise HTTPException(status_code=503, detail="提示词暂时不可读取")
336
  users_db = db.load_data("users.json", default_data={})
337
  user_map = users_db
338
 
339
  for prompt in prompts_db:
340
+ if isinstance(prompt, dict) and prompt.get("id") == prompt_id:
341
+ try:
342
+ price = effective_resource_price(prompt)
343
+ except Exception:
344
+ raise HTTPException(status_code=503, detail="提示词暂时不可读取")
 
345
 
346
  author_info = user_map.get(prompt.get("author"), {})
347
  prompt_data = {
348
  **prompt,
349
+ "price": price,
350
  "author_name": author_info.get("name", prompt.get("author")),
351
  "author_avatar": author_info.get("avatarDataUrl", "")
352
  }
353
  prompt_data.pop("viewed_by", None)
354
 
 
355
  is_author = current_user and current_user == prompt.get("author")
356
  can_view = price <= 0 or is_author or _user_owns_prompt(db_session, current_user, prompt_id)
357
  prompt_data["owned"] = bool(can_view)
358
+ prompt_data["price_confirmation_supported"] = True
359
  _mask_prompt_text(prompt_data, can_view)
360
 
361
+ attach_comment_counts([prompt_data])
362
  return {"status": "success", "data": prompt_data}
363
 
364
  raise HTTPException(status_code=404, detail="提示词不存在")
365
 
366
 
367
  @router.post("/api/prompts")
368
+ @shared_limit(5)
369
+ async def create_prompt(request: Request, prompt: PromptCreate, current_user: str = Depends(require_auth)):
370
  """
371
  发布提示词
372
  """
373
+ try:
374
+ stable_id = publication_identity('prompt', current_user, getattr(request, 'headers', {}).get('Idempotency-Key'))
375
+ except ValueError:
376
+ raise HTTPException(status_code=400, detail='发布请求身份无效') from None
377
+
378
  # 参数校验
379
  if prompt.prompt_type not in VALID_PROMPT_TYPES:
380
  raise HTTPException(status_code=400, detail="提示词类型必须是 image、video 或 music")
 
394
  raise HTTPException(status_code=400, detail="请选择分类")
395
 
396
  # 分类必须存在于分类库
397
+ categories = await _prompt_operation(load_categories, db, DEFAULT_CATEGORIES)
398
  if category not in categories.get(prompt.prompt_type, []):
399
  raise HTTPException(status_code=400, detail="所选分类不存在")
400
 
401
+ price = prompt.price
402
+ if type(price) is not int or not 0 <= price <= MAX_PRICE:
403
  raise HTTPException(status_code=400, detail=f"定价必须在0-{MAX_PRICE}之间")
404
 
405
  media_type = prompt.media_type if prompt.media_type in ("image", "video") else "image"
 
417
  audio_url = None
418
 
419
  new_prompt = {
420
+ "id": stable_id or f"prompt_{int(time.time())}_{uuid.uuid4().hex}",
421
  "prompt_type": prompt.prompt_type,
422
  "category": category,
423
  "title": title,
 
446
  "tags": _sanitize_tags(prompt.tags)
447
  }
448
 
449
+ new_prompt = await _prompt_operation(publish_prompt, db, new_prompt, stable_id is not None)
450
+ _invalidate_prompts()
 
 
 
451
 
452
  return {"status": "success", "data": new_prompt}
453
 
454
 
455
  @router.put("/api/prompts/{prompt_id}")
456
+ @shared_limit(10)
457
+ async def update_prompt(request: Request, prompt_id: str, update_data: PromptUpdate, current_user: str = Depends(require_auth)):
458
  """
459
  更新提示词(仅作者可操作,原子操作,并发安全)
460
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
461
  error_map = {
462
  "forbidden": (403, "无权修改他人提示词"),
463
  "not_found": (404, "提示词不存在"),
 
468
  "invalid_price": (400, f"定价必须在0-{MAX_PRICE}之间"),
469
  "invalid_media_type": (400, "媒体类型必须是 image 或 video")
470
  }
471
+ categories = await _prompt_operation(load_categories, db, DEFAULT_CATEGORIES) if update_data.category is not None else None
472
+
473
+ def apply(prompt):
474
+ # 更新字段
475
+ if update_data.title is not None:
476
+ title = update_data.title.strip()
477
+ if not title or len(title) > MAX_TITLE_LEN:
478
+ raise PromptStoreError(*error_map["invalid_title"])
479
+ prompt["title"] = title
480
+ if update_data.content is not None:
481
+ content = update_data.content.strip()
482
+ if len(content) > MAX_CONTENT_LEN:
483
+ raise PromptStoreError(*error_map["invalid_content"])
484
+ prompt["content"] = content
485
+ if update_data.prompt_text is not None:
486
+ prompt_text = update_data.prompt_text.strip()
487
+ if not prompt_text or len(prompt_text) > MAX_PROMPT_TEXT_LEN:
488
+ raise PromptStoreError(*error_map["invalid_prompt_text"])
489
+ prompt["prompt_text"] = prompt_text
490
+ if update_data.category is not None:
491
+ category = update_data.category.strip()
492
+ if category not in categories.get(prompt.get("prompt_type"), []):
493
+ raise PromptStoreError(*error_map["invalid_category"])
494
+ prompt["category"] = category
495
+ if update_data.cover_image is not None:
496
+ prompt["cover_image"] = update_data.cover_image
497
+ if update_data.price is not None:
498
+ price = update_data.price
499
+ if type(price) is not int or not 0 <= price <= MAX_PRICE:
500
+ raise PromptStoreError(*error_map["invalid_price"])
501
+ current_price = effective_resource_price(prompt)
502
+ if current_price != price:
503
+ # 设置待生效价格,24小时后生效(与资源改价机制同构;提示词不支持退款,无需禁购配套)
504
+ effective_time = datetime.datetime.now() + datetime.timedelta(hours=24)
505
+ prompt["pending_price"] = price
506
+ prompt["pending_price_effective_at"] = effective_time.isoformat()
507
+ # 不立即修改 price,等待24小时后生效
508
+ else:
509
+ # 价格未变,清除待生效价格
510
+ prompt["pending_price"] = None
511
+ prompt["pending_price_effective_at"] = None
512
+ if update_data.media_type is not None:
513
+ if update_data.media_type not in ("image", "video"):
514
+ raise PromptStoreError(*error_map["invalid_media_type"])
515
+ prompt["media_type"] = update_data.media_type
516
+ if update_data.media_type == "image":
517
+ # 图像类型不保留视频(与 create 逻辑一致)
518
+ prompt["video_url"] = None
519
+ if update_data.video_url is not None:
520
+ prompt["video_url"] = update_data.video_url
521
+ if update_data.video_url:
522
+ prompt["media_type"] = "video"
523
+ if update_data.audio_url is not None or "audio_url" in getattr(update_data, "model_fields_set", set()):
524
+ # 🎵 音频仅音乐提示词保留;显式空字符串/null 清除,未传保持
525
+ audio_url = (update_data.audio_url or "").strip() or None
526
+ prompt["audio_url"] = audio_url if prompt.get("prompt_type") == "music" else None
527
+ if update_data.images is not None:
528
+ prompt["images"] = _sanitize_images(update_data.images)
529
+ if update_data.is_original is not None:
530
+ prompt["is_original"] = bool(update_data.is_original)
531
+ if update_data.tags is not None:
532
+ prompt["tags"] = _sanitize_tags(update_data.tags)
533
+ if prompt.get("media_type") == "image":
534
+ prompt["video_url"] = None
535
+ else:
536
+ prompt["images"] = []
537
 
538
+ await _prompt_operation(edit_prompt, db, prompt_id, current_user, apply)
539
+ _invalidate_prompts()
540
  return {"status": "success"}
541
 
542
 
543
  @router.delete("/api/prompts/{prompt_id}")
544
+ @shared_limit(10)
545
+ async def delete_prompt(request: Request, prompt_id: str, current_user: str = Depends(require_auth)):
546
  """
547
  删除提示词(仅作者可操作)
548
  """
549
+ await _prompt_operation(remove_prompt, db, prompt_id, current_user)
550
+ _invalidate_prompts()
551
+ return {"status": "success"}
 
 
 
 
 
 
 
 
 
 
 
552
 
553
 
554
  # ==========================================
 
556
  # ==========================================
557
 
558
  @router.post("/api/prompts/{prompt_id}/like")
559
+ @shared_limit(30)
560
+ async def toggle_like(request: Request, prompt_id: str, current_user: str = Depends(require_auth)):
561
  """
562
  点赞/取消点赞(原子操作,并发安全)
563
  """
 
592
 
593
 
594
  @router.post("/api/prompts/{prompt_id}/favorite")
595
+ @shared_limit(30)
596
+ async def toggle_favorite(request: Request, prompt_id: str, current_user: str = Depends(require_auth)):
597
  """
598
  收藏/取消收藏(原子操作,并发安全)
599
  """
 
632
  # ==========================================
633
 
634
  @router.post("/api/prompts/{prompt_id}/tip")
635
+ @shared_limit(10)
636
+ async def tip_prompt(request: Request, prompt_id: str, amount: int, is_anon: bool = False,
637
  current_user: str = Depends(require_auth), db_session: Session = Depends(get_db)):
638
  """
639
  打赏提示词(原子操作,并发安全)- 使用 SQL Wallet 系统
 
660
  # 💳 使用 SQL Wallet 系统处理余额转账(先扣款)
661
  try:
662
 
663
+ # 🔒 并发安全:使用悲观锁获取双方钱包
664
+ tipper_wallet, author_wallet = lock_tip_wallets(db_session, current_user, author)
665
  # 🔒 P1幂等性防护:检查最近5秒内是否存在相同交易
666
  recent_cutoff = datetime.datetime.utcnow() - datetime.timedelta(seconds=5)
667
  duplicate_tx = db_session.query(Transaction).filter(
 
670
  Transaction.amount == -amount,
671
  Transaction.related_account == author,
672
  Transaction.created_at >= recent_cutoff
673
+ ).with_for_update().first()
674
  if duplicate_tx:
675
+ db_session.rollback() # 重复请求释放锁,不执行任何后续操作
676
  return {"status": "success", "message": "打赏已处理(重复请求)"}
677
 
 
 
 
 
678
  if not tipper_wallet or tipper_wallet.balance < amount:
679
  raise HTTPException(status_code=400, detail="余额不足")
 
 
 
 
680
 
681
  # 执行转账
682
  tipper_wallet.balance -= amount
 
737
  db_session.commit()
738
 
739
  # 📝 审计日志
740
+ logger.info(f"PROMPT_TIP | prompt={prompt_id}")
741
+
742
+ except HTTPException:
743
+ db_session.rollback()
744
+ raise
745
+ except Exception as e:
746
+ db_session.rollback()
747
+ logger.error(f"PROMPT_TIP_ERROR | prompt={prompt_id} | error_type={type(e).__name__}")
748
+ raise HTTPException(status_code=500, detail="打赏处理失败,请稍后重试")
749
 
750
+ def notify_tip():
751
  # 🔔 打赏通知(考虑匿名)
752
  if not is_anon:
753
  add_notification(author, {
 
766
  "content": f"您收到了一份 {amount} 积分的匿名提示词打赏"
767
  })
768
 
769
+ run_tip_effect(notify_tip)
770
+ run_tip_effect(lambda: sort_cache.invalidate("prompts:"))
 
 
 
 
 
 
 
 
771
 
772
  # 🏆 扣款成功后再写打赏榜(写榜失败仅记录日志,不影响资金安全)
773
  def board_updater(data):
 
786
  try:
787
  db.atomic_update("prompts.json", board_updater, default_data=[])
788
  except Exception as e:
789
+ logger.error(f"PROMPT_TIP_BOARD_ERROR | prompt={prompt_id} | error_type={type(e).__name__}")
790
 
791
  return {"status": "success", "message": f"成功打赏 {amount} 积分"}
792
 
 
796
  # ==========================================
797
 
798
  @router.post("/api/prompts/{prompt_id}/purchase")
799
+ @shared_limit(10) # 🔒 P0安全优化:购买每分钟最多10次
800
  async def purchase_prompt(request: Request, prompt_id: str,
801
+ purchase: Optional[PromptPurchaseRequest] = None,
802
  current_user: str = Depends(require_auth), db_session: Session = Depends(get_db)):
803
+ """资金与所有权原子提交;通知失败不改变已提交的购买结果。"""
804
+ try:
805
+ prompts_db = db.load_data("prompts.json", default_data=[], strict=True, skip_cache=True)
806
+ if not isinstance(prompts_db, list):
807
+ raise ValueError("invalid prompt collection")
808
+ prompt = next((p for p in prompts_db if isinstance(p, dict) and p.get("id") == prompt_id), None)
809
+ if prompt is None:
810
+ raise HTTPException(status_code=404, detail="提示词不存在")
811
+ price = effective_resource_price(prompt)
812
+ seller_account = prompt.get("author")
813
+ prompt_text = prompt.get("prompt_text")
814
+ if not isinstance(seller_account, str) or not seller_account.strip() or not isinstance(prompt_text, str):
815
+ raise ValueError("invalid prompt delivery")
816
+ response = {"status": "success", "already_owned": True, "prompt_text": prompt_text}
817
+ except HTTPException:
818
+ raise
819
+ except Exception as error:
820
+ logger.error("PROMPT_PURCHASE_SOURCE_ERROR | error_type=%s", type(error).__name__)
821
+ raise HTTPException(status_code=503, detail="提示词暂时不可购买")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
822
 
823
+ # 非资金展示资料在写锁外读取;失败不影响正文交付或交易。
824
  try:
825
+ users_db = db.load_data("users.json", default_data={})
826
+ seller_info = users_db.get(seller_account, {})
827
+ buyer_info = users_db.get(current_user, {})
828
+ seller_user_name = seller_info.get("name", seller_account)
829
+ buyer_user_name = buyer_info.get("name", current_user)
830
+ except Exception:
831
+ seller_user_name, buyer_user_name = seller_account, current_user
832
 
833
+ free = price == 0 or current_user == seller_account
834
+ try:
835
+ # 必须是本次事务的第一项 SQL;SQLite 使用 BEGIN IMMEDIATE。
836
+ buyer_wallet, seller_wallet = lock_resource_purchase_wallets(
837
+ db_session, current_user, seller_account, free)
838
+ owned = db_session.query(Ownership).filter(
839
  Ownership.account == current_user,
840
  Ownership.item_id == prompt_id,
841
  Ownership.is_refunded == False
842
  ).first()
843
+ if owned:
844
  db_session.rollback()
845
+ return response
846
+ if free:
847
+ db_session.add(Ownership(account=current_user, item_id=prompt_id, price_paid=0))
848
+ db_session.commit()
849
+ return response
850
+ expected_price = getattr(purchase, "expected_price", None)
851
+ if expected_price is None:
852
+ raise HTTPException(status_code=428, detail="purchase_price_confirmation_required")
853
+ if isinstance(expected_price, bool) or not isinstance(expected_price, int) or expected_price < 0:
854
+ raise HTTPException(status_code=422, detail="purchase_price_invalid")
855
+ if expected_price != price:
856
+ raise HTTPException(status_code=409, detail="purchase_price_changed")
857
+ if buyer_wallet is None or buyer_wallet.balance < price:
858
  raise HTTPException(status_code=402, detail="余额不足,请先充值")
859
+ for amount in (buyer_wallet.balance, seller_wallet.balance, seller_wallet.earn_balance):
860
+ if isinstance(amount, bool) or not isinstance(amount, int):
861
+ raise ValueError("invalid wallet amount")
862
+ buyer_wallet.balance -= price
863
+ seller_wallet.balance += price
864
+ seller_wallet.earn_balance += price
865
+ db_session.add(Ownership(account=current_user, item_id=prompt_id, price_paid=price))
866
+
867
+ for account, other, tx_type, amount, user_name, description in (
868
+ (current_user, seller_account, "PURCHASE", -price, seller_user_name,
869
+ f"购买提示词 {prompt.get('title', '未知提示词')}"),
870
+ (seller_account, current_user, "SALE", price, buyer_user_name,
871
+ f"销售收入: {prompt.get('title', prompt_id)}")
872
+ ):
873
+ last_tx = db_session.query(Transaction).filter(Transaction.account == account).order_by(
874
+ Transaction.created_at.desc(), Transaction.tx_id.desc()).with_for_update().first()
875
+ prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
876
+ tx_id = f"{tx_type}_{uuid.uuid4().hex}"
877
+ db_session.add(Transaction(
878
+ tx_id=tx_id, account=account, tx_type=tx_type, amount=amount,
879
+ related_account=other, item_id=prompt_id, prev_hash=prev_hash,
880
+ tx_hash=calculate_tx_hash(tx_id, account, tx_type, amount, prev_hash),
881
+ description=description, item_title=prompt.get("title", ""),
882
+ item_type="prompt", related_user_name=user_name))
883
+ response["already_owned"] = False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
884
  db_session.commit()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
885
  except HTTPException:
886
  db_session.rollback()
887
  raise
888
+ except Exception as error:
889
  db_session.rollback()
890
+ logger.error("PROMPT_PURCHASE_ERROR | error_type=%s", type(error).__name__)
891
  raise HTTPException(status_code=500, detail="购买处理失败,请稍后重试")
892
 
893
+ run_tip_effect(lambda: logger.info("PROMPT_PURCHASE_COMPLETED"))
894
+ run_tip_effect(lambda: add_notification(seller_account, {
895
+ "type": "purchase", "from_user": current_user,
896
+ "target_item_id": prompt_id, "target_item_title": prompt.get("title", ""),
897
+ "content": f"您的提示词《{prompt.get('title', '')}》已被购买"
898
+ }))
899
+ return response
900
+
901
 
902
  # ==========================================
903
  # 💬 评论接口(复用通用评论存储 comments.json)
 
929
 
930
 
931
  @router.post("/api/prompts/{prompt_id}/comments")
932
+ @shared_limit(10)
933
+ async def add_prompt_comment(request: Request, prompt_id: str, content: str, current_user: str = Depends(require_auth)):
934
  """
935
  添加提示词评论
936
  """
 
938
  raise HTTPException(status_code=400, detail="评论内容不能为空")
939
 
940
  prompts_db = db.load_data("prompts.json", default_data=[])
 
941
 
942
  # 检查提示词是否存在
943
  prompt_exists = any(p["id"] == prompt_id for p in prompts_db)
 
951
  "created_at": int(time.time())
952
  }
953
 
954
+ append_comment(prompt_id, new_comment, prepend=True)
955
+ increment_comment_count("prompts.json", prompt_id)
 
 
 
956
 
957
  # 🗂️ 清除排序缓存(评论数变化可能影响排序)
958
  sort_cache.invalidate("prompts:")
959
 
 
 
 
 
 
 
 
960
  return {"status": "success", "data": new_comment}
961
 
962
 
963
  @router.post("/api/prompts/{prompt_id}/view")
964
+ @shared_limit(60)
965
+ async def record_prompt_view(request: Request, prompt_id: str, current_user: str = Depends(require_auth)):
966
  """
967
  记录提示词访问量
968
  👁️ 需要用户认证,每个用户只计算一次总访问量,日访问量每次调用都增加
router_proxy.py CHANGED
@@ -1,22 +1,102 @@
1
  # router_proxy.py
2
- from fastapi import APIRouter, Depends, HTTPException
 
3
  from fastapi.responses import StreamingResponse, JSONResponse, Response
4
  from sqlalchemy.orm import Session
5
  from pydantic import BaseModel, Field, field_validator, HttpUrl
6
  from typing import Optional
7
- from urllib.parse import urlparse
8
  import ipaddress
9
  import httpx
10
  import os
11
  import re
 
 
12
  import urllib.request
13
  import urllib.error
14
  import 数据库连接 as json_db
15
  from database_sql import get_db
16
  from models_sql import Ownership
 
 
 
 
17
  from 安全认证 import require_auth
 
 
 
 
18
 
19
  router = APIRouter()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
 
22
  class ProxyGithubZipRequest(BaseModel):
@@ -35,172 +115,27 @@ class ProxyGithubZipRequest(BaseModel):
35
  return v
36
 
37
  @router.post("/api/proxy_github_zip")
38
- async def proxy_github_zip(req_data: ProxyGithubZipRequest, account: str = Depends(require_auth), db: Session = Depends(get_db)):
 
39
  """云端代理:校验所有权后,拉取 GitHub 私有库 ZIP 流透传给本地"""
40
- items_db = json_db.load_data("items.json", default_data=[])
41
- item = next((i for i in items_db if i["id"] == req_data.item_id), None)
42
- if not item: return JSONResponse(content={"error": "资源不存在"}, status_code=404)
43
-
44
- # 1. 核心鉴权:验证用户是否购买过该工具(account 来自 Token 验证,不可伪造)
45
- try:
46
- price = int(float(item.get("price", 0) or 0))
47
- except (ValueError, TypeError):
48
- price = 0
49
- if price > 0 and account != item.get("author"):
50
- owned = db.query(Ownership).filter(Ownership.account == account, Ownership.item_id == req_data.item_id).first()
51
- if not owned:
52
- return JSONResponse(content={"error": "🚨 拒绝访问:未找到购买记录!"}, status_code=403)
53
-
54
- # ===== ZIP 缓存优先机制(鉴权后、GitHub请求前) =====
55
- try:
56
- versions_db = json_db.load_data("versions.json", default_data={})
57
- item_version = versions_db.get(req_data.item_id)
58
-
59
- # 兼容新格式:{"item_id": {"hash": "...", "cached_at": "...", "zip_size": ...}}
60
- if isinstance(item_version, dict) and item_version.get("cached_at"):
61
- cache_filename = f"zips/{req_data.item_id}_{item_version['hash']}.zip"
62
- cache_url = f"https://huggingface.co/datasets/ZHIWEI666/ComfyUI-Ranking/resolve/main/{cache_filename}"
63
-
64
- cache_client = httpx.AsyncClient(follow_redirects=True)
65
- head_resp = None
66
- try:
67
- head_resp = await cache_client.head(cache_url, timeout=10, follow_redirects=True)
68
- except Exception as head_err:
69
- print(f"[ZIP缓存] HEAD验证失败: {head_err}")
70
- await cache_client.aclose()
71
- raise
72
-
73
- if head_resp.status_code == 200:
74
- print(f"[ZIP缓存] 命中: {req_data.item_id}, 大小: {item_version.get('zip_size', 'unknown')}")
75
-
76
- content_length = head_resp.headers.get('content-length', '') or str(item_version.get('zip_size', ''))
77
-
78
- try:
79
- stream_resp = await cache_client.send(
80
- cache_client.build_request("GET", cache_url),
81
- stream=True
82
- )
83
- except Exception as stream_err:
84
- print(f"[ZIP缓存] 流请求失败: {stream_err}")
85
- await cache_client.aclose()
86
- raise
87
-
88
- cache_headers = {}
89
- if content_length:
90
- cache_headers["Content-Length"] = content_length
91
- # 🔖 回传本次实际下发的版本戳(缓存命中即 item_version['hash']),
92
- # 供本地私有安装写入 LocalVer 驱动卡片更新徽章(前端 installed_version 优先)
93
- if item_version.get('hash'):
94
- cache_headers["X-Installed-Version"] = item_version['hash']
95
-
96
- async def cache_stream_generator():
97
- try:
98
- if stream_resp.status_code != 200:
99
- yield b"CACHE_DOWNLOAD_FAILED"
100
- return
101
- async for chunk in stream_resp.aiter_bytes():
102
- yield chunk
103
- except Exception:
104
- yield b"CACHE_DOWNLOAD_FAILED"
105
- finally:
106
- await stream_resp.aclose()
107
- await cache_client.aclose()
108
-
109
- return StreamingResponse(cache_stream_generator(), media_type="application/zip", headers=cache_headers)
110
-
111
- # 缓存未命中:关闭 client 后继续原有逻辑
112
- await cache_client.aclose()
113
- except Exception as e:
114
- print(f"[ZIP缓存] 查询失败,fallback到GitHub直连: {e}")
115
-
116
- # 2. 解析 GitHub 仓库信息
117
- repo_url = item.get("link", "").rstrip("/")
118
- if not repo_url.startswith("https://github.com/"):
119
- return JSONResponse(content={"error": "无效的仓库地址,目前仅支持 GitHub 私有库代理"}, status_code=400)
120
-
121
- repo_parts = repo_url.split("/")
122
- if len(repo_parts) < 2: return JSONResponse(content={"error": "无效的仓库地址格式"}, status_code=400)
123
- owner = repo_parts[-2]
124
- repo = repo_parts[-1].replace(".git", "")
125
-
126
- # GitHub 官方提供的打包下载 API
127
- github_zip_api = f"https://api.github.com/repos/{owner}/{repo}/zipball"
128
-
129
- # 优先读取该资源在数据库中绑定的专属创作者 Token
130
- creator_token = item.get("github_token")
131
- # 如果没填,尝试使用官方全局兜底的 PAT
132
- fallback_token = os.environ.get("GITHUB_PAT")
133
-
134
- active_token = creator_token if creator_token else fallback_token
135
-
136
- headers = {
137
- "Accept": "application/vnd.github.v3+json",
138
- "User-Agent": "ComfyUI-Ranking-SaaS"
139
- }
140
- if active_token:
141
- headers["Authorization"] = f"Bearer {active_token}"
142
-
143
- # 3. 异步请求 GitHub API 并以流形式透传回客户端 (防内存打爆)
144
- client = httpx.AsyncClient(follow_redirects=True)
145
  try:
146
- try:
147
- response = await client.send(
148
- client.build_request("GET", github_zip_api, headers=headers),
149
- stream=True
150
- )
151
- except Exception as send_err:
152
- await client.aclose()
153
- return JSONResponse(content={"error": f"代理下载时发生网络异常:{str(send_err)}"}, status_code=500)
154
-
155
- # 非 200 直接返回错误,避免 Content-Length 不匹配导致 h11 协议错误
156
- if response.status_code != 200:
157
- error_body = ""
158
- try:
159
- error_body = (await response.aread()).decode('utf-8', errors='ignore')[:200]
160
- except Exception:
161
- pass
162
- await response.aclose()
163
- await client.aclose()
164
- status_map = {404: "仓库不存在或无权访问", 401: "Token 无效或已过期", 403: "访问被拒绝(可能触发速率限制)"}
165
- err_msg = status_map.get(response.status_code, f"GitHub API 返回 HTTP {response.status_code}")
166
- print(f"[proxy_github_zip] ❌ GitHub API 失败: {response.status_code}, repo: {github_zip_api}, body: {error_body[:100]}")
167
- return JSONResponse(content={"error": f"GITHUB_DOWNLOAD_FAILED: {err_msg}"}, status_code=502)
168
 
169
- # 成功时才透传 Content-Length 和流式响应
170
- content_length = response.headers.get('content-length', '')
171
- resp_headers = {}
172
- if content_length:
173
- resp_headers["Content-Length"] = content_length
174
 
175
- # 🔖 缓存未命中(含 versions.json 尚未填充)时,现取仓库最新 commit 短 hash 回传,
176
- # 补齐私有工具"安装瞬间无版本戳"的边界,使卡片更新徽章可正常触发。
177
- # 解析失败不影响 ZIP 下发(前端会兜底 latest_version)
178
- try:
179
- from 云端_定时版本检测引擎 import fetch_latest_github_hash
180
- live_hash = await fetch_latest_github_hash(repo_url, active_token)
181
- if live_hash:
182
- resp_headers["X-Installed-Version"] = live_hash
183
- except Exception as ver_err:
184
- print(f"[proxy_github_zip] ⚠️ 版本戳解析失败(不影响下发): {ver_err}")
185
 
186
- async def stream_generator():
187
- try:
188
- async for chunk in response.aiter_bytes():
189
- yield chunk
190
- except Exception:
191
- yield b"GITHUB_DOWNLOAD_FAILED"
192
- finally:
193
- await response.aclose()
194
- await client.aclose()
195
 
196
- return StreamingResponse(stream_generator(), media_type="application/zip", headers=resp_headers)
197
- except Exception as e:
198
- await client.aclose()
199
- return JSONResponse(content={"error": f"代理下载时发生网络异常:{str(e)}"}, status_code=500)
200
 
201
- # ==========================================
202
- # 新增:工作流/应用 (App) JSON 代理下载接口
203
- # ==========================================
204
  class ProxyDownloadRequest(BaseModel):
205
  """工作流/应用 JSON 代理下载请求模型"""
206
  url: str = Field(..., min_length=1, description="下载URL")
@@ -240,68 +175,17 @@ class ProxyDownloadRequest(BaseModel):
240
  return v
241
 
242
  @router.post("/api/proxy_download")
243
- async def proxy_download(req_data: ProxyDownloadRequest, account: str = Depends(require_auth), db: Session = Depends(get_db)):
 
244
  """云端代理:校验所有权后,拉取真实的工作流 JSON 文件并透传给本地"""
245
- items_db = json_db.load_data("items.json", default_data=[])
246
- item = next((i for i in items_db if i["id"] == req_data.item_id), None)
247
-
248
- if not item:
249
- return JSONResponse(content={"error": "云端记录中找不到该资源"}, status_code=404)
250
-
251
- # 1. 核心鉴权:验证用户是否购买/获取过该工作流(account 来自 Token 验证,不可伪造)
252
- try:
253
- price = int(float(item.get("price", 0) or 0))
254
- except (ValueError, TypeError):
255
- price = 0
256
- if price > 0 and account != item.get("author"):
257
- owned = db.query(Ownership).filter(Ownership.account == account, Ownership.item_id == req_data.item_id).first()
258
- if not owned:
259
- return JSONResponse(content={"error": "您尚未获取该工作流,请先在社区页面点击获取"}, status_code=403)
260
-
261
- target_url = req_data.url
262
-
263
- # 🚀 核心修复:从环境变量提取 Hugging Face Token,并组装 Authorization 请求头
264
- hf_token = os.environ.get("HF_TOKEN")
265
-
266
- headers = {"User-Agent": "ComfyUI-Ranking-SaaS"}
267
- if hf_token and "huggingface.co" in target_url:
268
- headers["Authorization"] = f"Bearer {hf_token}"
269
-
270
- # 🚀 核心修复:使用异步 httpx 替代同步 urllib,避免阻塞事件循环
271
- # ⚠️ 安全警告:仅在网络环境导致证书验证失败时,通过环境变量临时关闭
272
- verify_ssl = os.environ.get("DISABLE_SSL_VERIFY", "").lower() not in ("1", "true")
273
- if not verify_ssl:
274
- print("⚠️ SSL证书验证已关闭,请仅在调试环境使用")
275
-
276
  try:
277
- async with httpx.AsyncClient(follow_redirects=True, verify=verify_ssl, timeout=120.0) as client:
278
- print(f"🔍 开始下载资源 [{req_data.item_id}]")
279
- print(f"🔗 目标地址:{target_url[:80]}...")
280
-
281
- response = await client.get(target_url, headers=headers)
282
-
283
- if response.status_code != 200:
284
- print(f"❌ 源文件拉取失败 [HTTP {response.status_code}]")
285
- return JSONResponse(
286
- content={"error": f"源文件拉取失败,HTTP 状态码:{response.status_code}"},
287
- status_code=response.status_code
288
- )
289
-
290
- content = response.content
291
- print(f"✅ 成功下载资源 [{req_data.item_id}], 大小:{len(content)} bytes")
292
-
293
- return Response(
294
- content=content,
295
- media_type="application/json",
296
- status_code=200,
297
- headers={"Content-Disposition": f"attachment; filename={req_data.item_id}.json"}
298
- )
299
-
300
- except httpx.TimeoutException as e:
301
- print(f"❌ 下载超时:{str(e)}")
302
- return JSONResponse(content={"error": "下载超时,请稍后重试"}, status_code=504)
303
- except Exception as e:
304
- import traceback
305
- print(f"❌ 代理下载异常:{str(e)}")
306
- print(traceback.format_exc())
307
- return JSONResponse(content={"error": f"代理下载时发生网络异常:{str(e)}"}, status_code=500)
 
1
  # router_proxy.py
2
+ from fastapi import APIRouter, Depends, HTTPException, Request
3
+ from rate_limit import shared_limit
4
  from fastapi.responses import StreamingResponse, JSONResponse, Response
5
  from sqlalchemy.orm import Session
6
  from pydantic import BaseModel, Field, field_validator, HttpUrl
7
  from typing import Optional
8
+ from urllib.parse import urlparse, urljoin
9
  import ipaddress
10
  import httpx
11
  import os
12
  import re
13
+ import asyncio
14
+ import socket
15
  import urllib.request
16
  import urllib.error
17
  import 数据库连接 as json_db
18
  from database_sql import get_db
19
  from models_sql import Ownership
20
+ from workflow_source import authorized_workflow_path, workflow_credential_target, WorkflowSourceError
21
+ from proxy_access import authorized_proxy_item, ProxyAccessError
22
+ from github_source import repository_parts, select_github_token
23
+ from github_archive import ARCHIVE_MAX_BYTES
24
  from 安全认证 import require_auth
25
+ from safe_http_transport import ValidatedAsyncHTTPTransport
26
+ from workflow_download_service import prepare_workflow_download
27
+ from zip_stream import ZIP_DOWNLOAD_MAX_BYTES
28
+ from github_zip_service import prepare_github_zip
29
 
30
  router = APIRouter()
31
+ MAX_PROXY_DOWNLOAD_BYTES = int(os.environ.get("MAX_PROXY_DOWNLOAD_BYTES", str(256 * 1024 * 1024)))
32
+
33
+
34
+ async def _validate_public_http_target(url: str) -> str:
35
+ """Validate every proxy hop and reject local/private destinations."""
36
+ parsed = urlparse(url)
37
+ if parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username or parsed.password:
38
+ raise HTTPException(status_code=400, detail="下载地址无效")
39
+ try:
40
+ port = parsed.port
41
+ except ValueError:
42
+ raise HTTPException(status_code=400, detail="下载地址端口无效")
43
+ if port not in (None, 80, 443):
44
+ raise HTTPException(status_code=400, detail="不允许访问自定义端口")
45
+
46
+ host = parsed.hostname.rstrip(".").lower()
47
+ try:
48
+ addresses = [ipaddress.ip_address(host)]
49
+ except ValueError:
50
+ try:
51
+ infos = await asyncio.to_thread(
52
+ socket.getaddrinfo, host, port or (443 if parsed.scheme == "https" else 80),
53
+ type=socket.SOCK_STREAM,
54
+ )
55
+ except OSError:
56
+ raise HTTPException(status_code=400, detail="下载地址无法解析")
57
+ addresses = [ipaddress.ip_address(info[4][0].split("%", 1)[0]) for info in infos]
58
+ if not addresses or any(not address.is_global for address in addresses):
59
+ raise HTTPException(status_code=400, detail="下载地址解析到非公网地址")
60
+ return host
61
+
62
+
63
+ async def _send_with_validated_redirects(client, method: str, url: str, headers=None, timeout=None,
64
+ max_redirects: int = 5):
65
+ """逐跳校验 URL/DNS 后请求;不让 HTTPX 自动跟随未经检查的重定向。"""
66
+ current_url = url
67
+ original_host = await _validate_public_http_target(current_url)
68
+ original_parsed = urlparse(current_url)
69
+ original_origin = (
70
+ original_parsed.scheme.lower(),
71
+ original_host,
72
+ original_parsed.port or (443 if original_parsed.scheme.lower() == "https" else 80),
73
+ )
74
+ request_headers = dict(headers or {})
75
+ for redirect_count in range(max_redirects + 1):
76
+ current_host = await _validate_public_http_target(current_url)
77
+ current_parsed = urlparse(current_url)
78
+ current_origin = (
79
+ current_parsed.scheme.lower(),
80
+ current_host,
81
+ current_parsed.port or (443 if current_parsed.scheme.lower() == "https" else 80),
82
+ )
83
+ hop_headers = dict(request_headers)
84
+ if current_origin != original_origin:
85
+ # 凭据只发给初始 HTTPS origin,绝不随跨 origin 重定向传递。
86
+ hop_headers.pop("Authorization", None)
87
+ response = await client.send(
88
+ client.build_request(method, current_url, headers=hop_headers, timeout=timeout),
89
+ stream=True,
90
+ follow_redirects=False,
91
+ )
92
+ if response.status_code not in (301, 302, 303, 307, 308):
93
+ return response
94
+ location = response.headers.get("location")
95
+ await response.aclose()
96
+ if not location or redirect_count >= max_redirects:
97
+ raise HTTPException(status_code=502, detail="下载源重定向无效或次数过多")
98
+ current_url = urljoin(current_url, location)
99
+ raise HTTPException(status_code=502, detail="下载源重定向失败")
100
 
101
 
102
  class ProxyGithubZipRequest(BaseModel):
 
115
  return v
116
 
117
  @router.post("/api/proxy_github_zip")
118
+ @shared_limit(5)
119
+ async def proxy_github_zip(request: Request, req_data: ProxyGithubZipRequest, account: str = Depends(require_auth), db: Session = Depends(get_db)):
120
  """云端代理:校验所有权后,拉取 GitHub 私有库 ZIP 流透传给本地"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  try:
122
+ item = await authorized_proxy_item(json_db, db, req_data.item_id, account)
123
+ except ProxyAccessError as error:
124
+ return JSONResponse(content={"error": error.message}, status_code=error.status_code)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
+ async def resolve_commit(repo_url, token):
127
+ # 缓存命中不加载版本引擎;保留原按需导入时序。
128
+ from 云端_定时版本检测引擎 import fetch_github_commit
129
+ return await fetch_github_commit(repo_url, token)
 
130
 
131
+ return await prepare_github_zip(item, req_data.item_id, store=json_db,
132
+ token_selector=select_github_token, resolve_commit=resolve_commit,
133
+ send=_send_with_validated_redirects, client_factory=httpx.AsyncClient,
134
+ transport_factory=ValidatedAsyncHTTPTransport, error_response=JSONResponse,
135
+ cache_max_bytes=ARCHIVE_MAX_BYTES, direct_max_bytes=ZIP_DOWNLOAD_MAX_BYTES)
 
 
 
 
 
136
 
 
 
 
 
 
 
 
 
 
137
 
 
 
 
 
138
 
 
 
 
139
  class ProxyDownloadRequest(BaseModel):
140
  """工作流/应用 JSON 代理下载请求模型"""
141
  url: str = Field(..., min_length=1, description="下载URL")
 
175
  return v
176
 
177
  @router.post("/api/proxy_download")
178
+ @shared_limit(10)
179
+ async def proxy_download(request: Request, req_data: ProxyDownloadRequest, account: str = Depends(require_auth), db: Session = Depends(get_db)):
180
  """云端代理:校验所有权后,拉取真实的工作流 JSON 文件并透传给本地"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  try:
182
+ item = await authorized_proxy_item(json_db, db, req_data.item_id, account)
183
+ except ProxyAccessError as error:
184
+ return JSONResponse(content={"error": error.message}, status_code=error.status_code)
185
+
186
+ return await prepare_workflow_download(item, req_data.item_id, store=json_db,
187
+ source_authorizer=authorized_workflow_path, source_error=WorkflowSourceError,
188
+ credential_target=workflow_credential_target, validate_target=_validate_public_http_target,
189
+ client_factory=httpx.AsyncClient, transport_factory=ValidatedAsyncHTTPTransport,
190
+ timeout_error=httpx.TimeoutException, http_error=HTTPException,
191
+ error_response=JSONResponse, max_bytes=MAX_PROXY_DOWNLOAD_BYTES)