Spaces:
Running
Running
3.0更新
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- Dockerfile +7 -5
- app.py +112 -274
- chat_store.py +157 -0
- comment_store.py +118 -0
- database_sql.py +13 -13
- db_utils.py +85 -111
- deployment_config.py +49 -0
- github_archive.py +103 -0
- github_probe_transport.py +89 -0
- github_source.py +53 -0
- github_versions.py +89 -0
- github_zip_service.py +145 -0
- hf_snapshot_store.py +101 -0
- hf_sync_journal.py +97 -0
- hf_sync_scheduler.py +109 -0
- image_file_budget.py +192 -0
- image_moderation.py +37 -19
- json_file_lock.py +68 -0
- media_proxy_service.py +65 -0
- models.py +21 -15
- models_sql.py +288 -131
- notification_store.py +165 -0
- notifications.py +5 -41
- open_transaction.py +47 -0
- owned_thread.py +33 -0
- post_store.py +91 -0
- prompt_store.py +146 -0
- proxy_access.py +62 -0
- proxy_resources.py +63 -0
- public_list_etag.py +71 -0
- publication_identity.py +22 -0
- purchase_transaction.py +25 -0
- rate_limit.py +290 -0
- recharge_transaction.py +90 -0
- requirements-build.lock +13 -0
- requirements.lock +0 -0
- requirements.txt +11 -7
- resource_deletion.py +208 -0
- resource_probe_service.py +93 -0
- resource_publication.py +188 -0
- resource_store.py +148 -0
- response_privacy.py +14 -0
- router_admin_plugins.py +40 -14
- router_comments.py +27 -105
- router_items.py +127 -321
- router_messages.py +19 -225
- router_open_api.py +638 -740
- router_posts.py +80 -113
- router_prompts.py +259 -410
- router_proxy.py +108 -224
Dockerfile
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
-
#
|
| 2 |
-
FROM python:3.
|
| 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 /
|
| 17 |
-
RUN pip install --no-cache-dir --
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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":
|
| 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__}
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
@
|
| 389 |
def upload_file(request: Request, file: UploadFile = File(...), file_type: str = Form(...), current_user: str = Depends(require_auth)):
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 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 |
-
|
| 501 |
-
|
| 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 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
if
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 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 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 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 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 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 |
-
|
| 236 |
-
if not
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 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 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 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(
|
| 202 |
-
return ModerationResult(passed=
|
| 203 |
-
details={"error":
|
| 204 |
|
| 205 |
# 解析审核结果
|
| 206 |
-
suggestion = response.get("Suggestion", "
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
label = response.get("Label", "")
|
| 208 |
score = response.get("Score", 0)
|
| 209 |
|
| 210 |
passed = suggestion == "pass"
|
| 211 |
|
| 212 |
-
logger.info(f"
|
| 213 |
|
| 214 |
return ModerationResult(
|
| 215 |
passed=passed,
|
| 216 |
label=label,
|
| 217 |
confidence=score,
|
| 218 |
suggestion=suggestion,
|
| 219 |
-
details=
|
| 220 |
)
|
| 221 |
|
| 222 |
except Exception as e:
|
| 223 |
-
logger.error(f"
|
| 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(
|
| 325 |
-
return ModerationResult(passed=
|
| 326 |
-
details={"error":
|
| 327 |
|
| 328 |
data = result.get("data", [])
|
| 329 |
if not data:
|
| 330 |
-
|
|
|
|
|
|
|
| 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", "
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|
| 359 |
|
| 360 |
return ModerationResult(
|
| 361 |
passed=passed,
|
| 362 |
label=block_label,
|
| 363 |
confidence=max_rate * 100,
|
| 364 |
suggestion=overall_suggestion,
|
| 365 |
-
details=
|
| 366 |
)
|
| 367 |
|
| 368 |
except Exception as e:
|
| 369 |
-
logger.error(f"阿里云审核异常: {
|
| 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
|
| 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
|
| 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 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
""
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
)
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
)
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 2 |
-
import uuid
|
| 3 |
-
import 数据库连接 as db
|
| 4 |
|
| 5 |
def add_notification(target_account: str, notif_data: dict):
|
| 6 |
try:
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 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 |
-
#
|
| 2 |
-
fastapi==0.
|
| 3 |
uvicorn==0.24.0
|
| 4 |
-
pydantic==2.
|
| 5 |
huggingface_hub==0.19.4
|
| 6 |
# 🧹 P2清理:datasets/aiofiles 全库零引用,已移除
|
| 7 |
-
python-multipart==0.0.
|
| 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 |
-
#
|
| 15 |
-
|
| 16 |
# 🔒 P0安全增强:bcrypt密码哈希
|
| 17 |
bcrypt==4.1.2
|
| 18 |
# 🔧 P0修复:升级支付宝SDK,打破旧版死锁,自动适配最新安全加密库
|
| 19 |
-
python-alipay-sdk
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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.
|
| 238 |
return JSONResponse(status_code=500, content={"success": False, "error": "创建插件失败"})
|
| 239 |
|
| 240 |
-
logger.info(f"
|
| 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.
|
| 286 |
return JSONResponse(status_code=500, content={"success": False, "error": "更新插件失败"})
|
| 287 |
|
| 288 |
-
logger.info(
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 317 |
return JSONResponse(status_code=500, content={"success": False, "error": "删除插件失败"})
|
| 318 |
|
| 319 |
-
logger.info(f"
|
| 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.
|
| 350 |
return JSONResponse(status_code=500, content={"success": False, "error": "轮换 plugin_key 失败"})
|
| 351 |
|
| 352 |
-
logger.info(f"
|
| 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.
|
| 385 |
return JSONResponse(status_code=500, content={"success": False, "error": "切换插件状态失败"})
|
| 386 |
|
| 387 |
-
logger.info(
|
|
|
|
|
|
|
|
|
|
| 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
|
| 13 |
-
from
|
| 14 |
-
limiter = Limiter(key_func=get_remote_address)
|
| 15 |
|
| 16 |
router = APIRouter()
|
| 17 |
|
| 18 |
@router.post("/api/interactions/toggle")
|
| 19 |
-
|
|
|
|
| 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 |
-
@
|
| 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 |
-
|
| 88 |
-
|
| 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 |
-
|
| 101 |
elif target_post:
|
| 102 |
-
|
| 103 |
elif comment.item_id in users_db:
|
| 104 |
-
|
| 105 |
|
| 106 |
-
|
| 107 |
-
|
|
|
|
| 108 |
return {"status": "success", "data": new_comment}
|
| 109 |
|
| 110 |
@router.delete("/api/comments/{item_id}/{comment_id}")
|
| 111 |
-
|
|
|
|
| 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 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 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 |
-
"""
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
item[
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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
|
| 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 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 364 |
|
| 365 |
# 🚀 首次发布立即触发版本检测与预缓存
|
| 366 |
try:
|
| 367 |
link = item.link or ""
|
| 368 |
if "github.com" in link:
|
| 369 |
-
from 云端_定时版本检测引擎 import
|
| 370 |
|
| 371 |
async def _first_publish_precache():
|
| 372 |
try:
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
| 396 |
"""
|
| 397 |
更新内容接口
|
| 398 |
🔒 P0安全修复:使用 JWT Token 验证用户身份,而非前端传入的 author 参数
|
| 399 |
🔄 P7后悔模式:价格修改延迟24小时生效
|
| 400 |
🔧 Bug修复:使用 atomic_update 避免并发竞态条件
|
| 401 |
"""
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 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 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 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 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 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 |
-
|
|
|
|
| 533 |
"""
|
| 534 |
遍历所有 GitHub 类型的工具,获取最新 commit hash,存入 versions.json
|
| 535 |
"""
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
updated_count = 0
|
| 540 |
-
|
| 541 |
for item in items_db:
|
| 542 |
-
|
| 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 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 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 |
-
|
|
|
|
| 654 |
"""
|
| 655 |
删除内容(仅作者或管理员可操作)
|
| 656 |
🔧 Bug修复:使用 atomic_update 避免并发竞态条件
|
| 657 |
"""
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 764 |
"""
|
| 765 |
为资源评分(原子操作,并发安全)
|
| 766 |
⭐ score: 1-5
|
| 767 |
"""
|
| 768 |
-
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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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
|
| 935 |
except ImportError:
|
| 936 |
raise HTTPException(status_code=500, detail="预缓存模块不可用")
|
| 937 |
|
| 938 |
-
|
| 939 |
-
|
| 940 |
-
|
| 941 |
-
|
| 942 |
-
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
@
|
| 163 |
async def send_private_message(request: Request, msg: PrivateMessage, current_user: str = Depends(require_auth)):
|
| 164 |
-
|
| 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 |
-
|
| 188 |
-
|
| 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 |
-
|
| 225 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
| 374 |
"""
|
| 375 |
标记消息为已读(原子操作,并发安全)
|
| 376 |
"""
|
| 377 |
# 🔒 P0安全加固:补全鉴权,防止任意用户篡改他人消息已读状态
|
| 378 |
if account != current_user:
|
| 379 |
raise HTTPException(status_code=403, detail="无权操作他人消息")
|
| 380 |
-
|
| 381 |
-
|
| 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 |
-
|
| 2 |
-
#
|
| 3 |
-
#
|
| 4 |
-
#
|
| 5 |
-
#
|
| 6 |
-
#
|
| 7 |
-
#
|
| 8 |
-
#
|
| 9 |
-
# -
|
| 10 |
-
# -
|
| 11 |
-
# -
|
| 12 |
-
#
|
| 13 |
-
#
|
| 14 |
-
# -
|
| 15 |
-
# -
|
| 16 |
-
#
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
import
|
| 20 |
-
import
|
| 21 |
-
import
|
| 22 |
-
import
|
| 23 |
-
import
|
| 24 |
-
import
|
| 25 |
-
import
|
| 26 |
-
from
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
from fastapi import
|
| 30 |
-
from
|
| 31 |
-
from sqlalchemy
|
| 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
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
"
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
class
|
| 236 |
-
token: str = Field(..., description="
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
"""
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
)
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
""
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
"
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
"
|
| 316 |
-
"
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
"
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
"""
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
)
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
"
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
)
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
db.
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
if
|
| 495 |
-
raise HTTPException(
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
)
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
)
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 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
|
| 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 |
-
@
|
| 153 |
async def create_post(request: Request, post: PostCreate, current_user: str = Depends(require_auth)):
|
| 154 |
"""
|
| 155 |
发布帖子
|
| 156 |
"""
|
| 157 |
-
|
| 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
|
| 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 |
-
|
| 197 |
-
|
| 198 |
-
# 🗂️ 清除排序缓存
|
| 199 |
-
sort_cache.invalidate("posts:")
|
| 200 |
|
| 201 |
return {"status": "success", "data": new_post}
|
| 202 |
|
| 203 |
@router.put("/api/posts/{post_id}")
|
| 204 |
-
|
|
|
|
| 205 |
"""
|
| 206 |
更新帖子(仅作者可操作,原子操作,并发安全)
|
| 207 |
"""
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 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 |
-
|
| 252 |
-
|
| 253 |
-
删除帖子
|
| 254 |
-
|
| 255 |
-
|
| 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 |
-
|
|
|
|
| 276 |
"""
|
| 277 |
为帖子评分(原子操作,并发安全)
|
| 278 |
⭐ score: 1-5
|
| 279 |
"""
|
| 280 |
-
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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 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 |
|
| 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} |
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 642 |
-
|
| 643 |
-
|
| 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 |
-
|
|
|
|
| 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
|
| 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
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 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 =
|
| 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 |
-
@
|
| 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 |
-
|
| 188 |
-
|
| 189 |
-
|
| 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 |
-
@
|
| 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 |
-
|
| 237 |
-
|
| 238 |
-
|
| 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 =
|
| 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 |
-
|
| 329 |
-
|
| 330 |
-
|
| 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 |
-
|
| 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 =
|
| 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 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
price_updated = True
|
| 374 |
prompt_data = dict(prompt)
|
| 375 |
prompt_data.pop("viewed_by", None)
|
| 376 |
result.append(prompt_data)
|
| 377 |
|
| 378 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 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 |
-
|
|
|
|
| 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 =
|
| 447 |
if category not in categories.get(prompt.prompt_type, []):
|
| 448 |
raise HTTPException(status_code=400, detail="所选分类不存在")
|
| 449 |
|
| 450 |
-
price =
|
| 451 |
-
if 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
|
| 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 |
-
|
| 499 |
-
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 612 |
|
| 613 |
-
|
| 614 |
-
|
| 615 |
return {"status": "success"}
|
| 616 |
|
| 617 |
|
| 618 |
@router.delete("/api/prompts/{prompt_id}")
|
| 619 |
-
|
|
|
|
| 620 |
"""
|
| 621 |
删除提示词(仅作者可操作)
|
| 622 |
"""
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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} |
|
| 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 |
-
@
|
| 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 |
-
|
| 890 |
-
|
| 891 |
-
|
| 892 |
-
|
| 893 |
-
|
| 894 |
-
|
| 895 |
-
|
| 896 |
-
|
| 897 |
-
|
| 898 |
-
|
| 899 |
-
|
| 900 |
-
|
| 901 |
-
|
| 902 |
-
|
| 903 |
-
|
| 904 |
-
|
| 905 |
-
|
| 906 |
-
|
| 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 |
-
#
|
| 943 |
try:
|
| 944 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 945 |
|
| 946 |
-
|
| 947 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 948 |
Ownership.account == current_user,
|
| 949 |
Ownership.item_id == prompt_id,
|
| 950 |
Ownership.is_refunded == False
|
| 951 |
).first()
|
| 952 |
-
if
|
| 953 |
db_session.rollback()
|
| 954 |
-
return
|
| 955 |
-
|
| 956 |
-
|
| 957 |
-
|
| 958 |
-
|
| 959 |
-
|
| 960 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 961 |
raise HTTPException(status_code=402, detail="余额不足,请先充值")
|
| 962 |
-
|
| 963 |
-
|
| 964 |
-
|
| 965 |
-
|
| 966 |
-
|
| 967 |
-
|
| 968 |
-
|
| 969 |
-
|
| 970 |
-
|
| 971 |
-
|
| 972 |
-
|
| 973 |
-
|
| 974 |
-
|
| 975 |
-
|
| 976 |
-
|
| 977 |
-
|
| 978 |
-
|
| 979 |
-
|
| 980 |
-
|
| 981 |
-
|
| 982 |
-
|
| 983 |
-
|
| 984 |
-
|
| 985 |
-
|
| 986 |
-
|
| 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
|
| 1039 |
db_session.rollback()
|
| 1040 |
-
logger.error(
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 1097 |
-
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 147 |
-
|
| 148 |
-
|
| 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 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
resp_headers["Content-Length"] = content_length
|
| 174 |
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|