diff --git a/Dockerfile b/Dockerfile index c83f7299184980633a7c970dd2ac5d254092ea73..fa07ade96cf15a83ac3c29eb24e6b95d57441d82 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ -# 🚀 P2优化:使用轻量级基镜像,从 900MB 降到 ~150MB -FROM python:3.9-slim +# 使用Python 3.11 slim基镜像;包含FFmpeg后的实际镜像体积需测量 +FROM python:3.11-slim # 设置工作目录 WORKDIR /code @@ -13,8 +13,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* # 先复制依赖文件,利用 Docker 缓存层 -COPY ./requirements.txt /code/requirements.txt -RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt +COPY ./requirements.txt ./requirements.lock ./requirements-build.lock /code/ +RUN python -m pip install --no-cache-dir --require-hashes --only-binary=:all: -r /code/requirements-build.lock \ + && python -m pip install --no-cache-dir --require-hashes --no-build-isolation -r /code/requirements.lock \ + && python -m pip check # 复制应用代码 COPY . . @@ -29,4 +31,4 @@ HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD python -c "import urllib # force rebuild: 2026-08-16 # 启动命令 -CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"] \ No newline at end of file +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"] diff --git a/app.py b/app.py index 1c74619bdf5758a6e4207c5724608383a714fcdf..b248b775ecfe48dadf05c310ba8f80883f76e46e 100644 --- a/app.py +++ b/app.py @@ -1,22 +1,22 @@ # ⚙️ 后端逻辑/核心服务端.py (Hugging Face Spaces app.py) from fastapi import FastAPI, File, UploadFile, Form, Depends, HTTPException, Request +from rate_limit import shared_limit +from response_privacy import requires_no_store from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import Response, JSONResponse, FileResponse from sqlalchemy.orm import Session from pydantic import BaseModel from huggingface_hub import hf_hub_download, HfApi -import hashlib import uuid import urllib.parse import urllib.request import urllib.error import os import json -import mimetypes import logging import time -import subprocess import datetime +import threading import 数据库连接 as db import asyncio from 安全认证 import require_auth @@ -31,6 +31,8 @@ logging.basicConfig( datefmt='%Y-%m-%d %H:%M:%S' ) logger = logging.getLogger("ComfyUI-Ranking") +_UPLOAD_CONCURRENCY = max(1, min(int(os.environ.get("MAX_CONCURRENT_UPLOADS", "4")), 16)) +_UPLOAD_SEMAPHORE = threading.BoundedSemaphore(_UPLOAD_CONCURRENCY) from 云端_定时版本检测引擎 import daily_version_check_task, reset_daily_views_task @@ -62,15 +64,9 @@ from router_admin_plugins import router as admin_plugins_router # 🔒 管理 from database_sql import init_sql_db, get_db, SessionLocal from models_sql import SystemConfig # 🧹 P2清理:Ownership 在本文件无引用 -# 🚀 P2优化:速率限制 (防止暴力攻击) -from slowapi import Limiter, _rate_limit_exceeded_handler -from slowapi.util import get_remote_address -from slowapi.errors import RateLimitExceeded from fastapi.exceptions import RequestValidationError from starlette.exceptions import HTTPException as StarletteHTTPException -import traceback -limiter = Limiter(key_func=get_remote_address) app = FastAPI( title="ComfyUI Ranking API", description="ComfyUI 社区排名系统 API", @@ -78,8 +74,6 @@ app = FastAPI( docs_url="/api/docs", redoc_url="/api/redoc" ) -app.state.limiter = limiter -app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) # ========================================== @@ -107,7 +101,7 @@ def create_error_response(status_code: int, detail: str, error_type: str = None, @app.exception_handler(StarletteHTTPException) async def http_exception_handler(request, exc): """🚀 P3优化:HTTP 异常处理,返回统一格式""" - logger.warning(f"HTTP {exc.status_code} | {request.url.path} | {exc.detail}") + logger.warning(f"HTTP {exc.status_code} | {request.url.path} | detail_type={type(exc.detail).__name__}") # 根据状态码确定错误类型 error_type_map = { @@ -117,6 +111,7 @@ async def http_exception_handler(request, exc): 404: "not_found", 409: "conflict", 429: "rate_limited", + 502: "upstream_error", } error_type = error_type_map.get(exc.status_code, "http_error") @@ -125,16 +120,40 @@ async def http_exception_handler(request, exc): content=create_error_response(exc.status_code, str(exc.detail), error_type) ) +from upload_body_guard import UploadBodyGuard +app.add_middleware(UploadBodyGuard, capacity=_UPLOAD_CONCURRENCY, error_response=create_error_response) + +from public_list_etag import PublicListETag +app.add_middleware(PublicListETag) + +@app.middleware("http") +async def private_response_cache_control(request: Request, call_next): + response = await call_next(request) + if requires_no_store(request.url.path, request.headers.get("Authorization")): + response.headers["Cache-Control"] = "no-store" + vary = [name.strip() for name in response.headers.get("Vary", "").split(",") if name.strip()] + if not any(name.lower() in {"authorization", "*"} for name in vary): + vary.append("Authorization") + response.headers["Vary"] = ", ".join(vary) + return response + + @app.exception_handler(RequestValidationError) async def validation_exception_handler(request, exc): """🚀 P3优化:请求参数校验失败处理""" - logger.warning(f"Validation Error | {request.url.path} | {exc.errors()}") + # Pydantic errors may include rejected input values (passwords, tokens, private text). + # Keep only field paths and machine error codes in public runtime logs. + error_fields = [ + {"field": ".".join(str(loc) for loc in err.get("loc", ())[1:]), "type": err.get("type", "unknown")} + for err in exc.errors() + ] + logger.warning(f"Validation Error | {request.url.path} | fields={error_fields}") # 提取简洁的错误信息 errors = [] for err in exc.errors(): field = ".".join(str(loc) for loc in err["loc"][1:]) # 跳过 'body' - errors.append({"field": field, "message": err["msg"], "type": err["type"]}) + errors.append({"field": field, "message": "该字段格式不正确", "type": err["type"]}) return JSONResponse( status_code=422, @@ -150,8 +169,7 @@ async def validation_exception_handler(request, exc): async def global_exception_handler(request, exc): """🚀 P3优化:全局异常处理,捕获所有未预期异常""" error_id = f"ERR_{int(time.time())}_{id(exc) % 10000}" - logger.error(f"Unhandled Exception [{error_id}] | {request.url.path} | {type(exc).__name__}: {exc}") - logger.error(traceback.format_exc()) + logger.error(f"Unhandled Exception [{error_id}] | {request.url.path} | error_type={type(exc).__name__}") return JSONResponse( status_code=500, content=create_error_response( @@ -187,7 +205,8 @@ def detailed_health_check(): health_status["components"]["sql_database"] = "error: connection failed" health_status["status"] = "degraded" except Exception as e: - health_status["components"]["sql_database"] = f"error: {str(e)}" + logger.warning(f"健康检查 SQL 数据库异常: error_type={type(e).__name__}") + health_status["components"]["sql_database"] = "error: connection failed" health_status["status"] = "degraded" # 检查 JSON 文件访问 @@ -196,7 +215,8 @@ def detailed_health_check(): json_db.load_data("users.json", default_data={}) health_status["components"]["json_storage"] = "ok" except Exception as e: - health_status["components"]["json_storage"] = f"error: {str(e)}" + logger.warning(f"健康检查 JSON 存储异常: error_type={type(e).__name__}") + health_status["components"]["json_storage"] = "error: storage unavailable" health_status["status"] = "degraded" return health_status @@ -214,7 +234,7 @@ async def on_startup(): init_sql_db() logger.info("✅ SQL 数据库初始化完成") except Exception as e: - logger.error(f"❌ SQL 数据库初始化失败: {e}") + logger.error(f"❌ SQL 数据库初始化失败: error_type={type(e).__name__}") # 不抛出异常,允许降级运行 # ========== 加载第三方插件注册表(依赖 SQL 数据库) ========== @@ -223,7 +243,7 @@ async def on_startup(): load_plugins_from_db() logger.info("✅ 第三方插件注册表已加载") except Exception as e: - logger.warning(f"⚠️ 第三方插件注册表加载失败: {e}") + logger.warning(f"⚠️ 第三方插件注册表加载失败: error_type={type(e).__name__}") # ========== 预热检查:JSON 数据库 ========== try: @@ -231,8 +251,26 @@ async def on_startup(): db.load_data("items.json", default_data=[]) logger.info("✅ JSON 数据库访问正常") except Exception as e: - logger.warning(f"⚠️ JSON 数据库访问异常: {e}") + logger.warning(f"⚠️ JSON 数据库访问异常: error_type={type(e).__name__}") + # SQL资金已冻结但JSON发布失败的任务,重启后继续投影;持有句柄供停机收尾。 + from task_publication import publication_worker + from database_sql import engine + from db_utils import sort_cache + app.state.task_publication_stop = asyncio.Event() + app.state.task_publication_worker = asyncio.create_task(publication_worker( + engine, db, sort_cache.invalidate, app.state.task_publication_stop)) + + from resource_publication import publication_worker as resource_publication_worker + app.state.resource_publication_stop = asyncio.Event() + app.state.resource_publication_worker = asyncio.create_task(resource_publication_worker( + engine, db, sort_cache.invalidate, app.state.resource_publication_stop)) + + from resource_deletion import resource_deletion_worker + app.state.resource_deletion_stop = asyncio.Event() + app.state.resource_deletion_worker = asyncio.create_task(resource_deletion_worker( + db, app.state.resource_deletion_stop, sort_cache.invalidate)) + # ========== 启动后台任务 ========== asyncio.create_task(daily_version_check_task()) logger.info("✅ 定时版本检测任务已挂载") @@ -242,7 +280,7 @@ async def on_startup(): # ========== 启动 HF 批量同步定时器 ========== db.start_batch_sync() - logger.info("✅ HF 批量同步定时器已启动") + logger.info("✅ HF 同步调度初始化已完成") logger.info("🎉 ComfyUI-Ranking API 启动完成!") @@ -252,9 +290,30 @@ async def on_shutdown(): """优雅关闭,清理资源""" logger.info("🛑 ComfyUI-Ranking API 正在关闭...") + publication_stop = getattr(app.state, "task_publication_stop", None) + publication_task = getattr(app.state, "task_publication_worker", None) + if publication_stop is not None: + publication_stop.set() + if publication_task is not None: + await publication_task + + resource_stop = getattr(app.state, "resource_publication_stop", None) + resource_task = getattr(app.state, "resource_publication_worker", None) + if resource_stop is not None: + resource_stop.set() + if resource_task is not None: + await resource_task + + deletion_stop = getattr(app.state, "resource_deletion_stop", None) + deletion_task = getattr(app.state, "resource_deletion_worker", None) + if deletion_stop is not None: + deletion_stop.set() + if deletion_task is not None: + await deletion_task + # ========== 关闭前同步所有脏文件 ========== - db.flush_sync() - logger.info("✅ HF 批量同步已完成") + await asyncio.to_thread(db.flush_sync) + logger.info("✅ HF 关闭前同步尝试已结束") # 这里可以添加其他清理逻辑(如关闭连接池等) logger.info("✅ 关闭完成") @@ -264,7 +323,10 @@ async def on_shutdown(): # 传递 Token 且 allow_credentials=False 不依赖 Cookie,通配风险可控)。 # 如需收紧可在环境变量 ALLOWED_ORIGINS 中逗号分隔指定域名。 ALLOWED_ORIGINS = os.environ.get("ALLOWED_ORIGINS", "*").split(",") -logger.info(f"CORS 允许域名: {ALLOWED_ORIGINS}") +logger.info( + f"CORS 配置已加载: origin_count={len(ALLOWED_ORIGINS)} | " + f"wildcard={len(ALLOWED_ORIGINS) == 1 and ALLOWED_ORIGINS[0] == '*'}" +) app.add_middleware( CORSMiddleware, @@ -272,6 +334,7 @@ app.add_middleware( allow_credentials=False, allow_methods=["*"], allow_headers=["*"], + expose_headers=["ETag"], ) # ========================================== @@ -298,46 +361,12 @@ app.include_router(admin_plugins_router) # 🔒 管理后台 - 第三方插件 # 🟢 私有图床代理中心 (Image Proxy) # 解决 Private 仓库下,本地客户端报 401 Unauthorized 的终极方案 # ========================================== +from media_proxy_service import read_public_media + @app.get("/api/image_proxy") def proxy_hf_image(url: str = None, path: str = None): - """云端图片代理:使用云端的 HF_TOKEN 提取私有图床图片并返回给没有任何权限的本地端""" - - # 兼容处理:如果本地发来的是已经被污染的老版本 HF 直链,我们自动将其转换为相对路径 - if url and url.startswith("https://huggingface.co/datasets/"): - try: - path = url.split("resolve/main/")[-1] - path = urllib.parse.unquote(path) - except Exception: - raise HTTPException(status_code=400, detail="无效的 HF 原链接格式") - - if not path: - raise HTTPException(status_code=400, detail="缺少路径参数") - - # 🛡️ 路径穿越防护:禁止包含 .. 和绝对路径 - if ".." in path or path.startswith("/"): - raise HTTPException(status_code=400, detail="非法路径") - - # 🛡️ 绝对的安全红线:限制只能代理下载图片目录,严禁黑客通过此接口下载 users.json 或账本数据! - allowed_dirs = ["uploads/", "avatars/", "covers/"] - if not any(path.startswith(d) for d in allowed_dirs): - raise HTTPException(status_code=403, detail="非法访问:该接口仅允许代理图片资源") - - hf_token = os.environ.get("HF_TOKEN") - dataset_repo_id = db.DATASET_REPO_ID - - try: - # hf_hub_download 会自动利用云端容器的缓存,只有第一次会去真实请求 Dataset - cached_file_path = hf_hub_download( - repo_id=dataset_repo_id, - repo_type="dataset", - filename=path, - token=hf_token - ) - # 智能识别文件类型 (image/jpeg, image/png 等) - content_type, _ = mimetypes.guess_type(cached_file_path) - return FileResponse(cached_file_path, media_type=content_type or "application/octet-stream") - except Exception as e: - return JSONResponse(content={"error": f"代理获取图片失败: {str(e)}"}, status_code=404) + return read_public_media(url, path, store=db, download=hf_hub_download, + file_response=FileResponse, error_response=create_error_response) # ========================================== # 上传接口 (将返回的 URL 替换为 Proxy 代理链接) @@ -348,148 +377,18 @@ from image_moderation import moderate_image_sync, ModerationResult # ========================================== # 🎵 音频安全净化:重编码压缩,剥离元数据与隐藏附加信息 # ========================================== -AUDIO_TRANSCODE_ARGS = { - "mp3": ["-acodec", "libmp3lame", "-b:a", "192k"], - "wav": ["-acodec", "pcm_s16le", "-ar", "44100"], - "m4a": ["-acodec", "aac", "-b:a", "192k"], - "ogg": ["-acodec", "libvorbis", "-b:a", "192k"], -} - -def sanitize_audio_content(content: bytes, ext: str) -> bytes: - """🔒 用 ffmpeg 重编码压缩音频并剥离元数据/隐藏附加信息;处理失败一律拒绝上传,保障平台安全""" - src_path = f"/tmp/audio_src_{uuid.uuid4().hex[:8]}.{ext}" - dst_path = f"/tmp/audio_dst_{uuid.uuid4().hex[:8]}.{ext}" - try: - with open(src_path, "wb") as f: - f.write(content) - cmd = ["ffmpeg", "-y", "-i", src_path, "-vn", "-map_metadata", "-1", "-fflags", "+bitexact"] \ - + AUDIO_TRANSCODE_ARGS[ext] + [dst_path] - try: - result = subprocess.run(cmd, capture_output=True, timeout=120) - except subprocess.TimeoutExpired: - logger.error(f"AUDIO_SANITIZE_TIMEOUT | ext={ext}") - raise HTTPException(status_code=400, detail="音频处理超时,请上传更短的音频") - if result.returncode != 0: - logger.error(f"AUDIO_SANITIZE_FAIL | ext={ext} | {result.stderr[-500:]}") - raise HTTPException(status_code=400, detail="音频文件解析失败,请转换格式后重新上传") - with open(dst_path, "rb") as f: - sanitized = f.read() - if not sanitized: - raise HTTPException(status_code=400, detail="音频处理结果为空,请重新上传") - logger.info(f"AUDIO_SANITIZE_OK | ext={ext} | {len(content)} -> {len(sanitized)} bytes") - return sanitized - finally: - for p in (src_path, dst_path): - if os.path.exists(p): - os.remove(p) - +from upload_service import process_upload @app.post("/api/upload") -@limiter.limit("30/minute") # 🔒 安全限流:防止刷存储(单次发布最多 9 图 + 1 JSON,阈值留足余量) +@shared_limit(30) def upload_file(request: Request, file: UploadFile = File(...), file_type: str = Form(...), current_user: str = Depends(require_auth)): - # 🟢 动态文件大小风控 - # 🔒 安全修复:上限必须先算出来。原实现是先把整个文件 read() 进内存再判大小, - # 超大文件会直接吃满容器内存(可被用于打挂服务),现改为分块读取、超限立即中止。 - max_size = 10 * 1024 * 1024 - if file_type == "avatar": - max_size = 2 * 1024 * 1024 - elif file_type == "cover": - # GIF 动图允许更大文件(前端不压缩,保留动画) - if file.filename and file.filename.lower().endswith('.gif'): - max_size = 50 * 1024 * 1024 # GIF: 50MB - else: - max_size = 5 * 1024 * 1024 # 其他图片: 5MB - elif file_type == "banner": - # 广告横幅图片:支持GIF动图 - if file.filename and file.filename.lower().endswith('.gif'): - max_size = 50 * 1024 * 1024 # GIF: 50MB - else: - max_size = 10 * 1024 * 1024 # 其他图片: 10MB - elif file_type == "post_video": - max_size = 100 * 1024 * 1024 - elif file_type == "post_audio": - max_size = 30 * 1024 * 1024 # 音乐提示词音频:30MB - - # 分块读取(1MB / 块),累计超限则马上报错,不再继续消耗内存与带宽 - _CHUNK_SIZE = 1024 * 1024 - _chunks = [] - _received = 0 - while True: - _chunk = file.file.read(_CHUNK_SIZE) - if not _chunk: - break - _received += len(_chunk) - if _received > max_size: - raise HTTPException(status_code=400, detail=f"文件过大,{file_type} 类型请限制在 {max_size // (1024*1024)}MB 以内") - _chunks.append(_chunk) - content = b"".join(_chunks) - - if not file.filename: - raise HTTPException(status_code=400, detail="文件名不能为空") - ext = file.filename.split(".")[-1].lower() - allowed_exts = ["jpg", "jpeg", "png", "gif", "webp", "json", "mp4"] - if file_type == "post_video": - allowed_exts = ["mp4", "webm", "mov"] - elif file_type == "post_audio": - allowed_exts = ["mp3", "wav", "m4a", "ogg"] - if ext not in allowed_exts: - raise HTTPException(status_code=400, detail=f"不支持的文件格式,{file_type} 类型仅支持 {', '.join(allowed_exts)}") - - # 🔒 音频安全净化:重编码压缩,剥离可能附带的元数据与隐藏附加信息 - if file_type == "post_audio": - content = sanitize_audio_content(content, ext) - if len(content) > max_size: - raise HTTPException(status_code=400, detail=f"音频处理后仍超过 {max_size // (1024*1024)}MB 限制,请上传更短的音频") - - # 📝 审计日志:记录上传操作 - logger.info(f"UPLOAD | account={current_user} | filename={file.filename} | file_type={file_type}") - - # 🔒 P0安全优化:图片内容审核 - if ext in ["jpg", "jpeg", "png", "gif", "webp"]: - moderation_result = moderate_image_sync(content, ext) - if not moderation_result.passed: - raise HTTPException( - status_code=400, - detail=f"图片内容审核未通过:{moderation_result.label or '内容不合规'}" - ) - - if file_type in ("post_video", "post_audio"): - safe_filename = f"{file_type}_{uuid.uuid4().hex[:8]}.{ext}" - else: - file_hash = hashlib.md5(content).hexdigest()[:10] - safe_filename = f"{file_type}_{file_hash}.{ext}" - - local_tmp_path = f"/tmp/{safe_filename}" - with open(local_tmp_path, "wb") as f: - f.write(content) - - hf_token = os.environ.get("HF_TOKEN") - dataset_repo_id = "ZHIWEI666/ComfyUI-Ranking" - - try: - api = HfApi() - api.upload_file( - path_or_fileobj=local_tmp_path, - path_in_repo=f"uploads/{file_type}/{safe_filename}", - repo_id=dataset_repo_id, - repo_type="dataset", - token=hf_token, - commit_message=f"Upload media: {safe_filename}" - ) - except Exception as e: - raise HTTPException(status_code=500, detail=f"图床同步失败: {str(e)}") - finally: - if os.path.exists(local_tmp_path): - os.remove(local_tmp_path) - - # 🚀 核心修复:不再返回暴露隐私且报 401 的 HF 直链,而是返回我们刚写好的 Proxy 代理链接 - if file_type == "post_video": - # 视频文件返回相对路径,由前端 proxyImages 统一处理 - # 前端会将其转换为 /community_hub/video 本地视频代理(支持 Range 请求和流式传输) - permanent_url = f"uploads/{file_type}/{safe_filename}" - else: - permanent_url = f"https://zhiwei666-comfyui-ranking-api.hf.space/api/image_proxy?path=uploads/{file_type}/{safe_filename}" - return {"status": "success", "url": permanent_url} + return process_upload(file, file_type, current_user, store=db, storage_factory=HfApi, + moderate=moderate_image_sync, limiter=_UPLOAD_SEMAPHORE, + error_response=create_error_response) + + + +from github_source import select_github_token class ValidateResourceRequest(BaseModel): @@ -497,75 +396,14 @@ class ValidateResourceRequest(BaseModel): item_id: str account: str -@app.post("/api/validate_resource") -def validate_resource(req_data: ValidateResourceRequest, sql_db: Session = Depends(get_db)): - target_url = req_data.url - - # 允许的链接前缀:HuggingFace数据集、GitHub仓库、内部代理URL - ALLOWED_URL_PREFIXES = ( - "https://huggingface.co/datasets/", - "https://github.com/", - "https://zhiwei666-comfyui-ranking-api.hf.space/api/image_proxy", - ) - - if not any(target_url.startswith(prefix) for prefix in ALLOWED_URL_PREFIXES): - return JSONResponse(content={"error": "无效的下载链接"}, status_code=400) - - items_db = db.load_data("items.json", default_data=[]) - item = next((i for i in items_db if i["id"] == req_data.item_id), None) - - if not item: return JSONResponse(content={"error": "资源不存在或已被删除"}, status_code=404) - - # 内部代理URL直接返回成功(文件已存在于服务器本地) - if target_url.startswith("https://zhiwei666-comfyui-ranking-api.hf.space/api/image_proxy"): - return {"status": "success", "message": "资源有效"} - - hf_token = os.environ.get("HF_TOKEN") - if not hf_token: return JSONResponse(content={"error": "云端环境变量未配置 HF_TOKEN"}, status_code=401) - - try: - if target_url.startswith("https://github.com/"): - creator_token = item.get("github_token") - fallback_token = os.environ.get("GITHUB_PAT") - active_token = creator_token if creator_token else fallback_token - - headers = {"User-Agent": "ComfyUI-Ranking-SaaS"} - if active_token: - headers["Authorization"] = f"Bearer {active_token}" - - repo_parts = target_url.rstrip("/").split("/") - if len(repo_parts) < 2: return JSONResponse(content={"error": "无效的仓库地址格式"}, status_code=400) - owner, repo = repo_parts[-2], repo_parts[-1] - api_url = f"https://api.github.com/repos/{owner}/{repo}" - - req = urllib.request.Request(api_url, headers=headers) - with urllib.request.urlopen(req) as response: - if response.status != 200: - return JSONResponse(content={"error": "资源仓库不可访问,可能已被作者删除或设为私有"}, status_code=404) - return {"status": "success", "message": "资源有效"} - - elif target_url.startswith("https://huggingface.co/datasets/"): - repo_path_encoded = target_url.split("resolve/main/")[-1] - repo_path = urllib.parse.unquote(repo_path_encoded) - - cached_file_path = hf_hub_download( - repo_id=db.DATASET_REPO_ID, - repo_type="dataset", - filename=repo_path, - token=hf_token - ) - if not os.path.exists(cached_file_path): - return JSONResponse(content={"error": "云端文件不存在,可能已被作者删除"}, status_code=404) - - return {"status": "success", "message": "资源有效"} - - except urllib.error.HTTPError as e: - return JSONResponse(content={"error": f"资源探测失败,源站返回: {e.code}。请联系作者处理。"}, status_code=400) - except Exception as e: - return JSONResponse(content={"error": f"资源探测异常: {str(e)}"}, status_code=500) - - return {"status": "success"} +from resource_probe_service import probe_resource +from github_probe_transport import open_github_probe +@app.post("/api/validate_resource") +def validate_resource(req_data: ValidateResourceRequest, sql_db: Session = Depends(get_db), current_user: str = Depends(require_auth)): + return probe_resource(req_data.item_id, store=db, download=hf_hub_download, + open_url=open_github_probe, token_selector=select_github_token, + error_response=create_error_response) # ========================================== # 🔒 管理员:系统配置 API @@ -592,7 +430,7 @@ def _load_system_config_from_db(db: Session) -> dict: result[cfg.config_key] = cfg.config_value return result except Exception as e: - logger.warning(f"从数据库加载系统配置失败,降级到文件: {e}") + logger.warning(f"从数据库加载系统配置失败,降级到文件: error_type={type(e).__name__}") return _load_system_config_from_file() def _load_system_config_from_file() -> dict: @@ -602,7 +440,7 @@ def _load_system_config_from_file() -> dict: with open(SYSTEM_CONFIG_FILE, "r") as f: return json.load(f) except Exception as e: - logger.debug(f"加载系统配置文件失败: {e}") + logger.debug(f"加载系统配置文件失败: error_type={type(e).__name__}") return {} 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 db.commit() except Exception as e: db.rollback() - logger.warning(f"保存系统配置到数据库失败,降级到文件: {e}") + logger.warning(f"保存系统配置到数据库失败,降级到文件: error_type={type(e).__name__}") # 降级:保存到文件 config = _load_system_config_from_file() config[config_key] = value @@ -626,7 +464,7 @@ def _save_system_config_to_db(db: Session, config_key: str, value, updated_by: s with open(SYSTEM_CONFIG_FILE, "w") as f: json.dump(config, f, ensure_ascii=False) except Exception as fe: - logger.warning(f"降级保存系统配置文件也失败: {fe}") + logger.warning(f"降级保存系统配置文件也失败: error_type={type(fe).__name__}") # 兼容旧调用的包装函数 def _load_system_config(db: Session = None) -> dict: diff --git a/chat_store.py b/chat_store.py new file mode 100644 index 0000000000000000000000000000000000000000..b69a9b66d7d18fa96da786ffd1e9f0f092c52ec1 --- /dev/null +++ b/chat_store.py @@ -0,0 +1,157 @@ +"""私信按真实参与者读取,所有消息修改使用锁内最新记录。""" +import copy +import logging +import math +import time +import uuid + +from fastapi import HTTPException +import 数据库连接 as db + +logger = logging.getLogger(__name__) +HISTORY_TTL = 7 * 24 * 3600 +MAX_MESSAGE_LENGTH = 1000 +MESSAGE_FIELDS = frozenset({"id", "sender", "receiver", "content", "created_at", "is_read"}) + + +def _unavailable(): + return HTTPException(status_code=503, detail="聊天数据暂不可用") + + +def _timestamp(message): + value = message["created_at"] + return value / 1000 if value >= 1e12 else value + + +def _validate(data): + if not isinstance(data, dict): + raise _unavailable() + for key, messages in data.items(): + if not isinstance(key, str) or not isinstance(messages, list): + raise _unavailable() + for message in messages: + if not isinstance(message, dict): + raise _unavailable() + if any(not isinstance(message.get(field), str) or not message[field] + for field in ("id", "sender", "receiver")): + raise _unavailable() + timestamp = message.get("created_at") + if not isinstance(message.get("content"), str) or type(timestamp) not in (int, float): + raise _unavailable() + try: + valid_timestamp = math.isfinite(timestamp) and timestamp >= 0 + except (OverflowError, ValueError): + valid_timestamp = False + if not valid_timestamp or type(message.get("is_read", False)) is not bool: + raise _unavailable() + return data + + +def _read(name): + try: + return db.load_data(name, default_data={}, skip_cache=True, strict=True) + except Exception as error: + logger.warning("聊天读取失败: %s", type(error).__name__) + raise _unavailable() from None + + +def _mutate(updater): + try: + return db.atomic_update("chats.json", lambda data: updater(_validate(data)), default_data={}) + except HTTPException: + raise + except Exception as error: + logger.warning("聊天写入失败: %s", type(error).__name__) + raise _unavailable() from None + + +def _pair(message, account, target): + return ((message["sender"] == account and message["receiver"] == target) + or (message["sender"] == target and message["receiver"] == account)) + + +def _project(message): + result = copy.deepcopy({key: value for key, value in message.items() if key in MESSAGE_FIELDS}) + result.setdefault("is_read", False) + return result + + +def send_message(sender, receiver, content): + if not isinstance(receiver, str) or not receiver or not isinstance(content, str) or not content.strip() or len(content) > MAX_MESSAGE_LENGTH: + raise HTTPException(status_code=400, detail="私信内容或接收者不正确") + users = _read("users.json") + if not isinstance(users, dict): + raise _unavailable() + if receiver not in users: + raise HTTPException(status_code=404, detail="接收者不存在") + if not isinstance(users[receiver], dict) or users[receiver].get("account", receiver) != receiver: + raise _unavailable() + message = {"id": f"chat_{int(time.time())}_{uuid.uuid4().hex}", "sender": sender, + "receiver": receiver, "content": content, "created_at": int(time.time()), "is_read": False} + key = f"{min(sender, receiver)}_{max(sender, receiver)}" + _mutate(lambda data: data.setdefault(key, []).append(message)) + + +def chat_list(account): + data = _validate(_read("chats.json")) + users = _read("users.json") + if not isinstance(users, dict): + raise _unavailable() + conversations = {} + # 旧键的下划线编码可能相撞,账号子串也不能代表参与者。 + for messages in data.values(): + for message in messages: + if message["sender"] == account: + target = message["receiver"] + elif message["receiver"] == account: + target = message["sender"] + else: + continue + conversations.setdefault(target, []).append(message) + result = [] + for target, messages in conversations.items(): + user = users.get(target, {}) + if not isinstance(user, dict): + raise _unavailable() + last = max(messages, key=_timestamp) + result.append({"account": target, "name": user.get("name", target), "avatar": user.get("avatarDataUrl", ""), + "last_message": last["content"], "last_time": last["created_at"], + "unread_count": sum(message["receiver"] == account and not message.get("is_read", False) + for message in messages)}) + result.sort(key=lambda item: item["last_time"] / 1000 if item["last_time"] >= 1e12 else item["last_time"], reverse=True) + return result + + +def chat_history(account, target): + now = time.time() + data = _validate(_read("chats.json")) + + def expires(message): + return message.get("is_read", False) and now - _timestamp(message) >= HISTORY_TTL + + def matching(message): + return _pair(message, account, target) + + def needs_update(message): + return matching(message) and (expires(message) or (message["receiver"] == account and not message.get("is_read", False))) + + def snapshot(data): + return sorted((_project(message) for messages in data.values() for message in messages + if matching(message)), key=_timestamp) + + if not any(needs_update(message) for messages in data.values() for message in messages): + return snapshot(data) + + def update(data): + for key, messages in data.items(): + kept = [] + for message in messages: + if matching(message): + if expires(message): + continue + if message["receiver"] == account: + message["is_read"] = True + kept.append(message) + data[key] = kept + return snapshot(data) + return _mutate(update) diff --git a/comment_store.py b/comment_store.py new file mode 100644 index 0000000000000000000000000000000000000000..6ff7fcab3aaa7c9009757ce7a2f1d75057b58f3b --- /dev/null +++ b/comment_store.py @@ -0,0 +1,118 @@ +"""评论记录的锁内修改;路由负责认证、内容存在性及响应契约。""" +import copy +import logging +from fastapi import HTTPException +import 数据库连接 as db +from resource_deletion import resource_mutation_lock, assert_comment_target_active +from resource_store import ResourceStoreError + + +def _item_comments(data, item_id): + if not isinstance(data, dict): + raise HTTPException(status_code=503, detail="评论数据暂不可用") + records = data.get(item_id, []) + if not isinstance(records, list): + raise HTTPException(status_code=503, detail="评论数据暂不可用") + return records + + +def append_comment(item_id, record, parent_id=None, prepend=False): + def updater(data): + records = _item_comments(data, item_id) + if parent_id: + parent = next((entry for entry in records if isinstance(entry, dict) + and entry.get("id") == parent_id), None) + if parent is None: + raise HTTPException(status_code=404, detail="找不到要回复的评论") + replies = parent.setdefault("replies", []) + if not isinstance(replies, list): + raise HTTPException(status_code=503, detail="评论数据暂不可用") + replies.append(copy.deepcopy(record)) + elif prepend: + records.insert(0, copy.deepcopy(record)) + else: + records.append(copy.deepcopy(record)) + data[item_id] = records + try: + with resource_mutation_lock: + assert_comment_target_active(db, item_id) + db.atomic_update("comments.json", updater, default_data={}) + except ResourceStoreError as error: + raise HTTPException(status_code=error.status_code, detail=error.message) from None + + +def _find_comment(records, comment_id, depth=0): + if depth > 20 or not isinstance(records, list): + return None + for record in records: + if not isinstance(record, dict): + continue + if record.get("id") == comment_id: + return record + found = _find_comment(record.get("replies", []), comment_id, depth + 1) + if found is not None: + return found + return None + + +def delete_comment(item_id, comment_id, account, can_moderate=False): + def updater(data): + target = _find_comment(_item_comments(data, item_id), comment_id) + if target is None: + raise HTTPException(status_code=404, detail="找不到该评论") + # 在同一锁内检查最新目标并执行软删除,不能使用先前读取的快照。 + if target.get("author") != account and not can_moderate: + raise HTTPException(status_code=403, detail="无权删除此评论") + target["isDeleted"] = True + target["content"] = "" + db.atomic_update("comments.json", updater, default_data={}) + + +def increment_comment_count(file_name, item_id): + """派生计数独立更新;失败不能把已提交评论误报为失败或重放评论。""" + def updater(records): + for record in records: + if record.get("id") == item_id: + old_count = record.get("comments", 0) + record["comments"] = (old_count if type(old_count) is int and old_count >= 0 else 0) + 1 + break + try: + db.atomic_update(file_name, updater, default_data=[]) + return True + except Exception: + logging.getLogger(__name__).warning("评论已提交,派生计数暂未更新") + return False + + +def _visible_count(records, depth=0): + if not isinstance(records, list) or depth > 20: + raise ValueError("Invalid comment thread") + count = 0 + for record in records: + if not isinstance(record, dict) or not isinstance(record.get("id"), str) or not record["id"].strip(): + raise ValueError("Invalid comment record") + deleted = record.get("isDeleted", False) + if type(deleted) is not bool: + raise ValueError("Invalid comment state") + if not deleted: + count += 1 + count += _visible_count(record.get("replies", []), depth + 1) + return count + + +def attach_comment_counts(records): + """只校对响应副本,以评论主记录恢复展示计数,不写内容或资金数据。""" + try: + snapshot = db.load_data("comments.json", default_data={}, skip_cache=True, strict=True) + if not isinstance(snapshot, dict): + raise ValueError("Invalid comment data") + except Exception: + logging.getLogger(__name__).warning("评论计数无法校对,保留已有计数") + return records + for record in records: + try: + count = _visible_count(snapshot.get(record["id"], [])) + except (KeyError, TypeError, ValueError): + continue + record["comments"] = count + return records diff --git a/database_sql.py b/database_sql.py index eef5cfd4e69ed7d1191f06ff27f91445dbf51e89..b6bbe63ddc898cafb563b4beb3bd56963efa700e 100644 --- a/database_sql.py +++ b/database_sql.py @@ -77,10 +77,10 @@ def db_retry(max_retries: int = 3, delay: float = 0.5): last_error = e if attempt < max_retries - 1: wait_time = delay * (2 ** attempt) # 指数退避 - logger.warning(f"DB 操作失败 (第{attempt+1}次):{e}, {wait_time}秒后重试") + logger.warning(f"DB 操作失败 (第{attempt+1}次): error_type={type(e).__name__}, {wait_time}秒后重试") time.sleep(wait_time) else: - logger.error(f"DB 操作失败 (重试{max_retries}次后放弃): {e}") + logger.error(f"DB 操作失败 (重试{max_retries}次后放弃): error_type={type(e).__name__}") raise last_error return wrapper return decorator @@ -99,10 +99,10 @@ def init_sql_db(): return except Exception as e: if attempt < 2: - logger.warning(f"数据库初始化失败 (第{attempt+1}次): {e}") + logger.warning(f"数据库初始化失败 (第{attempt+1}次): error_type={type(e).__name__}") time.sleep(2 ** attempt) else: - logger.error(f"数据库初始化失败 (已重试3次): {e}") + logger.error(f"数据库初始化失败 (已重试3次): error_type={type(e).__name__}") raise @@ -130,7 +130,7 @@ def _auto_migrate_p7_fields(): conn.execute(text("ALTER TABLE ownerships ADD COLUMN IF NOT EXISTS price_paid INTEGER DEFAULT 0")) logger.info("迁移完成: 添加 ownerships.price_paid 字段") except Exception as e: - logger.debug(f"跳过 ownerships.price_paid (可能已存在): {e}") + logger.debug(f"跳过 ownerships.price_paid (可能已存在): error_type={type(e).__name__}") if 'is_refunded' not in columns: try: @@ -140,7 +140,7 @@ def _auto_migrate_p7_fields(): conn.execute(text("ALTER TABLE ownerships ADD COLUMN IF NOT EXISTS is_refunded BOOLEAN DEFAULT FALSE")) logger.info("迁移完成: 添加 ownerships.is_refunded 字段") except Exception as e: - logger.debug(f"跳过 ownerships.is_refunded (可能已存在): {e}") + logger.debug(f"跳过 ownerships.is_refunded (可能已存在): error_type={type(e).__name__}") if 'refunded_at' not in columns: try: @@ -150,7 +150,7 @@ def _auto_migrate_p7_fields(): conn.execute(text("ALTER TABLE ownerships ADD COLUMN IF NOT EXISTS refunded_at TIMESTAMP")) logger.info("迁移完成: 添加 ownerships.refunded_at 字段") except Exception as e: - logger.debug(f"跳过 ownerships.refunded_at (可能已存在): {e}") + logger.debug(f"跳过 ownerships.refunded_at (可能已存在): error_type={type(e).__name__}") conn.commit() @@ -167,7 +167,7 @@ def _auto_migrate_p7_fields(): conn.execute(text("ALTER TABLE wallets ADD COLUMN IF NOT EXISTS task_balance INTEGER DEFAULT 0")) logger.info("[DB Migration] 添加列 wallets.task_balance") except Exception as e: - logger.debug(f"跳过 wallets.task_balance (可能已存在): {e}") + logger.debug(f"跳过 wallets.task_balance (可能已存在): error_type={type(e).__name__}") conn.commit() @@ -206,7 +206,7 @@ def _auto_migrate_p7_fields(): conn.execute(text(f"ALTER TABLE transactions ADD COLUMN IF NOT EXISTS {col_name} {col_type}")) logger.info(f"[DB Migration] 添加列 transactions.{col_name}") except Exception as e: - logger.debug(f"跳过 transactions.{col_name} (可能已存在): {e}") + logger.debug(f"跳过 transactions.{col_name} (可能已存在): error_type={type(e).__name__}") conn.commit() @@ -235,7 +235,7 @@ def _auto_migrate_p7_fields(): logger.info("✅ 已创建 registered_plugins 表") except Exception as e: - logger.warning(f"字段迁移跳过 (可能已存在): {e}") + logger.warning(f"字段迁移跳过 (可能已存在): error_type={type(e).__name__}") def get_db(): @@ -244,7 +244,7 @@ def get_db(): try: yield db except (OperationalError, InterfaceError) as e: - logger.error(f"数据库连接错误: {e}") + logger.error(f"数据库连接错误: error_type={type(e).__name__}") db.rollback() raise finally: @@ -262,5 +262,5 @@ def check_db_connection() -> bool: db.close() return True except Exception as e: - logger.error(f"数据库连接检查失败: {e}") - return False \ No newline at end of file + logger.error(f"数据库连接检查失败: error_type={type(e).__name__}") + return False diff --git a/db_utils.py b/db_utils.py index a65e15fade23d2634c1bbb73ea1c60d9cb0ce0d1..a7084fff812325b85427edccd2507a59d92790e0 100644 --- a/db_utils.py +++ b/db_utils.py @@ -11,6 +11,7 @@ # ========================================== from typing import Any, Dict, List, Optional, Callable, Union +import copy import 数据库连接 as db @@ -111,6 +112,34 @@ def count_by(file_name: str, **conditions) -> int: # ✏️ 更新工具函数 # ========================================== +class _Unchanged(Exception): + def __init__(self, result): + self.result = result + + +def _mutate(file_name, callback): + def update(data): + if not isinstance(data, (dict, list)) or not all(isinstance(item, dict) for item in (data.values() if isinstance(data, dict) else data)): + raise ValueError('invalid mutable record source') + result = callback(data) + if result is False or (type(result) is int and result == 0): + raise _Unchanged(result) + return result + try: + return db.atomic_update(file_name, update, default_data=[]) + except _Unchanged as unchanged: + return unchanged.result + + +def _find_record(data, item_id, id_field): + if isinstance(data, dict): + return data.get(item_id) + matches = [item for item in data if item.get(id_field) == item_id] + if len(matches) > 1: + raise ValueError('ambiguous mutable record identity') + return matches[0] if matches else None + + def update_by_id(file_name: str, item_id: str, updates: Dict, id_field: str = "id") -> bool: """ 根据 ID 更新记录 @@ -127,22 +156,13 @@ def update_by_id(file_name: str, item_id: str, updates: Dict, id_field: str = "i 示例: update_by_id("tasks.json", "task_123", {"status": "completed"}) """ - data = db.load_data(file_name, default_data=[]) - - if isinstance(data, dict): - if item_id in data: - data[item_id].update(updates) - db.save_data(file_name, data) - return True - return False - - for item in data: - if item.get(id_field) == item_id: - item.update(updates) - db.save_data(file_name, data) - return True - - return False + saved_updates = copy.deepcopy(updates) + def update(data): + item = _find_record(data, item_id, id_field) + if item is None: return False + item.update(copy.deepcopy(saved_updates)) + return True + return _mutate(file_name, update) 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 update_with_fn("items.json", "item_123", increment_views) """ - data = db.load_data(file_name, default_data=[]) - - if isinstance(data, dict): - if item_id in data: - update_fn(data[item_id]) - db.save_data(file_name, data) - return True - return False - - for item in data: - if item.get(id_field) == item_id: - update_fn(item) - db.save_data(file_name, data) - return True - - return False + def update(data): + item = _find_record(data, item_id, id_field) + if item is None: return False + update_fn(item) + return True + return _mutate(file_name, update) # ========================================== @@ -201,23 +211,19 @@ def insert(file_name: str, item: Dict, prepend: bool = True) -> bool: 示例: insert("tasks.json", {"id": "task_123", "title": "新任务"}) """ - data = db.load_data(file_name, default_data=[]) - - if isinstance(data, dict): - item_id = item.get("id") or item.get("account") - if item_id: - data[item_id] = item - db.save_data(file_name, data) - return True - return False - - if prepend: - data.insert(0, item) - else: - data.append(item) - - db.save_data(file_name, data) - return True + saved = copy.deepcopy(item) + if not isinstance(saved, dict): raise ValueError('invalid inserted record') + def append(data): + if isinstance(data, dict): + item_id = saved.get('id') or saved.get('account') + if not item_id: return False + data[item_id] = copy.deepcopy(saved) + elif prepend: + data.insert(0, copy.deepcopy(saved)) + else: + data.append(copy.deepcopy(saved)) + return True + return _mutate(file_name, append) 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 返回: True 插入成功 / False 已存在 """ - item_id = item.get(id_field) - if not item_id: - return False - - existing = get_by_id(file_name, item_id, id_field) - if existing: - return False - - return insert(file_name, item) + saved = copy.deepcopy(item) + if not isinstance(saved, dict): raise ValueError('invalid inserted record') + item_id = saved.get(id_field) + if not item_id: return False + def append(data): + if isinstance(data, dict): + if item_id in data: return False + data[item_id] = copy.deepcopy(saved) + else: + if _find_record(data, item_id, id_field) is not None: return False + data.insert(0, copy.deepcopy(saved)) + return True + return _mutate(file_name, append) # ========================================== @@ -259,23 +269,7 @@ def delete_by_id(file_name: str, item_id: str, id_field: str = "id") -> bool: 返回: True 删除成功 / False 记录不存在 """ - data = db.load_data(file_name, default_data=[]) - - if isinstance(data, dict): - if item_id in data: - del data[item_id] - db.save_data(file_name, data) - return True - return False - - original_len = len(data) - data = [item for item in data if item.get(id_field) != item_id] - - if len(data) < original_len: - db.save_data(file_name, data) - return True - - return False + return bool(batch_delete(file_name, [item_id], id_field)) 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 返回: 更新的记录数量 """ - data = db.load_data(file_name, default_data=[]) - updated_count = 0 - - if isinstance(data, dict): - for item_id in item_ids: - if item_id in data: - data[item_id].update(updates) - updated_count += 1 - else: - id_set = set(item_ids) - for item in data: - if item.get(id_field) in id_set: - item.update(updates) - updated_count += 1 - - if updated_count > 0: - db.save_data(file_name, data) - - return updated_count + identities, saved_updates = set(item_ids), copy.deepcopy(updates) + def update(data): + 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] + for item in selected: item.update(copy.deepcopy(saved_updates)) + return len(selected) + return _mutate(file_name, update) 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 返回: 删除的记录数量 """ - data = db.load_data(file_name, default_data=[]) - original_count = len(data) if isinstance(data, list) else len(data) - - if isinstance(data, dict): - for item_id in item_ids: - data.pop(item_id, None) - else: - id_set = set(item_ids) - data = [item for item in data if item.get(id_field) not in id_set] - - new_count = len(data) if isinstance(data, list) else len(data) - deleted_count = original_count - new_count - - if deleted_count > 0: - db.save_data(file_name, data) - - return deleted_count + identities = set(item_ids) + def delete(data): + if isinstance(data, dict): + selected = [key for key in data if key in identities] + for key in selected: del data[key] + return len(selected) + old_count = len(data) + data[:] = [item for item in data if item.get(id_field) not in identities] + return old_count - len(data) + return _mutate(file_name, delete) diff --git a/deployment_config.py b/deployment_config.py new file mode 100644 index 0000000000000000000000000000000000000000..5340e55fe0948d41a89fae6b86edc592a61d4c16 --- /dev/null +++ b/deployment_config.py @@ -0,0 +1,49 @@ +"""可信部署地址配置;不依据请求 Host 或转发头构造持久链接。""" +import ipaddress +import os +import re +from urllib.parse import urlsplit, urlunsplit + +DEFAULT_PUBLIC_API_BASE_URL = "https://zhiwei666-comfyui-ranking-api.hf.space" +PUBLIC_API_BASE_URL_ENV = "RANKING_PUBLIC_API_BASE_URL" +_CONFIG_ERROR = "RANKING_PUBLIC_API_BASE_URL 配置无效:需要 HTTPS 服务基址,HTTP 仅允许本机回环地址" + + +def public_api_base_url(environ=None): + """读取受运维控制的基址;缺失兼容默认,显式错误拒绝初始化。""" + environ = os.environ if environ is None else environ + value = environ.get(PUBLIC_API_BASE_URL_ENV, DEFAULT_PUBLIC_API_BASE_URL) + if (not isinstance(value, str) or not value or len(value) > 2048 + or any(ord(char) <= 32 or ord(char) >= 127 for char in value) + or "\\" in value): + raise RuntimeError(_CONFIG_ERROR) + try: + parsed = urlsplit(value) + host = parsed.hostname + port = parsed.port + if (parsed.scheme not in {"http", "https"} or not host + or parsed.username is not None or parsed.password is not None + or "?" in value or "#" in value + or (port is not None and not 1 <= port <= 65535) + or parsed.netloc.endswith(":")): + raise ValueError() + try: + address = ipaddress.ip_address(host) + except ValueError: + address = None + if (len(host) > 253 or not all(re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label) + for label in host.split(".")) or re.fullmatch(r"[0-9.]+", host)): + raise ValueError() + if parsed.scheme == "http" and not (host == "localhost" or address and address.is_loopback): + raise ValueError() + path = parsed.path.rstrip("/") + if (path and (not path.startswith("/") or "//" in path + or not re.fullmatch(r"/[A-Za-z0-9._~/-]+", path) + or any(segment in {".", ".."} for segment in path.split("/")))): + raise ValueError() + return urlunsplit((parsed.scheme, parsed.netloc.lower(), path, "", "")) + except ValueError: + raise RuntimeError(_CONFIG_ERROR) from None + + +PUBLIC_API_BASE_URL = public_api_base_url() diff --git a/github_archive.py b/github_archive.py new file mode 100644 index 0000000000000000000000000000000000000000..e22e0200bba3bc78b3f4d191a503382050f53250 --- /dev/null +++ b/github_archive.py @@ -0,0 +1,103 @@ +"""固定Git commit归档与缓存来源证明;不把旧缓存当作已核实安装包。""" +import datetime +import hashlib +import json +import re +from github_source import canonical_repository + +ARCHIVE_SCHEMA = 3 +ARCHIVE_MAX_BYTES = 500 * 1024 * 1024 + + +def archive_repository(value): + """仅接收明确的owner/name标识,不能用元数据构造额外URL路径。""" + if (not isinstance(value, str) or len(value) > 193 + 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) + or '..' in value or '--' in value): + raise ValueError('invalid archive storage identity') + return value + + +def commit_sha(value): + if not isinstance(value, str) or not re.fullmatch(r'[0-9a-fA-F]{40}', value): + raise ValueError('invalid commit identity') + return value.lower() + + +def commit_ref(value): + if not isinstance(value, str) or not re.fullmatch(r'(?:[0-9a-fA-F]{7}|[0-9a-fA-F]{40})', value): + raise ValueError('invalid commit reference') + return value.lower() + + +def source_signature(item, token): + author = item.get('author') + if not isinstance(author, str) or not author.strip(): + raise ValueError('invalid archive author') + source = [canonical_repository(item.get('link')).lower(), author, token or ''] + # 元数据只保存摘要,不保存或输出凭据。 + return hashlib.sha256(json.dumps(source, ensure_ascii=True, separators=(',', ':')).encode()).hexdigest() + + +def archive_path(item_id, sha, signature): + if not isinstance(item_id, str) or not re.fullmatch(r'[A-Za-z0-9_-]{1,100}', item_id): + raise ValueError('invalid archive item') + if not isinstance(signature, str) or not re.fullmatch(r'[0-9a-f]{64}', signature): + raise ValueError('invalid archive source') + return f'zips/{item_id}_{commit_sha(sha)}_{signature[:16]}.zip' + + +def cached_archive(item, token, entry, *, repo_id, now=None): + """只有完整来源、commit、HF不可变提交及未过期元数据才允许命中。""" + try: + if not isinstance(entry, dict) or type(entry.get('archive_schema')) is not int or entry['archive_schema'] != ARCHIVE_SCHEMA: + return None + repository = archive_repository(repo_id) + if entry.get('archive_repo_id') != repository: + return None + if entry.get('archive_storage_private') is not True: + return None + sha = commit_sha(entry.get('archive_commit')) + if entry.get('version_commit') not in (None, sha): + return None + if entry.get('hash') != sha[:7]: + return None + signature = source_signature(item, token) + if entry.get('archive_source') != signature: + return None + path = archive_path(item.get('id'), sha, signature) + if entry.get('archive_path') != path: + return None + hf_commit = commit_sha(entry.get('archive_hf_commit')) + size = entry.get('zip_size') + if type(size) is not int or not 0 < size <= ARCHIVE_MAX_BYTES: + return None + cached_at = datetime.datetime.fromisoformat(entry['cached_at'].replace('Z', '+00:00')) + if cached_at.tzinfo is None: + return None + elapsed = (now or datetime.datetime.now(datetime.timezone.utc)) - cached_at + if not datetime.timedelta(minutes=-5) <= elapsed <= datetime.timedelta(days=30): + return None + return dict(commit=sha, path=path, hf_commit=hf_commit, size=size, repo_id=repository) + except (ValueError, TypeError, KeyError, AttributeError): + return None + + +def publish_archive_metadata(versions, item_id, expected_hash, metadata): + """锁内只更新目标缓存字段;晚到下载不能改变当前版本或其它资源。""" + if not isinstance(versions, dict): + raise ValueError('invalid version source') + entry = versions.get(item_id) + if isinstance(entry, str): + entry = {'hash': entry} + if not isinstance(entry, dict) or entry.get('hash') != commit_ref(expected_hash)[:7]: + return False + if (entry.get('version_commit') not in (None, metadata.get('archive_commit')) + or entry.get('version_source') not in (None, metadata.get('archive_source'))): + return False + keys = ('archive_schema', 'archive_commit', 'archive_source', 'archive_path', + 'archive_hf_commit', 'archive_storage_private', 'archive_repo_id', 'zip_size', 'cached_at') + archive_repository(metadata['archive_repo_id']) + entry = {**entry, **{key: metadata[key] for key in keys}} + versions[item_id] = entry + return True diff --git a/github_probe_transport.py b/github_probe_transport.py new file mode 100644 index 0000000000000000000000000000000000000000..d34cc835a64211d8acc28cc6a645db3091022d76 --- /dev/null +++ b/github_probe_transport.py @@ -0,0 +1,89 @@ +"""GitHub元数据预检传输:凭据仅留在HTTPS API源,不跟随外部跳转。""" +import re +import time +import urllib.error +import urllib.parse +import urllib.request +from github_source import repository_parts + +MAX_PROBE_TIMEOUT = 10 +MAX_PROBE_REDIRECTS = 3 + + +def _metadata_target(url): + if (not isinstance(url, str) or len(url) > 2048 + or any(ord(char) <= 32 or ord(char) >= 127 for char in url) + or "\\" in url or "?" in url or "#" in url): + raise ValueError("invalid GitHub metadata target") + parsed = urllib.parse.urlsplit(url) + if (parsed.scheme != "https" or parsed.hostname != "api.github.com" + or parsed.username is not None or parsed.password is not None + or parsed.port not in (None, 443) or parsed.netloc.endswith(":")): + raise ValueError("invalid GitHub metadata origin") + if parsed.path.startswith("/repos/"): + repository_parts("https://github.com/" + parsed.path[len("/repos/"):]) + elif not re.fullmatch(r"/repositories/[0-9]{1,20}", parsed.path): + raise ValueError("invalid GitHub metadata path") + return url + + +class GithubMetadataRedirect(urllib.request.HTTPRedirectHandler): + max_redirections = MAX_PROBE_REDIRECTS + max_repeats = 1 + + def __init__(self, deadline): + self.deadline = deadline + + def redirect_request(self, req, fp, code, msg, headers, newurl): + try: + _metadata_target(req.full_url) + _metadata_target(newurl) + except ValueError: + raise urllib.error.HTTPError(req.full_url, code, "GitHub预检跳转不允许", {}, fp) from None + remaining = self.deadline - time.monotonic() + if remaining <= 0: + fp.close() + raise TimeoutError("GitHub metadata probe timed out") + # urllib的父处理器使用旧req.timeout传给下一跳;收紧到剩余预算。 + req.timeout = min(getattr(req, "timeout", MAX_PROBE_TIMEOUT), remaining) + return super().redirect_request(req, fp, code, msg, headers, newurl) + + + def http_error_302(self, req, fp, code, msg, headers): + location = headers.get("location") or headers.get("uri") + if not isinstance(location, str) or not location or len(location) > 2048: + raise urllib.error.HTTPError(req.full_url, code, "GitHub预检跳转无效", {}, fp) + try: + target = urllib.parse.urljoin(req.full_url, location) + except ValueError: + raise urllib.error.HTTPError(req.full_url, code, "GitHub预检跳转无效", {}, fp) from None + new_request = self.redirect_request(req, fp, code, msg, headers, target) + count = getattr(req, "_ranking_probe_redirects", 0) + seen = getattr(req, "_ranking_probe_seen", {req.full_url}) + if new_request is None or count >= MAX_PROBE_REDIRECTS or target in seen: + raise urllib.error.HTTPError(req.full_url, code, "GitHub预检跳转次数过多", {}, fp) + new_request._ranking_probe_redirects = count + 1 + new_request._ranking_probe_seen = seen | {target} + # 只看元数据状态:关闭跳转正文,不使用urllib默认的无界fp.read()。 + fp.close() + return self.parent.open(new_request, timeout=req.timeout) + + http_error_301 = http_error_303 = http_error_307 = http_error_308 = http_error_302 + + +def open_github_probe(request, *, timeout=MAX_PROBE_TIMEOUT): + """创建本次请求专用opener,不修改全局;HTTP错误也关闭其响应。""" + _metadata_target(request.full_url) + if (request.get_method() != "GET" or isinstance(timeout, bool) + or not isinstance(timeout, (int, float)) or not 0 < timeout <= MAX_PROBE_TIMEOUT): + raise ValueError("invalid GitHub metadata request") + redirect = GithubMetadataRedirect(time.monotonic() + timeout) + opener = urllib.request.build_opener(redirect) + try: + return opener.open(request, timeout=timeout) + except urllib.error.HTTPError as error: + try: + error.close() + except Exception: + pass # 清理失败不覆盖原HTTP错误,不输出响应或地址。 + raise diff --git a/github_source.py b/github_source.py new file mode 100644 index 0000000000000000000000000000000000000000..471901387f815df22f896c4e689a7556f434588a --- /dev/null +++ b/github_source.py @@ -0,0 +1,53 @@ +"""GitHub资源地址与凭据选择;普通发布者不能借用服务器全局PAT。""" +import os +import re +from urllib.parse import urlsplit + + +def repository_parts(url): + if not isinstance(url, str): + raise ValueError('invalid repository') + parsed = urlsplit(url.strip()) + if (parsed.scheme != 'https' or parsed.hostname != 'github.com' or parsed.username or parsed.password + or parsed.port not in (None, 443) or parsed.query or parsed.fragment): + raise ValueError('invalid repository origin') + parts = parsed.path.strip('/').split('/') + if len(parts) != 2: + raise ValueError('invalid repository path') + owner, repo = parts + if repo.endswith('.git'): + repo = repo[:-4] + if (not re.fullmatch(r'[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?', owner) + or not re.fullmatch(r'[A-Za-z0-9_.-]{1,100}', repo) or repo in {'.', '..'}): + raise ValueError('invalid repository identity') + return owner, repo + + +def canonical_repository(url): + owner, repo = repository_parts(url) + return f'https://github.com/{owner}/{repo}' + + +def _token(value): + if value is None or value == '': + return None + if not isinstance(value, str) or not re.fullmatch(r'[\x21-\x7e]+', value): + raise ValueError('invalid github credential') + return value + + +def select_github_token(item, *, admin_check=None): + repository_parts(item.get('link')) + author = item.get('author') + if not isinstance(author, str) or not author.strip(): + raise ValueError('invalid resource author') + creator = _token(item.get('github_token')) + if creator is not None: + return creator + if admin_check is None: + from 安全认证 import is_admin + admin_check = is_admin + # 权限来自服务端管理员配置与已保存作者,不信任请求账号或前端管理员标记。 + if admin_check(author) is True: + return _token(os.environ.get('GITHUB_PAT')) + return None diff --git a/github_versions.py b/github_versions.py new file mode 100644 index 0000000000000000000000000000000000000000..2c570e1898170af52e3bb17d4a03a0bcfad8f19e --- /dev/null +++ b/github_versions.py @@ -0,0 +1,89 @@ +"""每资源版本检测票据和局部发布;拒绝较早检测的迟到结果。""" +import asyncio +import uuid +from github_archive import commit_sha, source_signature +from github_source import select_github_token +from proxy_access import read_proxy_item + + +def _entry(versions, item_id): + if not isinstance(versions, dict): + raise ValueError('invalid version source') + entry = versions.get(item_id) + if entry is None: + return {} + if isinstance(entry, str): + return {'hash': entry} + if not isinstance(entry, dict): + raise ValueError('invalid version record') + return dict(entry) + + +def _without_cache(entry): + return {key: value for key, value in entry.items() + if key not in ('cached_at', 'zip_size') and not key.startswith('archive_')} + + +def begin_probe(store, item_id): + item = read_proxy_item(store, item_id) + token = select_github_token(item) + source = source_signature(item, token) + ticket = uuid.uuid4().hex + def update(versions): + entry = _entry(versions, item_id) + entry['_probe_ticket'] = ticket + entry['_probe_source'] = source + versions[item_id] = entry + store.atomic_update('versions.json', update, default_data={}) + return dict(item=item, token=token, source=source, ticket=ticket) + + +def finish_probe(store, operation, sha): + sha = commit_sha(sha) + item_id = operation['item']['id'] + current = read_proxy_item(store, item_id) + if source_signature(current, select_github_token(current)) != operation['source']: + return None + def update(versions): + entry = _entry(versions, item_id) + if entry.get('_probe_ticket') != operation['ticket'] or entry.get('_probe_source') != operation['source']: + return None + changed = entry.get('hash') != sha[:7] + if (changed or entry.get('version_source') not in (None, operation['source']) + or entry.get('version_commit') not in (None, sha) + or entry.get('archive_commit') not in (None, sha)): + entry = _without_cache(entry) + entry.update(hash=sha[:7], version_commit=sha, version_source=operation['source']) + entry.pop('_probe_ticket', None); entry.pop('_probe_source', None) + versions[item_id] = entry + return dict(item=current, token=operation['token'], commit=sha, changed=changed) + return store.atomic_update('versions.json', update, default_data={}) + + +def invalidate_source(store, item_id): + """旧编辑回调也读取最新资源;已属于最新来源的确认记录保持。""" + current = read_proxy_item(store, item_id) + try: + source = source_signature(current, select_github_token(current)) + except ValueError: + source = None + def update(versions): + entry = _entry(versions, item_id) + if source is not None and entry.get('version_source') == source: + return False + entry = _without_cache(entry) + for key in ('version_commit', '_probe_ticket', '_probe_source'): + entry.pop(key, None) + entry.update(hash='', version_source=source) + versions[item_id] = entry + return True + return store.atomic_update('versions.json', update, default_data={}) + + +async def refresh_resource_version(store, item_id): + from 云端_定时版本检测引擎 import fetch_github_commit + operation = await asyncio.to_thread(begin_probe, store, item_id) + sha = await fetch_github_commit(operation['item']['link'], operation['token']) + if not sha: + return None + return await asyncio.to_thread(finish_probe, store, operation, sha) diff --git a/github_zip_service.py b/github_zip_service.py new file mode 100644 index 0000000000000000000000000000000000000000..c38da096d0b6cd72b68d1202ac285169697b548b --- /dev/null +++ b/github_zip_service.py @@ -0,0 +1,145 @@ +"""授权后的GitHub ZIP交付服务;认证/实时所有权仍由路由负责。""" +import asyncio +import os +from github_archive import cached_archive +from github_source import repository_parts +from proxy_resources import ProxyResources +from workflow_stream_response import OwnedProxyResponse +from zip_stream import zip_response_length, stream_zip_bytes + + +async def prepare_github_zip(item, item_id, *, store, token_selector, resolve_commit, + send, client_factory, transport_factory, error_response, + cache_max_bytes, direct_max_bytes): + # 按服务端资源解析仓库,普通作者只使用本人绑定凭据或匿名GitHub。 + try: + repo_url = item.get("link", "") + owner, repo = repository_parts(repo_url) + active_token = token_selector(item) + except ValueError: + return error_response(content={"error": "资源仓库或凭据暂时无法核实"}, status_code=503) + + # ===== ZIP 缓存优先机制(鉴权后、GitHub请求前) ===== + cache_resources = None + cache_handed_off = False + try: + versions_db = store.load_data("versions.json", default_data={}) + item_version = versions_db.get(item_id) + archive = cached_archive(item, active_token, item_version, repo_id=store.DATASET_REPO_ID) + hf_cache_token = os.environ.get("HF_TOKEN") + if archive and hf_cache_token: + hf_headers = {"Authorization": f"Bearer {hf_cache_token}"} + cache_filename = archive["path"] + cache_url = f"https://huggingface.co/datasets/{archive['repo_id']}/resolve/{archive['hf_commit']}/{cache_filename}" + cache_client = client_factory( + follow_redirects=False, trust_env=False, transport=transport_factory(), + ) + cache_resources = ProxyResources(cache_client) + head_resp = cache_resources.track(await send( + cache_client, "HEAD", cache_url, headers=hf_headers, timeout=10)) + if head_resp.status_code == 200: + await cache_resources.close_response(head_resp) + stream_resp = cache_resources.track(await send( + cache_client, "GET", cache_url, headers=hf_headers, timeout=120)) + if stream_resp.status_code == 200: + # identity响应应与已上传归档大小一致;编码响应仍保持原始透传。 + encoding = (stream_resp.headers.get('content-encoding') or 'identity').strip().lower() + expected_size = archive['size'] if encoding == 'identity' else None + cache_length = zip_response_length(stream_resp.headers, max_bytes=cache_max_bytes, + expected_size=expected_size) + cache_headers = {} + content_length = stream_resp.headers.get('content-length') + if content_length: + cache_headers["Content-Length"] = content_length + content_encoding = stream_resp.headers.get('content-encoding') + if content_encoding: + cache_headers["Content-Encoding"] = content_encoding + if item_version.get('hash'): + cache_headers["X-Installed-Version"] = archive["commit"] + + async def cache_stream_generator(): + try: + async for chunk in stream_zip_bytes(stream_resp, max_bytes=cache_max_bytes, + length=cache_length, expected_size=expected_size): + yield chunk + except Exception as error: + print(f"[ZIP缓存] 响应流中断: error={type(error).__name__}") + raise + finally: + await cache_resources.close() + + prepared = OwnedProxyResponse(cache_stream_generator(), close_callback=cache_resources.close, + media_type="application/zip", headers=cache_headers) + cache_handed_off = True + return prepared + print(f"[ZIP缓存] GET失败,回退GitHub: status={stream_resp.status_code}") + except asyncio.CancelledError: + raise + except Exception as error: + print(f"[ZIP缓存] 查询失败,回退GitHub: error={type(error).__name__}") + finally: + if cache_resources is not None and not cache_handed_off: + await cache_resources.close() + + # 先取得完整commit,再按它下载;不在下载后另取“最新版本”冒充实际内容。 + live_commit = await resolve_commit(repo_url, active_token) + if not live_commit: + return error_response(content={"error": "资源版本暂时无法核实,请稍后重试"}, status_code=502) + github_zip_api = f"https://api.github.com/repos/{owner}/{repo}/zipball/{live_commit}" + + headers = { + "Accept": "application/vnd.github.v3+json", + "User-Agent": "ComfyUI-Ranking-SaaS" + } + if active_token: + headers["Authorization"] = f"Bearer {active_token}" + + # 异步请求固定GitHub commit,原始块保持长度与Content-Encoding契约。 + client = client_factory( + follow_redirects=False, trust_env=False, transport=transport_factory(), + ) + resources = ProxyResources(client) + handed_off = False + try: + try: + response = resources.track(await send( + client, "GET", github_zip_api, headers=headers, timeout=120)) + except Exception as error: + print(f"[proxy_github_zip] 请求失败: error={type(error).__name__}") + return error_response(content={"error": "代理下载时发生网络异常,请稍后重试"}, status_code=502) + if response.status_code != 200: + status_map = {404: "仓库不存在或无权访问", 401: "Token 无效或已过期", 403: "访问被拒绝(可能触发速率限制)"} + err_msg = status_map.get(response.status_code, f"GitHub API 返回 HTTP {response.status_code}") + return error_response(content={"error": f"GITHUB_DOWNLOAD_FAILED: {err_msg}"}, status_code=502) + declared_length = zip_response_length(response.headers, max_bytes=direct_max_bytes) + resp_headers = {} + content_length = response.headers.get('content-length', '') + if content_length: + resp_headers["Content-Length"] = content_length + content_encoding = response.headers.get('content-encoding') + if content_encoding: + resp_headers["Content-Encoding"] = content_encoding + resp_headers["X-Installed-Version"] = live_commit + + async def stream_generator(): + try: + async for chunk in stream_zip_bytes(response, max_bytes=direct_max_bytes, length=declared_length): + yield chunk + except Exception as error: + print(f"[proxy_github_zip] 响应流中断: error={type(error).__name__}") + raise + finally: + await resources.close() + + prepared = OwnedProxyResponse(stream_generator(), close_callback=resources.close, + media_type="application/zip", headers=resp_headers) + handed_off = True + return prepared + except asyncio.CancelledError: + raise + except Exception as error: + print(f"[proxy_github_zip] 响应准备失败: error={type(error).__name__}") + return error_response(content={"error": "代理下载时发生网络异常,请稍后重试"}, status_code=502) + finally: + if not handed_off: + await resources.close() diff --git a/hf_snapshot_store.py b/hf_snapshot_store.py new file mode 100644 index 0000000000000000000000000000000000000000..7b27ae9024fffe351d671f00ca30360c55f8f6bd --- /dev/null +++ b/hf_snapshot_store.py @@ -0,0 +1,101 @@ +"""HF私有临时快照生命周期;创建/回收必须持同目录公共提交锁。""" +from contextlib import contextmanager +import json +import logging +import os +from pathlib import Path +import re +import stat +import uuid + + +logger=logging.getLogger('ComfyUI-Ranking.DB') + + +class SnapshotStore: + MANIFEST='snapshot-owner.json' + NAME=re.compile(r'\.hf-sync-[0-9a-f]{32}\Z') + PAYLOAD=re.compile(r'(0|[1-9][0-9]*)\.json\Z') + + def __init__(self,directory): + self.root=Path(os.path.abspath(directory)) + + @staticmethod + def _check(path,directory=False): + info=os.lstat(path) + if (getattr(info,'st_file_attributes',0)&1024 + or not (stat.S_ISDIR(info.st_mode) if directory else stat.S_ISREG(info.st_mode)) + or not info.st_ino): + raise ValueError('invalid snapshot path') + return (info.st_dev,info.st_ino) + + def _owner(self,path): + if path.parent!=self.root or not self.NAME.fullmatch(path.name): + raise ValueError('unknown snapshot directory') + self._check(self.root,directory=True) + directory_id=self._check(path,directory=True) + marker=path/self.MANIFEST + marker_id=self._check(marker) + descriptor=os.open(marker,os.O_RDONLY|getattr(os,'O_NOFOLLOW',0) + |getattr(os,'O_NONBLOCK',0)|getattr(os,'O_BINARY',0)) + with os.fdopen(descriptor,'rb') as stream: + info=os.fstat(stream.fileno()) + if not stat.S_ISREG(info.st_mode) or (info.st_dev,info.st_ino)!=marker_id: + raise ValueError('snapshot owner identity changed') + data=stream.read(4097) + if len(data)>4096:raise ValueError('snapshot owner too large') + owner=json.loads(data) + if (not isinstance(owner,dict) or set(owner)!={'schema','kind','directory'} + or type(owner['schema']) is not int or owner['schema']!=1 + or owner['kind']!='ranking-hf-snapshot' or owner['directory']!=path.name): + raise ValueError('invalid snapshot owner') + if self._check(path,directory=True)!=directory_id or self._check(marker)!=marker_id: + raise ValueError('snapshot identity changed') + return directory_id + + def remove(self,path): + """只删除验证归属且内容全为普通快照文件的本层目录,不递归删除。""" + path=Path(path) + directory_id=self._owner(path) + entries=[] + for entry in path.iterdir(): + if len(entries)>=4096:raise ValueError('too many snapshot files') + if entry.name!=self.MANIFEST and not self.PAYLOAD.fullmatch(entry.name): + raise ValueError('unknown snapshot content') + self._check(entry) + entries.append(entry) + # 先完整校验,再删除数据,归属记录最后删除,失败可在后续接手。 + for entry in sorted(entries,key=lambda entry:entry.name==self.MANIFEST): + self._check(self.root,directory=True) + if self._check(path,directory=True)!=directory_id: + raise ValueError('snapshot directory changed') + entry.unlink() + path.rmdir() + + def cleanup_stale(self): + self._check(self.root,directory=True) + removed=retained=0 + for path in self.root.glob('.hf-sync-*'): + try:self.remove(path);removed+=1 + except FileNotFoundError: + if path.exists() or path.is_symlink():retained+=1 + except (OSError,ValueError):retained+=1 + return removed,retained + + @contextmanager + def temporary(self): + self._check(self.root,directory=True) + path=self.root/('.hf-sync-'+uuid.uuid4().hex) + path.mkdir(mode=0o700) + try: + with (path/self.MANIFEST).open('xb') as stream: + data=json.dumps({'schema':1,'kind':'ranking-hf-snapshot','directory':path.name}).encode('utf-8') + stream.write(data);stream.flush();os.fsync(stream.fileno()) + yield str(path) + finally: + try:self.remove(path) + except FileNotFoundError: + if path.exists() or path.is_symlink(): + logger.warning('HF snapshot cleanup unavailable; retained for recovery') + except (OSError,ValueError): + logger.warning('HF snapshot cleanup unavailable; retained for recovery') diff --git a/hf_sync_journal.py b/hf_sync_journal.py new file mode 100644 index 0000000000000000000000000000000000000000..663aaf022ded8bc685fe0f744eca1e1c16336962 --- /dev/null +++ b/hf_sync_journal.py @@ -0,0 +1,97 @@ +"""同目录HF待同步写前记录;调用方持业务文件锁,提交方另持公共提交锁。""" +import hashlib +import json +import os +from pathlib import Path +import stat +import tempfile +import uuid + + +class SyncJournal: + def __init__(self,directory): + self.directory=Path(directory)/'.hf-pending' + + @staticmethod + def validate_name(name): + if (not isinstance(name,str) or not name or name in {'.','..'} + or os.path.normcase(name)==os.path.normcase('__hf_dataset_sync__') + or any(char in name for char in '/\\:') or any(ord(char)<32 for char in name)): + raise ValueError('invalid sync file name') + + def path(self,name): + self.validate_name(name) + key=hashlib.sha256(os.path.normcase(name).encode('utf-8')).hexdigest() + return self.directory/(key+'.json') + + @staticmethod + def check_regular(path,directory=False): + info=os.lstat(path) + if (stat.S_ISLNK(info.st_mode) or getattr(info,'st_file_attributes',0)&1024 + or not (stat.S_ISDIR(info.st_mode) if directory else stat.S_ISREG(info.st_mode))): + raise ValueError('invalid sync journal path') + + def mark(self,name): + target=self.path(name) + self.directory.mkdir(exist_ok=True) + self.check_regular(self.directory,directory=True) + if target.exists() or target.is_symlink(): self.check_regular(target) + generation=uuid.uuid4().hex + data=json.dumps({'schema':1,'file':name,'generation':generation},ensure_ascii=False).encode('utf-8') + if len(data)>4096: raise ValueError('sync journal record too large') + descriptor,temporary=tempfile.mkstemp(prefix='.pending-',suffix='.tmp',dir=self.directory) + try: + with os.fdopen(descriptor,'wb') as stream: + stream.write(data); stream.flush(); os.fsync(stream.fileno()) + os.replace(temporary,target) + finally: + try: os.unlink(temporary) + except FileNotFoundError: pass + return generation + + def read_record(self,path): + self.check_regular(self.directory,directory=True) + self.check_regular(path) + descriptor=os.open(path,os.O_RDONLY|getattr(os,'O_NOFOLLOW',0)) + with os.fdopen(descriptor,'rb') as stream: + if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode): raise ValueError('invalid sync journal file') + content=stream.read(4097) + if len(content)>4096: raise ValueError('sync journal record too large') + data=json.loads(content) + if (not isinstance(data,dict) or set(data)!={'schema','file','generation'} + or type(data['schema']) is not int or data['schema']!=1): + raise ValueError('invalid sync journal schema') + self.validate_name(data['file']) + generation=data['generation'] + if (not isinstance(generation,str) or len(generation)!=32 + or any(char not in '0123456789abcdef' for char in generation) + or self.path(data['file'])!=path): + raise ValueError('invalid sync journal identity') + return data + + def read(self,name): + try: return self.read_record(self.path(name))['generation'] + except FileNotFoundError: return None + + def pending(self,on_invalid=None): + """默认严格读取;同步方可隔离坏记录,目录本身故障仍整体拒绝。""" + if not self.directory.exists(): + if self.directory.is_symlink(): raise ValueError('invalid sync journal directory') + return set() + self.check_regular(self.directory,directory=True) + names=set() + for path in self.directory.glob('*.json'): + try: names.add(self.read_record(path)['file']) + except FileNotFoundError: pass # 其他提交方已确认该记录。 + except (OSError,ValueError) as error: + if on_invalid is None: raise + on_invalid(error) + return names + + def acknowledge(self,name,generation): + if (not isinstance(generation,str) or len(generation)!=32 + or any(char not in '0123456789abcdef' for char in generation)): + return False + if self.read(name)!=generation: return False + os.unlink(self.path(name)) + return True diff --git a/hf_sync_scheduler.py b/hf_sync_scheduler.py new file mode 100644 index 0000000000000000000000000000000000000000..3957da03f6cfacc3c30d730e913d559a1b9c43e2 --- /dev/null +++ b/hf_sync_scheduler.py @@ -0,0 +1,109 @@ +"""单工作线程同步调度;持久待同步记录由调用方管理。""" +import math +import threading +import time + + +class SyncScheduler: + MAX_PENDING_NAMES = 128 + + def __init__(self, sync, interval, on_error, cooldown=None): + self._sync = sync + self._interval = interval + self._on_error = on_error + self._cooldown = cooldown or (lambda: 0) + self._condition = threading.Condition() + self._state = 'new' + self._thread = None + self._pending = set() + self._all_pending = False + + def _delay(self): + delay = float(self._interval()) + if not math.isfinite(delay) or delay <= 0: + raise ValueError('invalid sync interval') + return delay + + def _start_locked(self, restart): + if self._state == 'running': + return False + if self._state == 'stopping' or (self._state == 'stopped' and not restart): + return False + self._delay() + self._state = 'running' + self._thread = threading.Thread(target=self._run, name='ranking-hf-sync', daemon=True) + try: + self._thread.start() + except Exception: + self._state = 'new' + self._thread = None + raise + return True + + def start(self, restart=False): + with self._condition: + return self._start_locked(restart) + + def request(self, file_name): + with self._condition: + if self._state in {'stopping', 'stopped'}: + return False + if not self._all_pending: + self._pending.add(file_name) + if len(self._pending) > self.MAX_PENDING_NAMES: + self._pending.clear() + self._all_pending = True + self._start_locked(False) + self._condition.notify_all() + return True + + def close(self): + with self._condition: + thread = self._thread + if thread is threading.current_thread(): + raise RuntimeError('sync worker cannot join itself') + self._state = 'stopping' + self._pending.clear() + self._all_pending = False + self._condition.notify_all() + if thread is not None: + thread.join() + with self._condition: + if self._thread is thread: + self._state = 'stopped' + self._thread = None + + def _run(self): + try: + deadline = time.monotonic() + self._delay() + while True: + with self._condition: + while self._state == 'running': + periodic = time.monotonic() >= deadline + if periodic or self._pending or self._all_pending: + cooldown = float(self._cooldown()) + if not math.isfinite(cooldown) or cooldown < 0: + raise ValueError('invalid sync cooldown') + if cooldown > 0: + # 远期服务端日期也不能超过平台锁等待上限。 + self._condition.wait(min(cooldown, 60)) + continue + names = None if periodic or self._all_pending else sorted(self._pending) + self._pending.clear() + self._all_pending = False + break + self._condition.wait(min(60, max(0, deadline - time.monotonic()))) + else: + return + try: + self._sync(names) + except Exception as error: + self._on_error(error) + # 即时请求不能无限推迟正常批量窗口。 + if periodic: + deadline = time.monotonic() + self._delay() + finally: + with self._condition: + if self._state == 'running': + self._state = 'stopped' + self._condition.notify_all() diff --git a/image_file_budget.py b/image_file_budget.py new file mode 100644 index 0000000000000000000000000000000000000000..973982bae1003c501ffb6a9c9a30fc92bbf63860 --- /dev/null +++ b/image_file_budget.py @@ -0,0 +1,192 @@ +"""Bounded image container inspection; never decompress pixels or retain image payloads.""" +import os +import time + +MAX_EDGE = 16_000 +MAX_PIXELS = 40_000_000 +MAX_FRAMES = 240 +MAX_ANIMATION_PIXELS = 400_000_000 +MAX_FILE_BYTES = 50 * 1024 * 1024 +MAX_STEPS = 500_000 +SCAN_SECONDS = 3 +JPEG_HEADER_BYTES = 256 * 1024 + +class ImageBudgetError(ValueError): + pass + +def _bad(): + raise ImageBudgetError('invalid or over-budget image container') + +def _budget(width, height, frames=1): + if not 0 < width <= MAX_EDGE or not 0 < height <= MAX_EDGE or width * height > MAX_PIXELS: + _bad() + if not 0 < frames <= MAX_FRAMES or width * height * frames > MAX_ANIMATION_PIXELS: + _bad() + +class _Reader: + def __init__(self, file): + self.file = file + self.size = os.fstat(file.fileno()).st_size + self.deadline = time.monotonic() + SCAN_SECONDS + self.steps = 0 + if not 0 < self.size <= MAX_FILE_BYTES: _bad() + def check(self, length): + self.steps += 1 + if self.steps > MAX_STEPS or time.monotonic() > self.deadline or length < 0 or self.file.tell() + length > self.size: + _bad() + def read(self, length): + self.check(length) + if length > 32: _bad() + result = self.file.read(length) + if len(result) != length: _bad() + return result + def skip(self, length): + self.check(length) + self.file.seek(length, 1) + def pos(self): return self.file.tell() + +def _png(r): + if r.read(8) != b'\x89PNG\r\n\x1a\n': _bad() + if r.read(8) != b'\x00\x00\x00\rIHDR': _bad() + header = r.read(13) + width, height = int.from_bytes(header[:4], 'big'), int.from_bytes(header[4:8], 'big') + _budget(width, height) + r.skip(4) + declared, frames, data, first_control_before_data = None, 0, False, False + while r.pos() < r.size: + head = r.read(8); length, kind = int.from_bytes(head[:4], 'big'), head[4:] + if kind == b'acTL': + if declared is not None or data or length != 8: _bad() + declared = int.from_bytes(r.read(8)[:4], 'big'); _budget(width, height, declared) + elif kind == b'fcTL': + if declared is None or length != 26: _bad() + control = r.read(26) + w, h, x, y = [int.from_bytes(control[n:n+4], 'big') for n in (4,8,12,16)] + if not w or not h or x + w > width or y + h > height: _bad() + if frames == 0: first_control_before_data = not data + frames += 1; _budget(width, height, frames) + elif kind == b'IDAT': + data = True; r.skip(length) + elif kind == b'IEND': + if length != 0 or not data or (declared is not None and frames != declared): _bad() + r.skip(4) + if r.pos() != r.size: _bad() + total = frames + (0 if first_control_before_data else 1) if declared is not None else 1 + _budget(width, height, total) + return width, height, total + else: + r.skip(length) + r.skip(4) + _bad() + +def _gif(r): + if r.read(6) not in (b'GIF87a', b'GIF89a'): _bad() + head = r.read(7); width, height = int.from_bytes(head[:2],'little'), int.from_bytes(head[2:4],'little') + _budget(width,height) + if head[4] & 128: r.skip(3 * (1 << ((head[4] & 7) + 1))) + def blocks(): + while True: + size = r.read(1)[0] + if not size: return + r.skip(size) + frames = 0 + while r.pos() < r.size: + kind = r.read(1)[0] + if kind == 0x21: + r.read(1); blocks() + elif kind == 0x2c: + image = r.read(9) + x,y,w,h = [int.from_bytes(image[n:n+2],'little') for n in (0,2,4,6)] + if not w or not h or x+w > width or y+h > height: _bad() + frames += 1; _budget(width,height,frames) + if image[8] & 128: r.skip(3 * (1 << ((image[8] & 7) + 1))) + if not 2 <= r.read(1)[0] <= 8: _bad() + blocks() + elif kind == 0x3b: + if not frames or r.pos() != r.size: _bad() + return width,height,frames + else: _bad() + _bad() + +def _jpeg(r): + if r.read(2) != b'\xff\xd8': _bad() + while r.pos() < min(r.size, JPEG_HEADER_BYTES): + if r.read(1) != b'\xff': _bad() + marker = r.read(1)[0] + while marker == 0xff: marker = r.read(1)[0] + if marker in (0,0xd8,0xd9,0xda) or 0xd0 <= marker <= 0xd7: _bad() + length = int.from_bytes(r.read(2),'big') + if length < 2 or r.pos() + length - 2 > min(r.size, JPEG_HEADER_BYTES): _bad() + if 0xc0 <= marker <= 0xcf and marker not in (0xc4,0xc8,0xcc): + if length < 8: _bad() + head = r.read(6); height, width = int.from_bytes(head[1:3],'big'), int.from_bytes(head[3:5],'big') + _budget(width,height) + return width,height,1 + r.skip(length-2) + _bad() + +def _webp(r): + head = r.read(12) + if head[:4] != b'RIFF' or head[8:] != b'WEBP' or int.from_bytes(head[4:8],'little')+8 != r.size: _bad() + canvas, frames, animated, animation_header, static = None, 0, False, False, False + def codec(kind,length): + if kind == b'VP8 ': + if length < 10: _bad() + h = r.read(10) + if h[3:6] != b'\x9d\x01\x2a' or h[0] & 1: _bad() + result = int.from_bytes(h[6:8],'little') & 0x3fff, int.from_bytes(h[8:10],'little') & 0x3fff + r.skip(length-10) + elif kind == b'VP8L': + if length < 5: _bad() + h = r.read(5) + if h[0] != 0x2f: _bad() + bits = int.from_bytes(h[1:],'little') + if bits >> 29: _bad() + result = (bits & 0x3fff)+1, ((bits >> 14) & 0x3fff)+1 + r.skip(length-5) + else: _bad() + _budget(*result) + return result + while r.pos() < r.size: + head = r.read(8); kind, length = head[:4], int.from_bytes(head[4:],'little') + r.check(length+(length & 1)); end = r.pos()+length + if kind == b'VP8X': + if canvas is not None or length != 10: _bad() + h=r.read(10); canvas=(int.from_bytes(h[4:7],'little')+1,int.from_bytes(h[7:10],'little')+1) + _budget(*canvas); animated=bool(h[0] & 2) + elif kind == b'ANIM': + if not animated or length != 6 or animation_header: _bad() + animation_header=True; r.skip(length) + elif kind == b'ANMF': + if not animated or not animation_header or canvas is None or length < 16: _bad() + h=r.read(16); x,y=int.from_bytes(h[:3],'little')*2,int.from_bytes(h[3:6],'little')*2 + w,hh=int.from_bytes(h[6:9],'little')+1,int.from_bytes(h[9:12],'little')+1 + if x+w > canvas[0] or y+hh > canvas[1]: _bad() + frames+=1; _budget(*canvas,frames) + found=False + while r.pos()end: _bad() + if sub in (b'VP8 ',b'VP8L'): + if found or codec(sub,size)!=(w,hh): _bad() + found=True + else: r.skip(size) + if size & 1: r.skip(1) + if not found: _bad() + elif kind in (b'VP8 ',b'VP8L'): + if animated or static: _bad() + size=codec(kind,length) + if canvas is not None and size!=canvas: _bad() + canvas=size;static=True + else: r.skip(length) + if r.pos()!=end: _bad() + if length & 1: r.skip(1) + if canvas is None or (animated and not frames) or (not animated and not static): _bad() + return canvas[0], canvas[1], frames if animated else 1 + +def inspect_image_file(path, ext): + parser={'png':_png,'gif':_gif,'jpg':_jpeg,'jpeg':_jpeg,'webp':_webp}.get(ext) + if parser is None: _bad() + with open(path,'rb') as file: + width,height,frames=parser(_Reader(file)) + return {'width':width,'height':height,'frames':frames} diff --git a/image_moderation.py b/image_moderation.py index 840887d269ba57f9bfd4b1c632177f508b7a245c..3e50d8a0180704e9c3530b855724a8051c18b829 100644 --- a/image_moderation.py +++ b/image_moderation.py @@ -58,7 +58,7 @@ def _load_quota() -> dict: return {"month": current_month, "aliyun": 0, "tencent": 0} return data except Exception as e: - logger.debug(f"加载额度记录失败: {e}") + logger.debug(f"加载额度记录失败: error_type={type(e).__name__}") return {"month": current_month, "aliyun": 0, "tencent": 0} def _save_quota(quota: dict): @@ -67,7 +67,7 @@ def _save_quota(quota: dict): with open(QUOTA_FILE, "w") as f: json.dump(quota, f) except Exception as e: - logger.warning(f"保存额度记录失败: {e}") + logger.warning(f"保存额度记录失败: error_type={type(e).__name__}") def _increment_quota(provider: str): """增加使用次数""" @@ -196,31 +196,39 @@ async def moderate_image_tencent(image_content: bytes) -> ModerationResult: result = resp.json() response = result.get("Response", {}) + if not isinstance(response, dict) or not response: + logger.error("IMAGE_MODERATION_API_FAIL | provider=tencent | category=empty_result") + return ModerationResult(passed=False, suggestion="review", + details={"error": "审核服务暂时不可用,需人工审核"}) if "Error" in response: - logger.error(f"腾讯云审核API错误: {response.get('Error', 'unknown')!r}") - return ModerationResult(passed=True, suggestion="pass", - details={"error": response["Error"]}) + logger.error("IMAGE_MODERATION_API_FAIL | provider=tencent | category=provider_error") + return ModerationResult(passed=False, suggestion="review", + details={"error": "审核服务暂时不可用,需人工审核"}) # 解析审核结果 - suggestion = response.get("Suggestion", "Pass").lower() + suggestion = str(response.get("Suggestion", "")).lower() + if suggestion not in {"pass", "review", "block"}: + logger.error("IMAGE_MODERATION_API_FAIL | provider=tencent | category=invalid_result") + return ModerationResult(passed=False, suggestion="review", + details={"error": "审核服务暂时不可用,需人工审核"}) label = response.get("Label", "") score = response.get("Score", 0) passed = suggestion == "pass" - logger.info(f"腾讯云审核结果: suggestion={suggestion}, label={label}, score={score}") + logger.info(f"IMAGE_MODERATION_RESULT | provider=tencent | suggestion={suggestion}") return ModerationResult( passed=passed, label=label, confidence=score, suggestion=suggestion, - details=response + details={"provider": "tencent"} ) except Exception as e: - logger.error(f"腾讯云审核异常: {e!r}") + logger.error(f"IMAGE_MODERATION_FAIL | provider=tencent | error_type={type(e).__name__}") # 审核服务异常时拒绝,需要人工审核 return ModerationResult(passed=False, suggestion="review", details={"error": "审核服务暂时不可用,需人工审核"}) @@ -321,15 +329,21 @@ async def moderate_image_aliyun(image_content: bytes) -> ModerationResult: # 解析审核结果 if result.get("code") != 200: - logger.error(f"阿里云审核API错误: {result}") - return ModerationResult(passed=True, suggestion="pass", - details={"error": result}) + logger.error("IMAGE_MODERATION_API_FAIL | provider=aliyun | category=provider_error") + return ModerationResult(passed=False, suggestion="review", + details={"error": "审核服务暂时不可用,需人工审核"}) data = result.get("data", []) if not data: - return ModerationResult(passed=True, suggestion="pass") + logger.error("IMAGE_MODERATION_API_FAIL | provider=aliyun | category=empty_result") + return ModerationResult(passed=False, suggestion="review", + details={"error": "审核服务暂时不可用,需人工审核"}) task_result = data[0].get("results", []) + if not task_result: + logger.error("IMAGE_MODERATION_API_FAIL | provider=aliyun | category=empty_result") + return ModerationResult(passed=False, suggestion="review", + details={"error": "审核服务暂时不可用,需人工审核"}) # 检查所有场景的审核结果 max_rate = 0 @@ -338,7 +352,11 @@ async def moderate_image_aliyun(image_content: bytes) -> ModerationResult: for scene_result in task_result: scene = scene_result.get("scene", "") - suggestion = scene_result.get("suggestion", "pass") + suggestion = str(scene_result.get("suggestion", "")).lower() + if suggestion not in {"pass", "review", "block"}: + logger.error("IMAGE_MODERATION_API_FAIL | provider=aliyun | category=invalid_result") + return ModerationResult(passed=False, suggestion="review", + details={"error": "审核服务暂时不可用,需人工审核"}) rate = scene_result.get("rate", 0) label = scene_result.get("label", "") @@ -355,18 +373,18 @@ async def moderate_image_aliyun(image_content: bytes) -> ModerationResult: passed = overall_suggestion == "pass" - logger.info(f"阿里云审核结果: suggestion={overall_suggestion}, label={block_label}, rate={max_rate}") + logger.info(f"IMAGE_MODERATION_RESULT | provider=aliyun | suggestion={overall_suggestion}") return ModerationResult( passed=passed, label=block_label, confidence=max_rate * 100, suggestion=overall_suggestion, - details=result + details={"provider": "aliyun"} ) except Exception as e: - logger.error(f"阿里云审核异常: {str(e)}") + logger.error(f"阿里云审核异常: error_type={type(e).__name__}") # 审核服务异常时拒绝,需要人工审核 return ModerationResult(passed=False, suggestion="review", details={"error": "审核服务暂时不可用,需人工审核"}) @@ -397,7 +415,7 @@ def _is_moderation_enabled() -> bool: finally: db.close() except Exception as e: - logger.debug(f"从数据库读取审核配置失败,降级到文件: {e}") + logger.debug(f"从数据库读取审核配置失败,降级到文件: error_type={type(e).__name__}") # 2️⃣ 降级到文件 try: @@ -407,7 +425,7 @@ def _is_moderation_enabled() -> bool: if "image_moderation_enabled" in config: return config["image_moderation_enabled"] == True except Exception as e: - logger.debug(f"读取系统配置文件失败: {e}") + logger.debug(f"读取系统配置文件失败: error_type={type(e).__name__}") # 3️⃣ 回退到环境变量 return MODERATION_ENABLED diff --git a/json_file_lock.py b/json_file_lock.py new file mode 100644 index 0000000000000000000000000000000000000000..c06832796ef852c2329085bf70f2b59ce8a470e8 --- /dev/null +++ b/json_file_lock.py @@ -0,0 +1,68 @@ +"""同一数据目录内的稳定协调锁,不锁会被原子替换的JSON inode。""" +import hashlib +import os +import sys +import threading +import time + + +class DataFileLock: + def __init__(self, directory, file_name, timeout=30): + if (not isinstance(file_name, str) or not file_name or file_name in ('.', '..') + or any(char in file_name for char in '/\\:') or any(ord(char) < 32 for char in file_name)): + raise ValueError('invalid data file name') + self.directory = os.path.abspath(directory) + self.path = os.path.join(self.directory, '.locks', hashlib.sha256(os.path.normcase(file_name).encode()).hexdigest() + '.lock') + self.timeout = timeout + self.thread_lock = threading.Lock() + self.handle = None + + def __enter__(self): + self.thread_lock.acquire() + handle = None + try: + os.makedirs(os.path.dirname(self.path), exist_ok=True) + handle = open(self.path, 'a+b') + # Windows允许锁定EOF之外的字节;不初始化写入,避免首次并发争用锁区。 + deadline = time.monotonic() + self.timeout + while True: + try: + if sys.platform == 'win32': + import msvcrt + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except OSError as error: + import errno + if error.errno not in (errno.EACCES, errno.EAGAIN, errno.EDEADLK): + raise + if time.monotonic() >= deadline: + raise TimeoutError('data coordination lock timed out') from None + time.sleep(.05) + self.handle = handle + return self + except BaseException: + if handle is not None: + handle.close() + self.thread_lock.release() + raise + + def __exit__(self, exc_type, exc_value, traceback): + handle, self.handle = self.handle, None + try: + if sys.platform == 'win32': + import msvcrt + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + finally: + try: + handle.close() + finally: + self.thread_lock.release() + return False diff --git a/media_proxy_service.py b/media_proxy_service.py new file mode 100644 index 0000000000000000000000000000000000000000..9b7144158eca9d17f0f65f347567a1d5cbda6ff6 --- /dev/null +++ b/media_proxy_service.py @@ -0,0 +1,65 @@ +"""公开媒体读取服务;仅代理既有允许目录与扩展名,不处理工作流下载。""" +import logging +import mimetypes +import os +import time +import urllib.parse +import uuid +from fastapi import HTTPException +from fastapi.responses import JSONResponse + +logger = logging.getLogger("ComfyUI-Ranking") + + +def read_public_media(url=None, path=None, *, store, download, file_response, error_response): + """公开媒体服务:使用服务凭据读取既有允许目录中的媒体""" + + # 兼容处理:如果本地发来的是已经被污染的老版本 HF 直链,我们自动将其转换为相对路径 + if url and url.startswith("https://huggingface.co/datasets/"): + try: + path = url.split("resolve/main/")[-1] + path = urllib.parse.unquote(path) + except Exception: + raise HTTPException(status_code=400, detail="无效的 HF 原链接格式") + + if not path: + raise HTTPException(status_code=400, detail="缺少路径参数") + + # 🛡️ 路径穿越防护:禁止包含 .. 和绝对路径 + if ".." in path or path.startswith("/"): + raise HTTPException(status_code=400, detail="非法路径") + + # 🛡️ 绝对的安全红线:限制只能代理下载图片目录,严禁黑客通过此接口下载 users.json 或账本数据! + allowed_dirs = ["uploads/", "avatars/", "covers/"] + if not any(path.startswith(d) for d in allowed_dirs): + raise HTTPException(status_code=403, detail="非法访问:该接口仅允许代理图片资源") + + media_extensions = {"jpg", "jpeg", "png", "gif", "webp", "mp4", "webm", "mov", "wav", "mp3", "m4a", "ogg"} + if path.rsplit(".", 1)[-1].lower() not in media_extensions: + raise HTTPException(status_code=403, detail="该接口不允许访问非媒体文件") + + hf_token = os.environ.get("HF_TOKEN") + dataset_repo_id = store.DATASET_REPO_ID + + try: + # hf_hub_download 会自动利用云端容器的缓存,只有第一次会去真实请求 Dataset + cached_file_path = download( + repo_id=dataset_repo_id, + repo_type="dataset", + filename=path, + token=hf_token + ) + # 智能识别文件类型 (image/jpeg, image/png 等) + content_type, _ = mimetypes.guess_type(cached_file_path) + return file_response(cached_file_path, media_type=content_type or "application/octet-stream") + except Exception as e: + error_id = f"ERR_{int(time.time())}_{uuid.uuid4().hex[:8]}" + upstream_status = getattr(getattr(e, "response", None), "status_code", None) + if not isinstance(upstream_status, int): + upstream_status = getattr(e, "code", None) + status_text = f" status={upstream_status}" if isinstance(upstream_status, int) and 100 <= upstream_status <= 599 else "" + logger.warning(f"IMAGE_PROXY_FAIL | error_id={error_id} | error_type={type(e).__name__}{status_text}") + return JSONResponse( + status_code=502, + content=error_response(502, "图片暂时无法获取,请稍后重试", "upstream_error", error_id=error_id), + ) diff --git a/models.py b/models.py index 31c3abdbe2308a15ce4c6da7e56530c86b95ea4c..a6d267f22c5ff9872000efe5896c4bb9ffdcfd9d 100644 --- a/models.py +++ b/models.py @@ -79,6 +79,7 @@ class CommentCreate(BaseModel): parent_id: Optional[str] = None class ItemCreate(BaseModel): + request_id: Optional[str] = Field(default=None, strict=True, pattern=r'^[0-9a-f]{32}$') type: str title: str shortDesc: str @@ -87,7 +88,7 @@ class ItemCreate(BaseModel): coverUrl: Optional[str] = None imageUrls: Optional[List[str]] = [] author: str - price: int = 0 + price: int = Field(default=0, ge=0, strict=True) github_token: Optional[str] = None netdisk_password: Optional[str] = None # ☁️ 网盘提取码(加密存储,购买后解密) is_netdisk: Optional[bool] = False # ☁️ 是否为网盘资源 @@ -95,13 +96,14 @@ class ItemCreate(BaseModel): allow_refund: Optional[bool] = True # 💸 是否支持退款(默认支持) class ItemUpdate(BaseModel): + expected_revision: Optional[int] = Field(default=None, ge=0, le=9007199254740991, strict=True) title: Optional[str] = None shortDesc: Optional[str] = None fullDesc: Optional[str] = None link: Optional[str] = None coverUrl: Optional[str] = None - imageUrls: Optional[List[str]] = [] - price: Optional[int] = None + imageUrls: Optional[List[str]] = None + price: Optional[int] = Field(default=None, ge=0, strict=True) github_token: Optional[str] = None netdisk_password: Optional[str] = None # ☁️ 网盘提取码 is_netdisk: Optional[bool] = None # ☁️ 是否为网盘资源 @@ -144,12 +146,16 @@ class RatingRequest(BaseModel): class RechargeRequest(BaseModel): account: str - amount: int = Field(..., ge=1, le=10000) # 单次充值1-10000 + amount: int = Field(..., ge=1, le=10000, strict=True) # 单次充值1-10000 method: Optional[str] = "alipay" class PurchaseRequest(BaseModel): account: str item_id: str + expected_price: Optional[int] = Field(default=None, ge=0, strict=True) + +class PromptPurchaseRequest(BaseModel): + expected_price: Optional[int] = Field(default=None, ge=0, strict=True) class TipRequest(BaseModel): sender_account: str @@ -160,10 +166,10 @@ class TipRequest(BaseModel): class WithdrawRequest(BaseModel): account: str - amount: int = Field(..., ge=1, le=5000) # 单次提现1-5000 - alipayAccount: str - real_name: str - code: str + amount: int = Field(..., ge=1, le=5000, strict=True) # 单次提现1-5000 + alipayAccount: str = Field(..., min_length=1, max_length=256) + real_name: str = Field(..., min_length=1, max_length=128) + code: str = Field(..., pattern=r"^[0-9]{6}$") # ========================================== @@ -175,8 +181,8 @@ class TaskCreate(BaseModel): description: str # 任务详情 referenceImages: Optional[List[str]] = [] # 参考图(最多6张) referenceLink: Optional[str] = None # 参考链接 - totalPrice: int # 总价格(积分) - depositRatio: int # 订金比例(10/20/30/50) + totalPrice: int = Field(..., ge=0, le=2147483647, strict=True) # SQL整数范围 + depositRatio: int = Field(..., strict=True) # 订金比例(10/20/30/50) deadline: str # 截止时间 (ISO格式) publisher: str # 发布者账号 @@ -186,8 +192,8 @@ class TaskUpdate(BaseModel): description: Optional[str] = None referenceImages: Optional[List[str]] = None referenceLink: Optional[str] = None - totalPrice: Optional[int] = None - depositRatio: Optional[int] = None + totalPrice: Optional[int] = Field(None, ge=0, le=2147483647, strict=True) + depositRatio: Optional[int] = Field(None, strict=True) deadline: Optional[str] = None # 🔧 P2修复:以下三个 body 模型修复前后端参数通道不匹配 @@ -251,7 +257,7 @@ class PromptCreate(BaseModel): audio_url: Optional[str] = None # 示例音频 URL(仅音乐提示词,图片+音频方案;视频可选) images: Optional[List[str]] = None # 图像提示词图片列表(最多9张,首张为封面) is_original: bool = False # 原创作品标记 - price: int = 0 # 定价(0=免费) + price: int = Field(default=0, ge=0, le=10000, strict=True) # 提示词定价(0=免费) tags: Optional[List[str]] = None # 自定义标签(最多10个,每个≤12字) class PromptUpdate(BaseModel): @@ -266,10 +272,10 @@ class PromptUpdate(BaseModel): audio_url: Optional[str] = None # 示例音频 URL(仅音乐提示词;传 null 表示清除) images: Optional[List[str]] = None # 图像提示词图片列表(最多9张,首张为封面) is_original: Optional[bool] = None # 原创作品标记 - price: Optional[int] = None + price: Optional[int] = Field(default=None, ge=0, le=10000, strict=True) tags: Optional[List[str]] = None class PromptCategoryAdd(BaseModel): """ 添加提示词分类 """ prompt_type: str # "image"、"video" 或 "music" - name: str # 分类名称(≤8字,去重) \ No newline at end of file + name: str # 分类名称(≤8字,去重) diff --git a/models_sql.py b/models_sql.py index cfcffa19d7080f366913157a3deb67ade5ab1964..782b19cdc828f0893aed0307600ef6f9bd6c497c 100644 --- a/models_sql.py +++ b/models_sql.py @@ -1,131 +1,288 @@ -# models_sql.py -# ========================================== -# 🗄️ SQL 数据库模型定义 -# ========================================== -# 🚀 P1性能优化:添加复合索引加速查询 -# ========================================== - -from sqlalchemy import Column, Integer, String, DateTime, Boolean, Index, func -from sqlalchemy.orm import declarative_base -import datetime - -Base = declarative_base() - -class Wallet(Base): - """用户钱包表""" - __tablename__ = "wallets" - - account = Column(String, primary_key=True, index=True) - balance = Column(Integer, default=0, nullable=False) # 用于消费的余额 (充值获得) - earn_balance = Column(Integer, default=0, nullable=False) # 创作者销售收益余额 (别人购买获得) - tip_balance = Column(Integer, default=0, nullable=False) # 【新增】创作者打赏收益余额 (粉丝赞助获得) - task_balance = Column(Integer, default=0, nullable=False) # 【新增】任务相关余额 - frozen_balance = Column(Integer, default=0, nullable=False) # 提现审核冻结中的余额 - - # 乐观锁版本号,防止并发扣款被击穿 - version = Column(Integer, default=1) - -class Ownership(Base): - """资源所有权表""" - __tablename__ = "ownerships" - - id = Column(Integer, primary_key=True, autoincrement=True) - account = Column(String, index=True) - item_id = Column(String, index=True) - purchased_at = Column(DateTime, default=datetime.datetime.utcnow) - # 🔄 P7后悔模式增强 - price_paid = Column(Integer, default=0) # 购买时支付的价格 - is_refunded = Column(Boolean, default=False) # 是否已退款 - refunded_at = Column(DateTime, nullable=True) # 退款时间 - - # 🚀 P1性能优化:复合索引 - __table_args__ = ( - Index('ix_ownerships_account_item', 'account', 'item_id'), - Index('ix_ownerships_account_refunded', 'account', 'is_refunded'), - ) - -class Refund(Base): - """🔄 P7后悔模式:退款记录表""" - __tablename__ = "refunds" - - id = Column(Integer, primary_key=True, autoincrement=True) - account = Column(String, index=True) # 退款用户 - item_id = Column(String, index=True) # 退款商品 - amount = Column(Integer) # 退款金额 - refunded_at = Column(DateTime, default=datetime.datetime.utcnow) - ban_until = Column(DateTime) # 禁止购买截止时间(30天后) - -class RechargeOrder(Base): - """💰 充值订单预存表(订单号→账号映射持久化,防止实例重启导致回调找不到订单)""" - __tablename__ = "recharge_orders" - - order_id = Column(String, primary_key=True) # 商户订单号 PAY_xxx - account = Column(String, index=True) # 充值用户 - amount = Column(Integer) # 充值金额 - status = Column(String, default="pending") # pending/paid - created_at = Column(DateTime, default=datetime.datetime.utcnow) - -class Transaction(Base): - """交易流水表""" - __tablename__ = "transactions" - - tx_id = Column(String, primary_key=True) - account = Column(String, index=True) - tx_type = Column(String) # 枚举: RECHARGE, PURCHASE, SALE, TIP_OUT, TIP_IN, WITHDRAW, WITHDRAW_FEE, REFUND, TASK_FREEZE, TASK_DEPOSIT, TASK_PAYMENT, TASK_INCOME, TASK_REFUND, TASK_CANCEL_REFUND - amount = Column(Integer) - related_account = Column(String, nullable=True) - item_id = Column(String, nullable=True) - created_at = Column(DateTime, default=datetime.datetime.utcnow, index=True) # 🚀 P1: 添加时间索引 - prev_hash = Column(String) # 上一笔订单的哈希 - tx_hash = Column(String) # 本笔订单的哈希 (防篡改) - - # 💰 提现相关字段 (仅 WITHDRAW_APPLY 类型交易使用) - alipay_account = Column(String, nullable=True) # 收款支付宝账号 - real_name = Column(String, nullable=True) # 收款人姓名 - withdraw_status = Column(String, nullable=True) # pending / completed - payment_order_id = Column(String, nullable=True) # 管理员填写的打款订单号 - net_amount = Column(Integer, nullable=True) # 实际到账金额(用于解冻时准确扣减) - - # 交易详情增强字段 - description = Column(String, nullable=True) # 交易自描述文本 - item_title = Column(String, nullable=True) # 资源/任务标题 - item_type = Column(String, nullable=True) # 资源类型 (tool/skill/app/recommend/post/task/prompt) - related_user_name = Column(String, nullable=True) # 对方用户名 - - # 🌐 开放API:来源插件标识(如 "nodecraft_ai"),用于按工具结构化追踪消费 - source_plugin = Column(String(50), nullable=True, index=True) - - # 🚀 P1性能优化:复合索引 - __table_args__ = ( - Index('ix_transactions_account_type', 'account', 'tx_type'), - Index('ix_transactions_account_created', 'account', 'created_at'), - Index('ix_transactions_source_plugin_created', 'source_plugin', 'created_at'), - ) - -class SystemConfig(Base): - """系统配置表 - 持久化存储,替代 /tmp/system_config.json""" - __tablename__ = "system_configs" - - config_key = Column(String, primary_key=True) - config_value = Column(String, nullable=False, default="{}") - updated_at = Column(DateTime, default=datetime.datetime.utcnow) - updated_by = Column(String, nullable=True) - - -class RegisteredPlugin(Base): - """🌐 开放API:第三方插件注册信息表""" - __tablename__ = "registered_plugins" - - plugin_id = Column(String(50), primary_key=True) # 英文标识如 "nodecraft_ai" - plugin_key = Column(String(100), unique=True, nullable=False, index=True) - name = Column(String(100), nullable=False) # 显示名 - description = Column(String(500), nullable=True) - enabled = Column(Boolean, default=True, nullable=False) - rate_limit = Column(Integer, default=100) # 每分钟最大请求数 - max_deduct_per_tx = Column(Integer, default=100) # 单笔扣款上限 - max_deduct_per_day = Column(Integer, default=1000) # 日扣款上限 - permissions = Column(String(200), default='["verify","balance","deduct","refund"]') # JSON数组 - created_at = Column(DateTime, default=func.now()) - created_by = Column(String(50)) # 创建者管理员账号 - updated_at = Column(DateTime, onupdate=func.now()) - last_rotated_at = Column(DateTime, nullable=True) # 上次轮换key时间 +# models_sql.py +# ========================================== +# 🗄️ SQL 数据库模型定义 +# ========================================== +# 🚀 P1性能优化:添加复合索引加速查询 +# ========================================== + +from sqlalchemy import Column, Integer, String, DateTime, Boolean, Index, func, Text +from sqlalchemy.orm import declarative_base +import datetime + +Base = declarative_base() + + +class ResourcePublication(Base): + """同次资源发布的持久原始请求和恢复内容;不参与资金变更。""" + __tablename__ = "resource_publications" + request_key = Column(String(64), primary_key=True) + item_id = Column(String(100), nullable=False, unique=True) + author = Column(String, nullable=False) + request_payload = Column(Text, nullable=False) + payload = Column(Text, nullable=False) + projected = Column(Boolean, nullable=False, default=False, index=True) + created_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False) + + +class TaskPublication(Base): + """与任务资金冻结同提交的待发布记录;保留原始内容供失败恢复。""" + __tablename__ = "task_publications" + task_id = Column(String(64), primary_key=True) + publisher = Column(String, nullable=False) + payload = Column(Text, nullable=False) + projected = Column(Boolean, nullable=False, default=False, index=True) + created_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False) + + +class TaskRevision(Base): + """任务编辑的持久字段投影,与改价资金同事务;版本按钱包锁串行递增。""" + __tablename__ = "task_revisions" + task_id = Column(String(64), primary_key=True) + revision = Column(Integer, primary_key=True) + publisher = Column(String, nullable=False) + payload = Column(Text, nullable=False) + projected = Column(Boolean, nullable=False, default=False, index=True) + created_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False) + + +class TaskAssignment(Base): + """指派和订金SQL提交后的状态投影,不能因JSON失败再次扣订金。""" + __tablename__ = "task_assignments" + task_id = Column(String(64), primary_key=True) + publisher = Column(String, nullable=False) + assignee = Column(String, nullable=False) + deposit = Column(Integer, nullable=False) + assigned_at = Column(Integer, nullable=False) + projected = Column(Boolean, nullable=False, default=False, index=True) + + +class TaskDelivery(Base): + """交付/退回最新版本先持久保存,JSON失败不能丢失成果或恢复旧验收。""" + __tablename__ = "task_deliveries" + task_id = Column(String(64), primary_key=True) + publisher = Column(String, nullable=False) + assignee = Column(String, nullable=False) + revision = Column(Integer, nullable=False) + payload = Column(Text, nullable=False) + updated_at = Column(Integer, nullable=False) + projected = Column(Boolean, nullable=False, default=False, index=True) + + +class TaskSettlement(Base): + """验收付款和待完成状态同事务;一任务只结算一次。""" + __tablename__ = "task_settlements" + task_id = Column(String(64), primary_key=True) + publisher = Column(String, nullable=False) + assignee = Column(String, nullable=False) + amount = Column(Integer, nullable=False) + deposit = Column(Integer, nullable=False) + payment_tx_id = Column(String, nullable=False) + income_tx_id = Column(String, nullable=False) + completed_at = Column(Integer, nullable=False) + feedback = Column(Text, nullable=True) + projected = Column(Boolean, nullable=False, default=False, index=True) + + +class TaskResolution(Base): + """仲裁资金和裁决同提交;一任务只允许一次最终分配。""" + __tablename__ = "task_resolutions" + task_id = Column(String(64), primary_key=True) + dispute_id = Column(String(64), nullable=False, unique=True) + publisher = Column(String, nullable=False) + assignee = Column(String, nullable=False) + amount = Column(Integer, nullable=False) + deposit = Column(Integer, nullable=False) + publisher_refund = Column(Integer, nullable=False) + assignee_income = Column(Integer, nullable=False) + initiator_role = Column(String(16), nullable=False) + resolution = Column(String(32), nullable=False) + ratio = Column(Integer, nullable=True) + note = Column(Text, nullable=True) + resolved_by = Column(String, nullable=False) + resolved_at = Column(Integer, nullable=False) + payment_tx_id = Column(String, nullable=False) + refund_tx_id = Column(String, nullable=False) + income_tx_id = Column(String, nullable=False) + projected = Column(Boolean, nullable=False, default=False, index=True) + +class TaskDisputeRecord(Base): + """申诉先持久保存;JSON同步期间也禁止普通验收结算。""" + __tablename__ = "task_dispute_records" + task_id = Column(String(64), primary_key=True) + dispute_id = Column(String(64), nullable=False, unique=True) + publisher = Column(String, nullable=False) + assignee = Column(String, nullable=False) + payload = Column(Text, nullable=False) + projected = Column(Boolean, nullable=False, default=False, index=True) + created_at = Column(Integer, nullable=False) + + +class TaskDisputeResponseRecord(Base): + """首次申诉回应持久记录;一个任务只保存一份原始回应。""" + __tablename__ = "task_dispute_responses" + task_id = Column(String(64), primary_key=True) + dispute_id = Column(String(64), nullable=False, unique=True) + publisher = Column(String, nullable=False) + assignee = Column(String, nullable=False) + respondent = Column(String, nullable=False) + payload = Column(Text, nullable=False) + projected = Column(Boolean, nullable=False, default=False, index=True) + + +class Wallet(Base): + """用户钱包表""" + __tablename__ = "wallets" + + account = Column(String, primary_key=True, index=True) + balance = Column(Integer, default=0, nullable=False) # 用于消费的余额 (充值获得) + earn_balance = Column(Integer, default=0, nullable=False) # 创作者销售收益余额 (别人购买获得) + tip_balance = Column(Integer, default=0, nullable=False) # 【新增】创作者打赏收益余额 (粉丝赞助获得) + task_balance = Column(Integer, default=0, nullable=False) # 【新增】任务相关余额 + frozen_balance = Column(Integer, default=0, nullable=False) # 提现审核冻结中的余额 + + # 预留版本列;未配置 version_id_col,不提供 ORM 乐观锁保护 + version = Column(Integer, default=1) + +class Ownership(Base): + """资源所有权表""" + __tablename__ = "ownerships" + + id = Column(Integer, primary_key=True, autoincrement=True) + account = Column(String, index=True) + item_id = Column(String, index=True) + purchased_at = Column(DateTime, default=datetime.datetime.utcnow) + # 🔄 P7后悔模式增强 + price_paid = Column(Integer, default=0) # 购买时支付的价格 + is_refunded = Column(Boolean, default=False) # 是否已退款 + refunded_at = Column(DateTime, nullable=True) # 退款时间 + + # 🚀 P1性能优化:复合索引 + __table_args__ = ( + Index('ix_ownerships_account_item', 'account', 'item_id'), + Index('ix_ownerships_account_refunded', 'account', 'is_refunded'), + ) + +class Refund(Base): + """🔄 P7后悔模式:退款记录表""" + __tablename__ = "refunds" + + id = Column(Integer, primary_key=True, autoincrement=True) + account = Column(String, index=True) # 退款用户 + item_id = Column(String, index=True) # 退款商品 + amount = Column(Integer) # 退款金额 + refunded_at = Column(DateTime, default=datetime.datetime.utcnow) + ban_until = Column(DateTime) # 禁止购买截止时间(30天后) + +class RechargeOrder(Base): + """💰 充值订单预存表(订单号→账号映射持久化,防止实例重启导致回调找不到订单)""" + __tablename__ = "recharge_orders" + + order_id = Column(String, primary_key=True) # 商户订单号 PAY_xxx + account = Column(String, index=True) # 充值用户 + amount = Column(Integer) # 充值金额 + status = Column(String, default="pending") # pending/paid + created_at = Column(DateTime, default=datetime.datetime.utcnow) + +class Transaction(Base): + """交易流水表""" + __tablename__ = "transactions" + + tx_id = Column(String, primary_key=True) + account = Column(String, index=True) + 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 + amount = Column(Integer) + related_account = Column(String, nullable=True) + item_id = Column(String, nullable=True) + created_at = Column(DateTime, default=datetime.datetime.utcnow, index=True) # 🚀 P1: 添加时间索引 + prev_hash = Column(String) # 上一笔订单的哈希 + tx_hash = Column(String) # 本笔订单的哈希 (防篡改) + + # 💰 提现相关字段 (仅 WITHDRAW_APPLY 类型交易使用) + alipay_account = Column(String, nullable=True) # 收款支付宝账号 + real_name = Column(String, nullable=True) # 收款人姓名 + withdraw_status = Column(String, nullable=True) # pending / completed + payment_order_id = Column(String, nullable=True) # 管理员填写的打款订单号 + net_amount = Column(Integer, nullable=True) # 实际到账金额(用于解冻时准确扣减) + + # 交易详情增强字段 + description = Column(String, nullable=True) # 交易自描述文本 + item_title = Column(String, nullable=True) # 资源/任务标题 + item_type = Column(String, nullable=True) # 资源类型 (tool/skill/app/recommend/post/task/prompt) + related_user_name = Column(String, nullable=True) # 对方用户名 + + # 🌐 开放API:来源插件标识(如 "nodecraft_ai"),用于按工具结构化追踪消费 + source_plugin = Column(String(50), nullable=True, index=True) + + # 🚀 P1性能优化:复合索引 + __table_args__ = ( + Index('ix_transactions_account_type', 'account', 'tx_type'), + Index('ix_transactions_account_created', 'account', 'created_at'), + Index('ix_transactions_source_plugin_created', 'source_plugin', 'created_at'), + ) + +class SystemConfig(Base): + """系统配置表 - 持久化存储,替代 /tmp/system_config.json""" + __tablename__ = "system_configs" + + config_key = Column(String, primary_key=True) + config_value = Column(String, nullable=False, default="{}") + updated_at = Column(DateTime, default=datetime.datetime.utcnow) + updated_by = Column(String, nullable=True) + + +class RegisteredPlugin(Base): + """🌐 开放API:第三方插件注册信息表""" + __tablename__ = "registered_plugins" + + plugin_id = Column(String(50), primary_key=True) # 英文标识如 "nodecraft_ai" + plugin_key = Column(String(100), unique=True, nullable=False, index=True) + name = Column(String(100), nullable=False) # 显示名 + description = Column(String(500), nullable=True) + enabled = Column(Boolean, default=True, nullable=False) + rate_limit = Column(Integer, default=100) # 每分钟最大请求数 + max_deduct_per_tx = Column(Integer, default=100) # 单笔扣款上限 + max_deduct_per_day = Column(Integer, default=1000) # 日扣款上限 + permissions = Column(String(200), default='["verify","balance","deduct","refund"]') # JSON数组 + created_at = Column(DateTime, default=func.now()) + created_by = Column(String(50)) # 创建者管理员账号 + updated_at = Column(DateTime, onupdate=func.now()) + last_rotated_at = Column(DateTime, nullable=True) # 上次轮换key时间 + + +class OpenApiRateLimitBucket(Base): + """跨 worker 持久化的开放 API 插件分钟窗口计数。""" + __tablename__ = "open_api_rate_limit_buckets" + + plugin_id = Column(String(50), primary_key=True) + window_start = Column(Integer, nullable=False) + request_count = Column(Integer, nullable=False, default=0) + updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow) + + +class ApiRateLimitBucket(Base): + """跨 worker 共享的常规 API 限流分钟窗口。""" + __tablename__ = "api_rate_limit_buckets" + + bucket_key = Column(String(64), primary_key=True) + window_start = Column(Integer, nullable=False) + request_count = Column(Integer, nullable=False, default=0) + updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow) + + +class VerificationContactCooldown(Base): + """验证码联系方式发送冷却;只持久化脱敏哈希键,不保存原始邮箱/手机号。""" + __tablename__ = "verification_contact_cooldowns" + + contact_key = Column(String(64), primary_key=True) + last_sent_at = Column(Integer, nullable=False) + claim_id = Column(String(32), nullable=False) + + +class VerificationCode(Base): + """跨 worker 一次性验证码;仅保存联系方式哈希和带密钥的验证码摘要。""" + __tablename__ = "verification_codes" + + contact_key = Column(String(64), primary_key=True) + code_digest = Column(String(64), nullable=False) + expires_at = Column(Integer, nullable=False, index=True) + updated_at = Column(DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow) diff --git a/notification_store.py b/notification_store.py new file mode 100644 index 0000000000000000000000000000000000000000..5c74a757efbed9d5ee40a86d16e6d8c018113a2c --- /dev/null +++ b/notification_store.py @@ -0,0 +1,165 @@ +"""通知与公告的严格读取、白名单输出和锁内更新。""" +import copy +import logging +import math +import time +import uuid + +from fastapi import HTTPException +import 数据库连接 as db + +logger = logging.getLogger(__name__) +TTL = 7 * 24 * 3600 +FIELDS = frozenset({"id", "type", "from_user", "from_name", "from_avatar", "target_item_id", + "target_item_title", "content", "is_read", "created_at", "receiver"}) + + +def _unavailable(): + return HTTPException(status_code=503, detail="通知数据暂不可用") + + +def _timestamp(message): + value = message["created_at"] + return value / 1000 if value >= 1e12 else value + + +def _validate(message): + if not isinstance(message, dict): + raise _unavailable() + if any(not isinstance(message.get(key), str) or not message[key] for key in ("id", "type")): + raise _unavailable() + timestamp = message.get("created_at") + try: + valid_timestamp = type(timestamp) in (int, float) and math.isfinite(timestamp) and timestamp >= 0 + except (OverflowError, ValueError): + valid_timestamp = False + if not valid_timestamp: + raise _unavailable() + if not isinstance(message.get("content", ""), str) or type(message.get("is_read", False)) is not bool: + raise _unavailable() + return message + + +def _messages(data, account): + if not isinstance(data, dict) or not isinstance(data.get(account, []), list): + raise _unavailable() + messages = data.get(account, []) + for message in messages: + _validate(message) + if "receiver" in message and message["receiver"] != account: + raise _unavailable() + return messages + + +def _announcements(data): + if not isinstance(data, list): + raise _unavailable() + for message in data: + if _validate(message)["type"] != "system": + raise _unavailable() + return data + + +def _read(name, default): + try: + return db.load_data(name, default_data=default, skip_cache=True, strict=True) + except Exception as error: + logger.warning("通知读取失败: %s", type(error).__name__) + raise _unavailable() from None + + +def _mutate(name, updater, default): + try: + return db.atomic_update(name, updater, default_data=default) + except HTTPException: + raise + except Exception as error: + logger.warning("通知写入失败: %s", type(error).__name__) + raise _unavailable() from None + + +def _profile(account): + users = _read("users.json", {}) + if not isinstance(users, dict) or not isinstance(users.get(account, {}), dict): + raise _unavailable() + return users.get(account, {}) + + +def publish_announcement(account, content): + if not isinstance(content, str) or not content.strip(): + raise HTTPException(status_code=400, detail="公告内容不能为空") + user = _profile(account) + announcement = {"id": f"sys_{int(time.time())}_{uuid.uuid4().hex}", "type": "system", "from_user": account, + "from_name": user.get("name", account), "from_avatar": user.get("avatarDataUrl", ""), + "content": content, "created_at": int(time.time())} + def update(data): + _announcements(data).append(announcement) + _mutate("announcements.json", update, []) + + +def append_notification(account, payload): + source = payload.get("from_user") + if not account or source == account: + return + user = _profile(source) if source and source != "system" else {} + message = {"id": f"msg_{int(time.time())}_{uuid.uuid4().hex}", "type": payload.get("type"), + "from_user": source, "from_name": user.get("name", source) if source and source != "system" else "系统通知", + "from_avatar": user.get("avatarDataUrl", ""), "target_item_id": payload.get("target_item_id", ""), + "target_item_title": payload.get("target_item_title", ""), "content": payload.get("content", ""), + "is_read": False, "created_at": int(time.time())} + _validate(message) + def update(data): + messages = _messages(data, account) + data[account] = [message, *messages] + _mutate("messages.json", update, {}) + + +def get_messages(account, count_only=False): + now = time.time() + announcements = _announcements(_read("announcements.json", [])) + # 过期公告不重复注入已被清理的旧通知,也不在角标中重新出现。 + active = [message for message in announcements if now - _timestamp(message) < TTL] + messages = _messages(_read("messages.json", {}), account) + + def combine(messages): + result = copy.deepcopy(messages) + ids = {message["id"] for message in result} + for message in active: + if message["id"] not in ids: + result.append({**copy.deepcopy(message), "receiver": account, "is_read": False}) + ids.add(message["id"]) + return result + + def unread(messages): + return sum(not message.get("is_read", False) and message["type"] not in ("private", "plugin_update") + and now - _timestamp(message) < TTL for message in messages) + + def project(messages): + return [copy.deepcopy({key: value for key, value in message.items() if key in FIELDS}) for message in messages] + + combined = combine(messages) + if count_only: + return {"status": "success", "unread_count": unread(combined)} + if len(combined) == len(messages) and all(message.get("is_read", False) and now - _timestamp(message) < TTL for message in messages): + return {"status": "success", "data": project(messages), "unread_count": 0} + + def update(data): + latest = combine(_messages(data, account)) + count = unread(latest) + kept = [message for message in latest if not message.get("is_read", False) or now - _timestamp(message) < TTL] + for message in kept: + message["is_read"] = True + kept.sort(key=_timestamp, reverse=True) + data[account] = kept + return {"status": "success", "data": project(kept), "unread_count": count} + return _mutate("messages.json", update, {}) + + +def mark_messages_read(account): + messages = _messages(_read("messages.json", {}), account) + if all(message.get("is_read", False) for message in messages): + return + def update(data): + for message in _messages(data, account): + message["is_read"] = True + _mutate("messages.json", update, {}) diff --git a/notifications.py b/notifications.py index f3e9be8c17e0df7b14b5e9e4ebe4f5f85a6338e5..1175005b7c36721b24139618ebb17df6ca8646f6 100644 --- a/notifications.py +++ b/notifications.py @@ -1,44 +1,8 @@ -import time -import uuid -import 数据库连接 as db +import notification_store def add_notification(target_account: str, notif_data: dict): try: - from_user = notif_data.get("from_user") - if not target_account: - return - if from_user and target_account == from_user: - return # 不给自己发通知 - - users_db = db.load_data("users.json", default_data={}) - - if from_user == "system" or not from_user: - from_name = "系统通知" - from_avatar = "" - else: - user_info = users_db.get(from_user, {}) - from_name = user_info.get("name", from_user) - from_avatar = user_info.get("avatarDataUrl", "") - - notif = { - "id": f"msg_{int(time.time())}_{uuid.uuid4().hex[:6]}", - "type": notif_data.get("type"), - "from_user": from_user, - "from_name": from_name, - "from_avatar": from_avatar, - "target_item_id": notif_data.get("target_item_id", ""), - "target_item_title": notif_data.get("target_item_title", ""), - "content": notif_data.get("content", ""), - "is_read": False, - "created_at": int(time.time()) - } - - def updater(data): - if target_account not in data: - data[target_account] = [] - data[target_account].insert(0, notif) - - db.atomic_update("messages.json", updater, default_data={}) - except Exception as e: - print(f"⚠️ 通知发送失败 (target={target_account}): {e}") - # 不抛出异常,让主流程继续 \ No newline at end of file + notification_store.append_notification(target_account, notif_data) + except Exception as error: + # 通知属于派生操作,故障不影响已经完成的业务;不打印账号/正文。 + print(f"通知写入失败: error_type={type(error).__name__}") diff --git a/open_transaction.py b/open_transaction.py new file mode 100644 index 0000000000000000000000000000000000000000..9f91a9ab11aacf8a772ebc3697e82eb7320ac3ef --- /dev/null +++ b/open_transaction.py @@ -0,0 +1,47 @@ +"""开放资金接口的实时插件授权与钱包写锁;事务由调用方提交/回滚。""" +import hmac +import json +from decimal import Decimal + +from fastapi import HTTPException +from models_sql import RegisteredPlugin +from tip_transaction import lock_wallet_accounts + + +def validate_live_plugin(row, key, permission): + if row is None or not row.enabled: + raise HTTPException(status_code=403, detail="插件已停用或不存在") + 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')): + raise HTTPException(status_code=403, detail="Plugin Key 已失效") + try: + permissions = json.loads(row.permissions) + if not isinstance(permissions, list) or any(not isinstance(value, str) for value in permissions): + raise ValueError("invalid permissions") + except Exception: + raise HTTPException(status_code=503, detail="插件权限配置暂不可用") + if permission not in permissions: + raise HTTPException(status_code=403, detail="插件无权限调用该接口") + return row + + +def lock_open_wallet(session, account, plugin_id, key, permission): + # 首项资金SQL取得SQLite写事务;其它方言先锁钱包再锁插件,所有开放资金路径同序。 + wallet = lock_wallet_accounts(session, (account,))[account] + row = session.query(RegisteredPlugin).filter(RegisteredPlugin.plugin_id == plugin_id).populate_existing().with_for_update().first() + validate_live_plugin(row, key, permission) + return wallet, row + + +def positive_integer(value): + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise HTTPException(status_code=503, detail="插件额度配置暂不可用") + return value + + +def quota_total(value): + """SQL聚合可返回整数或精确Decimal;拒绝浮点/字符串/非整数与坏统计。""" + if isinstance(value, Decimal) and value.is_finite() and value >= 0 and value == value.to_integral_value(): + return int(value) + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise ValueError("invalid quota history") + return value diff --git a/owned_thread.py b/owned_thread.py new file mode 100644 index 0000000000000000000000000000000000000000..d9fe9a6370619db3edea0bbf14d03eafd50f8460 --- /dev/null +++ b/owned_thread.py @@ -0,0 +1,33 @@ +"""等待所属同步工作结束;取消不能遗留仍持有文件/SDK操作的线程。""" +import asyncio + + +async def run_owned_thread(function, *args, **kwargs): + async def finish(): + # 被取消的shield在部分Python版本会立即记录内层异常正文。 + # 先把常规线程结果封装为数据,所属调用再决定传播异常或取消。 + try: + return True, await asyncio.to_thread(function, *args, **kwargs) + except Exception as error: + return False, error + + task = asyncio.create_task(finish()) + cancelled = None + while not task.done(): + try: + await asyncio.shield(task) + except asyncio.CancelledError as error: + cancelled = error + except Exception: + break + if cancelled is not None: + # 消费线程结果/异常,取消仍是本次调用的终态。 + try: + task.result() + except Exception: + pass + raise cancelled + succeeded, result = task.result() + if not succeeded: + raise result + return result diff --git a/post_store.py b/post_store.py new file mode 100644 index 0000000000000000000000000000000000000000..eea015779a0d668843568bcb0e82e2729bf1189e --- /dev/null +++ b/post_store.py @@ -0,0 +1,91 @@ +"""帖子主记录在最新文件上的局部修改;不处理资金或评论跨文件清理。""" +import copy +from publication_identity import same_publication + + +class PostStoreError(Exception): + def __init__(self, status_code, message): + self.status_code = status_code + self.message = message + super().__init__(message) + + +class _Unchanged(Exception): + def __init__(self, result): self.result = result + + +def _records(source): + if not isinstance(source, list): + raise ValueError('invalid post source') + identities = set() + for record in source: + if (not isinstance(record, dict) or not isinstance(record.get('id'), str) + or not record['id'].strip() or record['id'] in identities + or not isinstance(record.get('author'), str) or not record['author'].strip()): + raise ValueError('invalid or ambiguous post record') + identities.add(record['id']) + return source + + +def _mutate(store, callback): + try: + return store.atomic_update('posts.json', lambda source: callback(_records(source)), default_data=[]) + except _Unchanged as unchanged: + return unchanged.result + except PostStoreError: + raise + except Exception: + # 替换后的同步故障也属于未知结果,不推断为未保存,不自动重放。 + raise PostStoreError(503, '帖子保存结果暂时无法确认,请稍后核对') from None + + +def publish_post(store, record, replay=False): + saved = copy.deepcopy(record) + try: + _records([saved]) + except ValueError: + raise PostStoreError(400, '帖子发布参数无效') from None + def append(records): + existing = next((item for item in records if item['id'] == saved['id']), None) + if existing is not None: + fields = ('author', 'title', 'content', 'cover_image', 'images', 'is_original', 'post_type', 'video_url') + if replay and same_publication(existing, saved, fields): + raise _Unchanged(copy.deepcopy(existing)) + raise PostStoreError(409, '帖子发布请求与已有记录冲突,请核对后再提交') + records.insert(0, copy.deepcopy(saved)) + return copy.deepcopy(saved) + return _mutate(store, append) + + +def _owned(records, post_id, account, action): + index = next((index for index, post in enumerate(records) if post['id'] == post_id), None) + if index is None: + raise PostStoreError(404, '帖子不存在') + if records[index]['author'] != account: + raise PostStoreError(403, f'无权{action}他人帖子') + return index + + +def edit_post(store, post_id, account, changes): + fields = {'title', 'content', 'cover_image', 'images', 'is_original', 'post_type', 'video_url'} + if not isinstance(changes, dict) or set(changes) - fields: + raise PostStoreError(400, '帖子编辑参数无效') + saved = {key: copy.deepcopy(value) for key, value in changes.items() if value is not None} + def edit(records): + index = _owned(records, post_id, account, '修改') + edited = copy.deepcopy(records[index]) + edited.update(copy.deepcopy(saved)) + if 'images' in saved: + edited['images'] = saved['images'][:9] + if saved.get('post_type') == 'video': + edited['images'] = [] + records[index] = edited + return copy.deepcopy(edited) + return _mutate(store, edit) + + +def delete_post(store, post_id, account): + def remove(records): + index = _owned(records, post_id, account, '删除') + records.pop(index) + return _mutate(store, remove) diff --git a/prompt_store.py b/prompt_store.py new file mode 100644 index 0000000000000000000000000000000000000000..33f3989e1767c36bc3212856bfe9f256ad084b9d --- /dev/null +++ b/prompt_store.py @@ -0,0 +1,146 @@ +"""提示词主记录与分类的锁内局部修改,价格只在副本上投影。""" +import copy +from publication_identity import same_publication +import datetime +from purchase_transaction import effective_resource_price + + +class PromptStoreError(Exception): + def __init__(self, status_code, message): + self.status_code = status_code + self.message = message + super().__init__(message) + + +class _Unchanged(Exception): + def __init__(self, result): self.result = result + + +def _mutate(store, filename, callback, default): + try: + return store.atomic_update(filename, callback, default_data=default) + except _Unchanged as unchanged: + return unchanged.result + except PromptStoreError: + raise + except Exception: + raise PromptStoreError(503, '提示词保存结果暂时无法确认,请稍后核对') from None + + +def _records(source): + if not isinstance(source, list): raise ValueError('invalid prompt collection') + ids = set() + for record in source: + if (not isinstance(record, dict) or not isinstance(record.get('id'), str) + or not record['id'].strip() or record['id'] in ids + or not isinstance(record.get('author'), str) or not record['author'].strip()): + raise ValueError('invalid prompt identity') + ids.add(record['id']) + return source + + +def read_prompts(store): + try: + return copy.deepcopy(_records(store.load_data('prompts.json', default_data=[], strict=True, skip_cache=True))) + except Exception: + raise PromptStoreError(503, '提示词暂时不可读取') from None + + +def project_price(record): + result = copy.deepcopy(record) + result['price'] = effective_resource_price(result) + if result.get('pending_price') is not None and result.get('pending_price_effective_at'): + when = datetime.datetime.fromisoformat(result['pending_price_effective_at']) + now = datetime.datetime.now(when.tzinfo) if when.tzinfo else datetime.datetime.now() + if now >= when: + result['pending_price'] = None + result['pending_price_effective_at'] = None + return result + + +def publish_prompt(store, record, replay=False): + saved = copy.deepcopy(record) + try: + _records([saved]) + effective_resource_price(saved) + except Exception: + raise PromptStoreError(400, '提示词发布参数无效') from None + def append(source): + records = _records(source) + existing = next((item for item in records if item['id'] == saved['id']), None) + if existing is not None: + fields = ('author', 'prompt_type', 'category', 'title', 'content', 'prompt_text', 'cover_image', + 'media_type', 'video_url', 'audio_url', 'images', 'is_original', 'price', 'tags') + if replay and same_publication(existing, saved, fields): + raise _Unchanged(copy.deepcopy(existing)) + raise PromptStoreError(409, '提示词发布请求与已有记录冲突,请核对后再提交') + records.insert(0, copy.deepcopy(saved)) + return copy.deepcopy(saved) + return _mutate(store, 'prompts.json', append, []) + + +def _owned(source, prompt_id, account, action): + records = _records(source) + index = next((index for index, record in enumerate(records) if record['id'] == prompt_id), None) + if index is None: raise PromptStoreError(404, '提示词不存在') + if records[index]['author'] != account: + raise PromptStoreError(403, f'无权{action}他人提示词') + return index + + +def edit_prompt(store, prompt_id, account, apply): + def edit(source): + index = _owned(source, prompt_id, account, '修改') + edited = project_price(source[index]) + apply(edited) + source[index] = edited + return copy.deepcopy(edited) + return _mutate(store, 'prompts.json', edit, []) + + +def delete_prompt(store, prompt_id, account): + def remove(source): source.pop(_owned(source, prompt_id, account, '删除')) + return _mutate(store, 'prompts.json', remove, []) + + +def _categories(source, defaults): + if not isinstance(source, dict): raise ValueError('invalid prompt categories') + result = copy.deepcopy(source) + for kind, values in defaults.items(): + existing = result.setdefault(kind, copy.deepcopy(values)) + if not isinstance(existing, list) or not all(isinstance(name, str) and name.strip() for name in existing): + raise ValueError('invalid prompt category group') + return result + + +def load_categories(store, defaults): + try: + source = store.load_data('prompt_categories.json', default_data={}, strict=True, skip_cache=True) + completed = _categories(source, defaults) + except Exception: + raise PromptStoreError(503, '提示词分类暂时不可读取') from None + if completed == source: return completed + def initialize(latest): + completed = _categories(latest, defaults) + if completed == latest: raise _Unchanged(completed) + latest.clear(); latest.update(completed) + return copy.deepcopy(completed) + return _mutate(store, 'prompt_categories.json', initialize, {}) + + +def modify_category(store, defaults, kind, name, *, delete=False, maximum=30): + if kind not in defaults or not isinstance(name, str) or not name.strip(): + raise PromptStoreError(400, '分类参数无效') + def update(source): + completed = _categories(source, defaults) + values = completed[kind] + if delete: + if name not in values: raise PromptStoreError(404, '分类不存在') + values.remove(name) + else: + if name in values: raise PromptStoreError(400, '该分类已存在') + if len(values) >= maximum: raise PromptStoreError(400, f'分类数量已达上限({maximum})') + values.append(name) + source.clear(); source.update(completed) + return copy.deepcopy(values) + return _mutate(store, 'prompt_categories.json', update, {}) diff --git a/proxy_access.py b/proxy_access.py new file mode 100644 index 0000000000000000000000000000000000000000..462a192d7dd1e9b792e7011819700d7c408efc50 --- /dev/null +++ b/proxy_access.py @@ -0,0 +1,62 @@ +"""下载代理共用资源/有效价格/当前所有权核查,不把存储故障视为免费。""" +import asyncio +from models_sql import Ownership +from purchase_transaction import effective_resource_price + + +class ProxyAccessError(Exception): + def __init__(self, status_code, message): + self.status_code = status_code + self.message = message + super().__init__(message) + + +def read_proxy_item(store, item_id): + try: + source = store.load_data('items.json', default_data=[], strict=True, skip_cache=True) + if not isinstance(source, list): + raise ValueError('invalid resource source') + if any(not isinstance(item, dict) or not isinstance(item.get('id'), str) or not item['id'] for item in source): + raise ValueError('invalid resource record') + matches = [item for item in source if item.get('id') == item_id] + if not matches: + raise ProxyAccessError(404, '资源不存在') + if len(matches) != 1: + raise ValueError('ambiguous resource') + item = matches[0] + author = item.get('author') + if not isinstance(author, str) or not author.strip(): + raise ValueError('invalid resource author') + effective_resource_price(item) + return item + except ProxyAccessError: + raise + except Exception: + raise ProxyAccessError(503, '资源信息暂时无法核实,请稍后核对') from None + + +async def authorized_proxy_item(store, session, item_id, account): + try: + item = await asyncio.to_thread(read_proxy_item, store, item_id) + author = item['author'] + price = effective_resource_price(item) + if price > 0 and account != author: + # SQL会话仍留在调用线程;不用陈旧的身份/拥有缓存,也不授予已退款记录。 + ownerships = session.query(Ownership).filter(Ownership.account == account, Ownership.item_id == item_id).populate_existing().all() + if any(type(row.is_refunded) is not bool for row in ownerships): + raise ValueError('invalid ownership refund state') + rows = [row for row in ownerships if not row.is_refunded] + if len(rows) > 1: + raise ValueError('ambiguous ownership') + if not rows: + raise ProxyAccessError(403, '尚未获取该资源') + return item + except ProxyAccessError: + raise + except Exception: + raise ProxyAccessError(503, '下载授权暂时无法核实,请稍后核对') from None + finally: + try: + session.rollback() # 不让下载流长期持有所有权读取事务。 + except Exception: + raise ProxyAccessError(503, '下载授权暂时无法核实,请稍后核对') from None diff --git a/proxy_resources.py b/proxy_resources.py new file mode 100644 index 0000000000000000000000000000000000000000..55ef3ce36489c5bbac28edf6cea0e4306a15290d --- /dev/null +++ b/proxy_resources.py @@ -0,0 +1,63 @@ +"""代理请求的所属响应/客户端收尾:每对象一次,取消等待同一任务。""" +import asyncio +import logging + +logger = logging.getLogger("ComfyUI-Ranking") + + +async def _await_close(task): + cancelled = None + while not task.done(): + try: + await asyncio.shield(task) + except asyncio.CancelledError as error: + cancelled = error + task.result() + if cancelled is not None: + raise cancelled + + +class ProxyResources: + def __init__(self, client): + self.client = client + self._responses = [] + self._response_tasks = {} + self._close_task = None + + def track(self, response): + if self._close_task is not None: + raise RuntimeError("proxy resources already closing") + if not any(previous is response for previous in self._responses): + self._responses.append(response) + return response + + async def close_response(self, response): + key = id(response) + if key not in self._response_tasks: + async def finish(): + try: + await response.aclose() + except Exception as error: + logger.warning("PROXY_RESPONSE_CLOSE_FAIL | error_type=%s", type(error).__name__) + self._response_tasks[key] = asyncio.create_task(finish()) + await _await_close(self._response_tasks[key]) + + async def close(self): + if self._close_task is None: + async def finish(): + cancelled = None + try: + for response in self._responses: + try: + await self.close_response(response) + except asyncio.CancelledError as error: + cancelled = error + finally: + try: + await self.client.aclose() + except Exception as error: + logger.warning("PROXY_CLIENT_CLOSE_FAIL | error_type=%s", type(error).__name__) + if cancelled is not None: + raise cancelled + self._close_task = asyncio.create_task(finish()) + await _await_close(self._close_task) diff --git a/public_list_etag.py b/public_list_etag.py new file mode 100644 index 0000000000000000000000000000000000000000..c5a9cac441d21dcc21addf1acf2bbae682e8264b --- /dev/null +++ b/public_list_etag.py @@ -0,0 +1,71 @@ +"""Bounded conditional replies for anonymous public list JSON responses.""" +import hashlib + +PUBLIC_LIST_PATHS = frozenset({'/api/items', '/api/creators', '/api/creators/search', + '/api/posts', '/api/prompts', '/api/tasks'}) +MAX_JSON_BYTES = 1024 * 1024 +MAX_VALIDATOR_BYTES = 4096 + + +def _matches(value, etag): + if len(value) > MAX_VALIDATOR_BYTES: + return False + for candidate in value.split(','): + candidate = candidate.strip() + if candidate == '*' or candidate.removeprefix('W/') == etag.removeprefix('W/'): + return True + return False + + +class PublicListETag: + """Inspect a single bounded JSON body; streams and private responses pass through.""" + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + headers = {key.lower(): value for key, value in scope.get('headers', [])} + eligible = (scope.get('type') == 'http' and scope.get('method') == 'GET' + and scope.get('path') in PUBLIC_LIST_PATHS + and b'authorization' not in headers and b'cookie' not in headers) + if not eligible: + return await self.app(scope, receive, send) + validator = headers.get(b'if-none-match', b'').decode('latin-1') + start = None + bypass = False + + async def conditional_send(message): + nonlocal start, bypass + if bypass: + return await send(message) + if message['type'] == 'http.response.start': + response_headers = {key.lower(): value for key, value in message.get('headers', [])} + media = response_headers.get(b'content-type', b'').split(b';', 1)[0].strip().lower() + if (message['status'] != 200 or media != b'application/json' + or b'content-encoding' in response_headers or b'etag' in response_headers + or b'set-cookie' in response_headers + or b'no-store' in response_headers.get(b'cache-control', b'').lower()): + bypass = True + return await send(message) + start = message + return + if message['type'] != 'http.response.body' or start is None: + return await send(message) + body = message.get('body', b'') + if message.get('more_body', False) or len(body) > MAX_JSON_BYTES: + bypass = True + await send(start) + return await send(message) + etag = 'W/"rk-' + hashlib.sha256(body).hexdigest() + '"' + updated = [(key, value) for key, value in start.get('headers', []) + if key.lower() not in {b'cache-control', b'etag'}] + updated.extend([(b'etag', etag.encode('ascii')), (b'cache-control', b'private, no-cache')]) + if _matches(validator, etag): + updated = [(key, value) for key, value in updated if key.lower() != b'content-length'] + await send({**start, 'status': 304, 'headers': updated}) + await send({'type': 'http.response.body', 'body': b''}) + else: + await send({**start, 'headers': updated}) + await send(message) + bypass = True + + await self.app(scope, receive, conditional_send) diff --git a/publication_identity.py b/publication_identity.py new file mode 100644 index 0000000000000000000000000000000000000000..23fa5286bfe42986578c1597e0f9da49c27f60f4 --- /dev/null +++ b/publication_identity.py @@ -0,0 +1,22 @@ +"""可选发布请求身份;只提供现存内容记录范围内的重复创建确认。""" +import hashlib +import json +import re + + +def publication_identity(kind, account, key): + if key is None: + return None + if kind not in {'post', 'prompt'} or not isinstance(account, str) or not account: + raise ValueError('invalid publication owner') + if not isinstance(key, str) or re.fullmatch(r'[0-9a-f]{32}', key) is None: + raise ValueError('invalid publication key') + data = json.dumps([kind, account, key], ensure_ascii=False, separators=(',', ':')).encode('utf-8') + return f'{kind}_publish_{hashlib.sha256(data).hexdigest()}' + + +def same_publication(existing, incoming, fields): + def content(record): + return json.dumps({field: record.get(field) for field in fields}, ensure_ascii=False, + sort_keys=True, separators=(',', ':'), allow_nan=False) + return content(existing) == content(incoming) diff --git a/purchase_transaction.py b/purchase_transaction.py new file mode 100644 index 0000000000000000000000000000000000000000..75c4d207573ac224175b816e54b764f536dd1bf4 --- /dev/null +++ b/purchase_transaction.py @@ -0,0 +1,25 @@ +"""资源购买的有效价格与写事务准备;调用方负责提交/回滚。""" +import datetime +from tip_transaction import lock_wallet_accounts + + +def effective_resource_price(item): + price = item.get("price", 0) + pending = item.get("pending_price") + effective = item.get("pending_price_effective_at") + if pending is not None and effective: + when = datetime.datetime.fromisoformat(effective) + now = datetime.datetime.now(when.tzinfo) if when.tzinfo else datetime.datetime.now() + if now >= when: + price = pending + if isinstance(price, bool) or not isinstance(price, int) or price < 0: + raise ValueError("invalid resource price") + return price + + +def lock_resource_purchase_wallets(session, buyer, seller, free): + # 免费/作者本人仍需买方钱包行作为跨进程所有权查重的锁锚点。 + accounts = (buyer,) if free else (buyer, seller) + create = (buyer,) if free else (seller,) + wallets = lock_wallet_accounts(session, accounts, create) + return wallets[buyer], wallets.get(seller) diff --git a/rate_limit.py b/rate_limit.py new file mode 100644 index 0000000000000000000000000000000000000000..b85f6d9de91ce1f692a49fc37792669dab80c1aa --- /dev/null +++ b/rate_limit.py @@ -0,0 +1,290 @@ +"""跨 worker API 限流:已验证用户按账号计数,匿名请求按客户端 IP 计数。""" + +from datetime import datetime +from functools import wraps +import hashlib +import hmac +import logging +import threading +import time + +from fastapi import Request +from fastapi import HTTPException +from starlette.concurrency import run_in_threadpool +from sqlalchemy import case, delete +from sqlalchemy.dialects.mysql import insert as mysql_insert +from sqlalchemy.dialects.postgresql import insert as postgresql_insert +from sqlalchemy.dialects.sqlite import insert as sqlite_insert + +from 安全认证 import get_account_from_token +from database_sql import SessionLocal, engine +from models_sql import ApiRateLimitBucket, VerificationCode, VerificationContactCooldown +from 安全认证 import JWT_SECRET + +logger = logging.getLogger("ComfyUI-Ranking.RateLimit") +_cleanup_lock = threading.Lock() +_last_cleanup_at = 0.0 + + +def get_remote_address(request: Request) -> str: + """仅信任 ASGI transport 提供的客户端地址,不直接信任可伪造的代理头。""" + client = getattr(request, "client", None) + return getattr(client, "host", None) or "unknown" + + +def get_rate_limit_key(request: Request) -> str: + """返回稳定的限流身份键;只读取并验证 Bearer Token,不记录或返回 Token。""" + authorization = request.headers.get("Authorization", "") + scheme, _, token = authorization.partition(" ") + if scheme.lower() == "bearer" and token.strip(): + account = get_account_from_token(token.strip()) + if account: + return f"account:{account.casefold()}" + return f"ip:{get_remote_address(request)}" + + +def _consume_shared_limit(identity: str, scope: str, limit: int) -> None: + """使用数据库原子 UPSERT 计数;数据库不可用时 fail closed。""" + now = int(time.time()) + window_start = now - (now % 60) + bucket_key = hashlib.sha256(f"{identity}\0{scope}".encode("utf-8")).hexdigest() + session = SessionLocal() + try: + dialect = engine.dialect.name + values = { + "bucket_key": bucket_key, + "window_start": window_start, + "request_count": 1, + "updated_at": datetime.utcnow(), + } + if dialect == "sqlite": + statement = sqlite_insert(ApiRateLimitBucket).values(**values) + statement = statement.on_conflict_do_update( + index_elements=[ApiRateLimitBucket.bucket_key], + set_={ + "window_start": window_start, + "request_count": case( + (ApiRateLimitBucket.window_start == window_start, + case((ApiRateLimitBucket.request_count < limit + 1, + ApiRateLimitBucket.request_count + 1), else_=limit + 1)), + else_=1, + ), + "updated_at": datetime.utcnow(), + }, + ) + elif dialect == "postgresql": + statement = postgresql_insert(ApiRateLimitBucket).values(**values) + statement = statement.on_conflict_do_update( + index_elements=[ApiRateLimitBucket.bucket_key], + set_={ + "window_start": window_start, + "request_count": case( + (ApiRateLimitBucket.window_start == window_start, + case((ApiRateLimitBucket.request_count < limit + 1, + ApiRateLimitBucket.request_count + 1), else_=limit + 1)), + else_=1, + ), + "updated_at": datetime.utcnow(), + }, + ) + elif dialect == "mysql": + statement = mysql_insert(ApiRateLimitBucket).values(**values) + statement = statement.on_duplicate_key_update( + [ + # MySQL evaluates ON DUPLICATE KEY UPDATE assignments left-to-right. + # Read the old window before replacing it, or a new minute would + # incorrectly continue the prior minute's count. + ("request_count", case( + (ApiRateLimitBucket.window_start == window_start, + case((ApiRateLimitBucket.request_count < limit + 1, + ApiRateLimitBucket.request_count + 1), else_=limit + 1)), + else_=1, + )), + ("window_start", window_start), + ("updated_at", datetime.utcnow()), + ] + ) + else: + raise RuntimeError(f"Unsupported SQL dialect for shared rate limiting: {dialect}") + + session.execute(statement) + bucket = session.get(ApiRateLimitBucket, bucket_key) + if not bucket: + raise RuntimeError("Rate limit bucket upsert did not produce a row") + request_count = bucket.request_count + session.commit() + if request_count > limit: + raise HTTPException(status_code=429, detail=f"请求过于频繁,限流 {limit}/分钟,请稍后再试") + _cleanup_old_buckets_if_due() + except HTTPException: + session.rollback() + raise + except Exception as exc: + session.rollback() + logger.error("SHARED_RATE_LIMIT_DB_FAIL | scope=%s | error_type=%s", scope, type(exc).__name__) + raise HTTPException(status_code=503, detail="限流服务暂不可用") from exc + finally: + session.close() + + +def _cleanup_old_buckets_if_due() -> None: + global _last_cleanup_at + now = time.time() + if now - _last_cleanup_at < 900: + return + if not _cleanup_lock.acquire(blocking=False): + return + try: + if now - _last_cleanup_at < 900: + return + session = SessionLocal() + try: + session.execute(delete(ApiRateLimitBucket).where(ApiRateLimitBucket.window_start < int(now) - 86_400)) + session.execute(delete(VerificationContactCooldown).where( + VerificationContactCooldown.last_sent_at < int(now) - 86_400 + )) + session.execute(delete(VerificationCode).where(VerificationCode.expires_at < int(now))) + session.commit() + _last_cleanup_at = now + except Exception: + session.rollback() + logger.warning("SHARED_RATE_LIMIT_CLEANUP_FAIL") + finally: + session.close() + finally: + _cleanup_lock.release() + + +def shared_limit(limit: int): + """FastAPI endpoint decorator backed by the shared SQL minute-window table.""" + if not isinstance(limit, int) or limit < 1: + raise ValueError("limit must be a positive integer") + + def decorate(endpoint): + scope = f"{endpoint.__module__}.{endpoint.__name__}" + + @wraps(endpoint) + async def wrapped(*args, **kwargs): + request = kwargs.get("request") + if request is None: + request = next((arg for arg in args if isinstance(arg, Request)), None) + if request is None: + raise RuntimeError(f"{scope} requires a FastAPI Request parameter") + identity = await run_in_threadpool(get_rate_limit_key, request) + await run_in_threadpool(_consume_shared_limit, identity, scope, limit) + return await endpoint(*args, **kwargs) + + return wrapped + + return decorate + + +def reserve_contact_cooldown(contact: str, cooldown_seconds: int) -> int: + """原子预留联系方式冷却;返回 0 表示成功,否则返回剩余秒数。""" + normalized = str(contact or "").strip().casefold() + contact_key = hashlib.sha256(normalized.encode("utf-8")).hexdigest() + now = int(time.time()) + cutoff = now - cooldown_seconds + claim_id = hashlib.sha256(f"{contact_key}:{now}:{time.time_ns()}".encode()).hexdigest()[:32] + session = SessionLocal() + try: + values = {"contact_key": contact_key, "last_sent_at": now, "claim_id": claim_id} + dialect = engine.dialect.name + if dialect == "sqlite": + statement = sqlite_insert(VerificationContactCooldown).values(**values) + statement = statement.on_conflict_do_update( + index_elements=[VerificationContactCooldown.contact_key], + set_={ + "last_sent_at": case((VerificationContactCooldown.last_sent_at <= cutoff, now), + else_=VerificationContactCooldown.last_sent_at), + "claim_id": case((VerificationContactCooldown.last_sent_at <= cutoff, claim_id), + else_=VerificationContactCooldown.claim_id), + }, + ) + elif dialect == "postgresql": + statement = postgresql_insert(VerificationContactCooldown).values(**values) + statement = statement.on_conflict_do_update( + index_elements=[VerificationContactCooldown.contact_key], + set_={ + "last_sent_at": case((VerificationContactCooldown.last_sent_at <= cutoff, now), + else_=VerificationContactCooldown.last_sent_at), + "claim_id": case((VerificationContactCooldown.last_sent_at <= cutoff, claim_id), + else_=VerificationContactCooldown.claim_id), + }, + ) + elif dialect == "mysql": + statement = mysql_insert(VerificationContactCooldown).values(**values) + statement = statement.on_duplicate_key_update( + [ + # Evaluate eligibility against the old timestamp before advancing it. + ("claim_id", case((VerificationContactCooldown.last_sent_at <= cutoff, claim_id), + else_=VerificationContactCooldown.claim_id)), + ("last_sent_at", case((VerificationContactCooldown.last_sent_at <= cutoff, now), + else_=VerificationContactCooldown.last_sent_at)), + ] + ) + else: + raise RuntimeError(f"Unsupported SQL dialect for contact cooldown: {dialect}") + + session.execute(statement) + record = session.get(VerificationContactCooldown, contact_key) + if not record: + raise RuntimeError("Contact cooldown upsert did not produce a row") + allowed = record.claim_id == claim_id + last_sent_at = record.last_sent_at + session.commit() + return 0 if allowed else max(1, cooldown_seconds - (now - last_sent_at)) + except Exception as exc: + session.rollback() + logger.error("CONTACT_COOLDOWN_DB_FAIL | error_type=%s", type(exc).__name__) + raise HTTPException(status_code=503, detail="验证码发送频控暂不可用") from exc + finally: + session.close() + + +def _verification_key(key: str) -> str: + return hashlib.sha256(str(key).strip().casefold().encode("utf-8")).hexdigest() + + +def _verification_digest(key: str, code: str) -> str: + return hmac.new(JWT_SECRET.encode("utf-8"), f"{_verification_key(key)}\0{code}".encode("utf-8"), hashlib.sha256).hexdigest() + + +def store_verification_code(key: str, code: str, expires_at: int) -> None: + """存储跨 worker 可用的一次性验证码,不保存验证码或联系方式明文。""" + session = SessionLocal() + try: + session.merge(VerificationCode(contact_key=_verification_key(key), + code_digest=_verification_digest(key, code), + expires_at=int(expires_at))) + session.commit() + except Exception as exc: + session.rollback() + logger.error("VERIFICATION_CODE_STORE_FAIL | error_type=%s", type(exc).__name__) + raise HTTPException(status_code=503, detail="验证码服务暂不可用") from exc + finally: + session.close() + + +def consume_verification_code(key: str, code: str, session=None) -> bool: + """正确且未过期时原子删除验证码;可传入业务事务 session 一并提交。""" + owns_session = session is None + session = session or SessionLocal() + try: + result = session.execute(delete(VerificationCode).where( + VerificationCode.contact_key == _verification_key(key), + VerificationCode.code_digest == _verification_digest(key, code), + VerificationCode.expires_at >= int(time.time()), + )) + consumed = result.rowcount == 1 + if owns_session: + session.commit() + return consumed + except Exception as exc: + if owns_session: + session.rollback() + logger.error("VERIFICATION_CODE_CONSUME_FAIL | error_type=%s", type(exc).__name__) + raise HTTPException(status_code=503, detail="验证码服务暂不可用") from exc + finally: + if owns_session: + session.close() diff --git a/recharge_transaction.py b/recharge_transaction.py new file mode 100644 index 0000000000000000000000000000000000000000..42acbce842640e311dcb599e5c79ea18fc8f7cea --- /dev/null +++ b/recharge_transaction.py @@ -0,0 +1,90 @@ +"""充值只信任持久订单;回调与主动查单共用同一到账事务。""" +import hashlib +import re +from decimal import Decimal +from sqlalchemy.orm import Session +from models_sql import RechargeOrder, Transaction +from tip_transaction import lock_wallet_accounts + + +def payment_amount(value): + if not isinstance(value, str) or not re.fullmatch(r"[0-9]{1,5}(?:\.[0-9]{1,2})?", value): + raise ValueError("invalid payment amount") + amount = Decimal(value) + if not 1 <= amount <= 10000 or amount != amount.to_integral_value(): + raise ValueError("invalid payment amount") + return int(amount) + + +def validate_order(row): + if (row is None or not isinstance(row.account, str) or not row.account.strip() + or type(row.amount) is not int or not 1 <= row.amount <= 10000 + or row.status not in {"pending", "paid"}): + raise ValueError("invalid recharge order") + + +def validate_ledger(tx, account, amount): + if tx is not None and (tx.account != account or tx.tx_type != "RECHARGE" + or type(tx.amount) is not int or tx.amount != amount): + raise ValueError("invalid recharge ledger") + + +def read_order(db, order_id): + if not isinstance(order_id, str) or not re.fullmatch(r"PAY_[0-9a-f]{32}", order_id): + return None + # 网络查单前的读取不在业务 Session 留下事务或钱包锁。 + with Session(bind=db.get_bind(), autoflush=False) as lookup: + row = lookup.get(RechargeOrder, order_id) + if row is None: + return None + validate_order(row) + tx = lookup.get(Transaction, order_id) + validate_ledger(tx, row.account, row.amount) + if row.status == "paid" and tx is None: + raise ValueError("paid recharge ledger missing") + return {"account": row.account, "amount": row.amount, "credited": tx is not None} + + +def persist_order(db, order_id, account, amount): + if type(amount) is not int or not 1 <= amount <= 10000 or not isinstance(account, str) or not account.strip(): + raise ValueError("invalid recharge request") + try: + db.add(RechargeOrder(order_id=order_id, account=account, amount=amount, status="pending")) + db.commit() + except Exception: + db.rollback() + raise + + +def credit_order(db, order_id, amount): + info = read_order(db, order_id) + if info is None or type(amount) is not int or amount != info["amount"]: + raise ValueError("payment order mismatch") + account = info["account"] + try: + wallet = lock_wallet_accounts(db, (account,), (account,))[account] + row = db.query(RechargeOrder).filter(RechargeOrder.order_id == order_id).populate_existing().with_for_update().first() + validate_order(row) + if row.account != account or row.amount != amount: + raise ValueError("payment order changed") + tx = db.query(Transaction).filter(Transaction.tx_id == order_id).populate_existing().with_for_update().first() + validate_ledger(tx, account, amount) + if tx is not None: + row.status = "paid" + db.commit() + return False + if row.status != "pending" or type(wallet.balance) is not int: + raise ValueError("invalid recharge state") + last = db.query(Transaction).filter(Transaction.account == account).order_by( + Transaction.created_at.desc(), Transaction.tx_id.desc()).with_for_update().first() + previous = last.tx_hash if last else "GENESIS_HASH" + digest = hashlib.sha256(f"{order_id}{account}RECHARGE{amount}{previous}".encode()).hexdigest() + wallet.balance += amount + row.status = "paid" + db.add(Transaction(tx_id=order_id, account=account, tx_type="RECHARGE", amount=amount, + prev_hash=previous, tx_hash=digest, description="支付宝充值")) + db.commit() + return True + except Exception: + db.rollback() + raise diff --git a/requirements-build.lock b/requirements-build.lock new file mode 100644 index 0000000000000000000000000000000000000000..92aeadad4c3ff9cab0f9aa7738e8a6381f4f1a00 --- /dev/null +++ b/requirements-build.lock @@ -0,0 +1,13 @@ +# Source SDK build tools, pinned separately from runtime dependencies. + +packaging==26.3 \ + --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c + +setuptools==84.0.0 \ + --hash=sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670 \ + --hash=sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73 + +wheel==0.48.0 \ + --hash=sha256:3217dcc807155e45db462d7ef2431f5ddda0d7273b700d05a67b271ceb1287ab \ + --hash=sha256:94800765601e9171bf5d58d066e640662842bcedcbab982b2c90787a2c987322 diff --git a/requirements.lock b/requirements.lock new file mode 100644 index 0000000000000000000000000000000000000000..eb8c5351693bd29c09442b83a7480167d4a8d8fb --- /dev/null +++ b/requirements.lock @@ -0,0 +1,1696 @@ +# Complete cloud runtime versions and official PyPI artifact SHA256. +# CPython 3.11 runtime; source SDKs build with requirements-build.lock. + +aiohappyeyeballs==2.7.1 \ + --hash=sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d \ + --hash=sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472 + +aiohttp==3.14.4 \ + --hash=sha256:019587c7b3a44917dd61bcb312fd5c034f341dc9851b83e7b41b65e08b68ca0f \ + --hash=sha256:02a508ba5ad91c4d730523584c0adc65882274bba5e37c33bc2f475e37577256 \ + --hash=sha256:09092cf906c18c824b6b16881da5a89cc9cfa9b15344e496bcf880e9045bee33 \ + --hash=sha256:0b4cec9fca876e2d4f4c6cb32b177390132d04618867689f43fefabf3f5c3980 \ + --hash=sha256:0cb3edece6fe6a944eaf97d803b88f606da6bc1600b1894c2969e78ffdc1fc29 \ + --hash=sha256:0d0934926fc65744e2fdd44ce68b2d79d5ee608a1e23f0596b35dd696519bb7c \ + --hash=sha256:0d4415ca5c9c8b24bc2f5733f870d4eab4929e680203559ba60131dd1a27852a \ + --hash=sha256:0da4525e9ba145a11617d2cd7e44fe1acaf471f9c72a0cad28e72879c1020fbb \ + --hash=sha256:0daa016eb74e7888ed5f9ceec6eea3158999c1c4a115ef5cbe88baa8d924f94c \ + --hash=sha256:1410b6f6e3d52ea7c02c950496522ce6e85d5543bf154bc80a6141e505251cfa \ + --hash=sha256:1494877625baeffab669f4a25a09ec4eea721b49e35363081a2a8e4231fe141f \ + --hash=sha256:15a3bb5f90a4e515071f3617a45dfe26560beb834a6b6e108cdf4bc9e9719ec3 \ + --hash=sha256:17e5d7c7775d8dd8e894c7ef6ebfa26509de36868a9a6c7c4fcfaf6a1bf43d60 \ + --hash=sha256:185154ffa3b54765b5de96f464f41cfa476f8815a03854ae5e0fe4d48fb06e9c \ + --hash=sha256:18a9fb9a3f6e6e63f4d27f9adeef313a2d3c69a84d31170a844623824e722534 \ + --hash=sha256:19290a108de0718b73bc69b86e788975c6a59855c671ebe2a98a07a6f968328f \ + --hash=sha256:1c43b6af8e4708e8b3812a9ee4f79895207e12d555b7f6d70deb838e0011e12d \ + --hash=sha256:1d385db2cd154bce0b3dd451cf2a52bec91ebb7be2c3a5d06b14b4391631d807 \ + --hash=sha256:20134dc8e68c67e7478124d516449428684c0545b2d3faa575691c8bcbd56678 \ + --hash=sha256:217707313e23aedde9bea67cf8365c7b138b549ae179121ef3f476cf67e3173a \ + --hash=sha256:21ce1223b461bcc5fb389a7204001578f1c0b85c2da60a84d915ebb021294245 \ + --hash=sha256:21f4624fb1051fc1aa10d57a636a80e829f7d0417462d6f1ce380b08fe1a904c \ + --hash=sha256:220912b351549dc736c59d104dc443fa615753102bb778a4e98f86e4d94bff6a \ + --hash=sha256:2617132524f66338f6c1d218cd16081576aefc40df4f2bcba338a99fbf67fec1 \ + --hash=sha256:264b5a7568306590c44ae4a0237ad1715e35d447ea44ebf324e4c8fd9f78f67a \ + --hash=sha256:27b370fb532e707e8c96d04fa0c96301fb7dba2d50aaca57f6c456cdfffec5bd \ + --hash=sha256:2a950bd8418ef54eccf5f9f517f08e37c1adab4bc50a470a0422ee1cc1032e29 \ + --hash=sha256:2b2c95f5f769eec92b552969db1f79ea0282058ce5131ed0f2fa540b4af3f14e \ + --hash=sha256:2c9585101cfca10e74a07cc0ef2bdc6d90616e6750926b20456f429d0608f740 \ + --hash=sha256:2d0d8e435582b0d53009dac99e26a3d2be932ed7c9dbfcfc70f3fce1cf943bd5 \ + --hash=sha256:2e8dc3a0fdaf9d12b954d3d06cc4c355d91b17fb6c671715b593624ca4c379f8 \ + --hash=sha256:2efa42b39bb3f524d5eca3760639b584afdd1143a6b047fda29910ffb75e4993 \ + --hash=sha256:2efbdb87e79d596325c4eefaef0f4495d701145e882351310ebc538e854dc7a3 \ + --hash=sha256:2f1a79977794592dcf6485ed964ec2448db527a1bc0c72ec89663658dad3a739 \ + --hash=sha256:3099a389e5b5900fc79c6f613015ff3f8fa06a519744e6001cc68a31ba418cac \ + --hash=sha256:322e3d741b3133bcbb981a41d4f9b8ac3545c18de49c662c2b1f4e1c770b98ad \ + --hash=sha256:34cf6f2fe4fc1d7183e8cc0d8c48bbdda828a740bc372d8278126149714d343c \ + --hash=sha256:35d5432f0dc1c8b71307e9112461b52f336da225e344283eec4779222c0a1dff \ + --hash=sha256:3647f8c090632c70abb1e80826e4ee24faea3f895f0283b60e91a0b17aea5e34 \ + --hash=sha256:3749e018123df1205161a73ba96d336e17e9cf6b947295e53a43571de420e7b1 \ + --hash=sha256:3b10799df72f66c30935bc661cb89e5ac2918debfc575e350e226fbaa846cbb7 \ + --hash=sha256:3b6945559a260112742ed58cde2165488d2fc51aeea34afbaa99d9371b43e1b5 \ + --hash=sha256:3c4dcf611b095e7983421f49d01ffbed7ed574891533d606ea115fc23e7c4e90 \ + --hash=sha256:3ec57223e261b7fa9979c12dbce009ca53439be76cac3af27f860d3e4b1d6f7a \ + --hash=sha256:3f3e8030169843fb652a60a3d32c206e03f260198aa398985a48b41cd7e42f0a \ + --hash=sha256:4135c4bb4b7682a7633fadc7170ea969d843f83aa7224e70111a20675105c985 \ + --hash=sha256:4ecc84f69c9e217984f6877e832e4786a74567432c0bf2b7512919fbdfe18ade \ + --hash=sha256:50969522bc7022026a2c7968e67da5c789f6b716b988edac55dc630390efd5df \ + --hash=sha256:515e7ac6d27509dc3ba2c35fb88970b00b9e7b07ee7cc947c8733dcd54ef5def \ + --hash=sha256:54209fff79346ee1ef0d5cbf92fa80a5bb37396406bb4d932e5107f9c29b2d3f \ + --hash=sha256:54f9256430ca040d58affbd551513de13a5cff7d065204fc52b9a1b0af09a049 \ + --hash=sha256:55b0b335982b0db7117dff7f6a8e3aad43c4105f9e392ca04f9fc8d958a95cce \ + --hash=sha256:5b681054b8c3b86aa6a0384b6ad7f5cb78dccbabab1f4a3a6793f98ad1378959 \ + --hash=sha256:5be8af106f96fd625f6c264fa9faaaa9e0104089684d9015d2fa5b6eba6407c7 \ + --hash=sha256:5c6758ba62aea282c537179cfc8474a90f2b5f7b089cc5ff66d8920d86a9bfdd \ + --hash=sha256:5d5d9843c34866e986dd24eef713452609e304550c471320ce7b54b3cfb1391e \ + --hash=sha256:60dc71d38db0988f37e78f6ad4a010a2f6355cfac3fff5f130cf5ef2231f3f0d \ + --hash=sha256:6112d5931bf12c776188cab81b82bbb17d86e2e1381f2ade6edc43e6b240f0cb \ + --hash=sha256:674f53ab9d2794e2dd767619a3384bf1006f8d075e43c19d449a645f8ff641c2 \ + --hash=sha256:681dde68ff8d8d5e7d5abcad4feb45bfb457a218310c919825e21fc36cac3d0b \ + --hash=sha256:68ed4824d96b7afec1f3a8d7fba190fe282718041c5772e20298003e1e3e5d66 \ + --hash=sha256:69ce2407951516f413adf3132b88451eababd710ce631f83c81a9603a3640253 \ + --hash=sha256:69dcb02d33dfe415d5ee342cc63b7d320efcaf4b761701c7bd7bfb91c715481a \ + --hash=sha256:6aece59f77959628a22cd2ffe48b102cda9611d680e090f204385b3998a1190f \ + --hash=sha256:6c044e52df1a466af82cad3518c92ea80cc4ddcfea86966ff083bb34bab29bf4 \ + --hash=sha256:6d243221f73e5ac546c2ed9255100423e4bc092b08b944f23498e8cf3096c26c \ + --hash=sha256:6dfe80b41bcf8d80d2125fb99a171adeb86db2fcb35c1a68e0226acba0ba7f03 \ + --hash=sha256:6e2eb0f8b03b4f2bd154ed8cf7c1a119a1ad04808c258bf58f90138c83b8c6d5 \ + --hash=sha256:6ff9d3a0e6935f1cb0a6685f9f4b3f0d3e406456d7a434a03cf309b8cd5d7a2e \ + --hash=sha256:70247156fc76e92919f9aacb820556cb30289a54784299a31714d030bb604d84 \ + --hash=sha256:70492983b7aed9e61bda82fe1ce80bcba9199610886443133783a288638923c0 \ + --hash=sha256:70c031a622aa20add05ebd234b90adaf6bf51c1c88cc305ec760721af83308c3 \ + --hash=sha256:71e4a59c6a8c5a6ae8b0b696c32635913e8f526cc01448762eaf5a3c59df8a4f \ + --hash=sha256:7242043e71fc449a19a47d92f4e9fd1e0ae8d3f88488982381f422bf6d651218 \ + --hash=sha256:73799000dd6fd5247094fd8e6d3e1eb871e41aab5cc35dff9d9f654cd230bc24 \ + --hash=sha256:749bfc43bb1df71bbe6ed215044d5412d786698fc5771d2037436ca49622f3c9 \ + --hash=sha256:7968634fa3a967a2bc0b9aeba1d5a1955501dcd7d0de44b96f7ebfb662797735 \ + --hash=sha256:7abae043e87692d1c2adda1fab501bb0612d28c4a23eb082005c1e684f792dac \ + --hash=sha256:7df363fa7c38945a896e12c69af4a1a56cbd4152791d9e41fd0bac396b72de38 \ + --hash=sha256:7e7356059c8262d2c1fd95242e2b7b70fbc6eee3135bf470049cbec3e968b5b0 \ + --hash=sha256:7fbe827d27e0e369fd7bb88ceeaa5b46ea006e73e015b9dd77e3723a53162293 \ + --hash=sha256:7ffd7f9dd39caccc6b2f68ec0339435ecad2b20f6ea2e2b3de91bbe464ac7137 \ + --hash=sha256:831fc5bd39ec2517851e348f613ddb5447a47cf4b71cb09845af7ad7ed45d8f9 \ + --hash=sha256:840775cb39a9424f9edf142133a1192263ac5bd79117ad2a16602cef6a0c1e9d \ + --hash=sha256:85fac7c99e0ac3dcbd6ae1c33774e0603bfc732ddb967a6fce4a731103c1888e \ + --hash=sha256:871035fe79c636b6bc126ed78df37d72b830dfc751612cb4b1040360edf2719f \ + --hash=sha256:878f2d7950238b2b0a8cbb88054444a125f0cfbc77f54c811aca0483132a9be3 \ + --hash=sha256:884e0d1c707b6217fee3080b9b4029eee7551af788ee761010df19be158414b7 \ + --hash=sha256:88cf889e51092537fe44adfbff96c2a22d6479d6f92b97b3d413fbfd0a4decf4 \ + --hash=sha256:8df6157d31703972aec9e3b93963f039ff06318301e30b007c3e941ff8a7ac42 \ + --hash=sha256:8f28a3c4fca436b2e594090e7cd22558f5e93a30ca18516c436de982da6fc25a \ + --hash=sha256:8f4078a90b174f15efb2c8725e8720c40dbd3b2cf6350dbc085c964a938d9de7 \ + --hash=sha256:9055324f156a94fdf6dc9a825ae60f9a2947b88f591ea81ee80826633de62ae7 \ + --hash=sha256:921fc4f1ad549091bfc39cb5e93925b04f307f83517756a3111843851130efce \ + --hash=sha256:9413cffe4e0d654b9b524f9c99dde8cbdb37e0ed9a2c4edd2e63adbac6b374e8 \ + --hash=sha256:97416b2b997e5e9b817e322b2f0a89b76c54cde22471f51ac17ac15e10901ba0 \ + --hash=sha256:97e65916588be0f952b3307aaa8460c0771fa98dbea2dd27dabc9f5d84d53c3b \ + --hash=sha256:9b416f995b68a921069e6ec61fa07f2ac3ff033d92e97a69f673af89f5bb954d \ + --hash=sha256:a0a7a227353f7693f73878ad14cd2623cafadd656ababcd20e2adfb7225172ea \ + --hash=sha256:a1205cbdddfca1428129bb6d23e0b292b09e681df951fa828145d83a63ffc0ca \ + --hash=sha256:a65189a89f3e621f7c27a968b5131bbca29126e2b8517dc72b205747a39d4c96 \ + --hash=sha256:a70387146d03af9f047c629b71aa27d9594b3b31ec4c9c91e1a73d0d8bf38e0e \ + --hash=sha256:a77776673331577147e9d25cd7833a72e8891b47556336cff06ba1569e488b99 \ + --hash=sha256:a779ce4bf099ce4ad2cdfed62f1ca536d435f53237c54ff40172fddada24d1b5 \ + --hash=sha256:aa800bb7c1d00e166493931f2ddacb982db913292503dc2f4884db1f2bc5f105 \ + --hash=sha256:ac6e6f90d9360e460c873f945f11dc8698db0a71fec6612823f1aaaab2c11faa \ + --hash=sha256:ae8a244889d8a53549851bf595b9f382f64acca57e8762da1ecb26ba786828f7 \ + --hash=sha256:b0a1274ee4ee6203c15a341cce87f9f3df8151d5496047b3608bb62bd969cffc \ + --hash=sha256:b84d3d057c60eb8e0c2b457a1203e868d82709d3f0c272a4975dfd88c2414939 \ + --hash=sha256:b91a6441fcaed88ca7bca67726b189332df3ceb6ac0dfc98dcb9654bb723288c \ + --hash=sha256:ba835fad8d4b5eaea0d34f9461cf499737a720a5f8bc6768d2f6d98a1d3ba8b5 \ + --hash=sha256:bc0fbe4f79c3b156d754dd062718ffe404c3b31ca878d9d2b88ecdbc72ef8369 \ + --hash=sha256:bf734016932d68e1324cfb638cac7a0866a83933a33badf15e836a7e24efb8c0 \ + --hash=sha256:c0a894b0265d139cd4c2c8bd4643822cc289a4a5331e21e810881bd000ffb2dc \ + --hash=sha256:c113e4b489d711f606ca278cd8dc89179746244ea841aa0638b1b134600afad0 \ + --hash=sha256:c5e93ab6e6330dce40f5b43996f1fc5f2543296d263a5e4b59ff09e73870b7d8 \ + --hash=sha256:ca450c6d42fda7c0bfaf0e200f32f77c5f2c960a33be1e14d197c84588b7f5f6 \ + --hash=sha256:caa4c0fc9b25e8c679baac68337e9c00deb6baf6601fe6f07e8511469cc1ceba \ + --hash=sha256:cbf92cd7a1c800cee244558dd5630abb45c1c1093ef7b0d546a99f526ff7a9eb \ + --hash=sha256:d36b0263e7c2fbf1750b9f35d4e7e48b0442d8c45e4a89e3e74168821fb014bb \ + --hash=sha256:d3d50a5a07a2b31a4369b86dd4a3acf3c2577c45091dbd5e884b198dec56480b \ + --hash=sha256:d43e538dfc85b705fd832620d050970ef5613b3869a7169b44a554b9dab9eee9 \ + --hash=sha256:d613d7d51bf06fe9a5e3aab86ce06d835bb0ceb1a31ea9e8766df4a403ae62a6 \ + --hash=sha256:d6ec364493057c118d08292f7cd60865965c209167d8657b45643a26aef77495 \ + --hash=sha256:d7205aa6589f1ad1306c6994ff2e1ab5efabacf938f9ffd7682fb225c197b06a \ + --hash=sha256:d7a41d1427136828e6b11d3f3fd194d4357d5205a5b610be7ae07b1ba52ab974 \ + --hash=sha256:d8bc326133272deee8ef337581550450e4bb735c0520be893b950a1f5baaf41a \ + --hash=sha256:d90e4dae71b26597f6d1fd12af7d092ae67551edf0e524b73e0687214b79ee33 \ + --hash=sha256:da16c3037178e72589d91de59536d271dffc92c2be2b47c38a45538cba54fd29 \ + --hash=sha256:de15c46a63f46da881d219e44a04778c40114c756e5575b5ea38143b20eb3713 \ + --hash=sha256:dea3b3653c1d0d6babd8ce09cb9e5a7776864315d9cfb1c569f926389874a261 \ + --hash=sha256:dfd144837e92264878bba6d3a7ffa8206668b21e87748740afd45d193e48c87e \ + --hash=sha256:e07dfd7cd360f20be26dc2487ad4c7f21b0b39fdd593e9ebf37d2424ad52defb \ + --hash=sha256:e099aae21246992a1d84303ff262262d054c474e46cace33b0d5a69810c71877 \ + --hash=sha256:e63eecf9a4b670a24055d5c0486128af16f83b02d9817abd685d3238940f8752 \ + --hash=sha256:e69727ec04a4608200ff32ec29478299df7657e2eb78c416ae338d0ca6662596 \ + --hash=sha256:e90980e0454a043f81b435309a089ac5deab883779557eb58d004f33990f61e4 \ + --hash=sha256:e91dc8fe9cbd052d16f7f1267e285cf8bbac4d2194bd5741f05ef201c77ed09e \ + --hash=sha256:eb0093ca0817019d539f0d27bdf98e823944e05bb7efd9d2337a45b92e4d1b06 \ + --hash=sha256:ee2a5aa31e507c17841d592364f2148393b17f83bc2766e9211e1eb31ea72a70 \ + --hash=sha256:ef1e9c3b4a2300023a5dc865889ce532cfaf6798ba4ef78fbbefe738a9d23efa \ + --hash=sha256:efcbf93e31b52c21665b6ebc489eb6fdb0dfd86b592edc677b5cffdfa7b11677 \ + --hash=sha256:f0daef5ef012369dddc391c61f604111424aa9a96313a69fb899018b09d1b01e \ + --hash=sha256:f15dbbac49bc15eaff95c0b8166525c4050996af578c7be141e145f40ee2cf24 \ + --hash=sha256:f2e3d34edb151d3e27f93a8338e2da33e2c7261cd4c879ce5f5fe280cc190ea5 \ + --hash=sha256:f38c7b3ce60d55ac0f2a9fd2edc194789d043fefea15cfb7dcb40b7d5f561146 \ + --hash=sha256:f433c5654e32f72de3ef074d715eb5c7604eb6d28a5c2f3c9e5056f20fe6b306 \ + --hash=sha256:f482dc9c8309801b4259dd218609d3dae0a7bcdedeac56d90500eabaa0c23465 \ + --hash=sha256:f50d719f97ba488dfb8e306cf8c8cc172ff0683ec0af53917b288229aa555ba5 \ + --hash=sha256:f6757e02c2c2b0d7985f57be87cf4a7c5b35708f849e1feaea3f77656ddb9dff \ + --hash=sha256:f7e8d3c5caa44fdbee3eba1aa1417aa58c74c95d4089fe3c451911117fcbb2f3 \ + --hash=sha256:f887c3b7fb3058fc1ccd917f52078540c18d938d12018cb9edd64b6811d20c57 \ + --hash=sha256:f8b658dd3ef2ebd7708b311ed96ff7cb5edaca001a30018a091504ef492bcb1c \ + --hash=sha256:f8f61a9b5fa56c58e3441f9dc64137f786232d40fb89127190d0e0f84e1e5865 \ + --hash=sha256:fac247d0cc956d732df1211ac2d8a9999ec8dc1a92a90401de9d52e4ead546f7 \ + --hash=sha256:facc6df39047c4732cbd885bb62e40df20885fcd709fc399e11f81b45ce0fff9 \ + --hash=sha256:fafb521891e646d9cf5da89f01c75e7159d08be7254a024149630a698be28251 \ + --hash=sha256:fb22c8e4b60385a9b414c70c6e8b2d0f4b23ff7fc4bb572ceb58297b228169f1 \ + --hash=sha256:fc76be08ff407fba717a769aff04ca1b4bf9ecfdcb5caadef84a563fdbbd146d \ + --hash=sha256:fdf2d2a6ad402797422949f4a200a60d43e3a021695c8a7804aa1d5bbef22652 \ + --hash=sha256:ff4b366510c733974adb7a4102c094e4a6308e6f369315a4cf14d2d0a619cf82 + +aiosignal==1.4.0 \ + --hash=sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e \ + --hash=sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7 + +alibabacloud-credentials==0.3.6 \ + --hash=sha256:caa82cf258648dcbe1ca14aeba50ba21845567d6ac3cd48d318e0a445fff7f96 + +alibabacloud-dysmsapi20170525==2.0.24 \ + --hash=sha256:6223dfb9254669c761e573339cd01df80a9c6ca5b717291fc01e4a151a347570 \ + --hash=sha256:f65cad7e506a4408d51fe1dcfcc09cefb5b2a6dbcedaa3fd7a4d87ab72696bad + +alibabacloud-endpoint-util==0.0.4 \ + --hash=sha256:a593eb8ddd8168d5dc2216cd33111b144f9189fcd6e9ca20e48f358a739bbf90 + +alibabacloud-gateway-spi==0.0.4 \ + --hash=sha256:0d5256e95d8719da8ec9611b7ffbb12c2d26fdf7ce52c8f64e4e06350731e893 \ + --hash=sha256:73d6e20d65b54eed26d89c19640d3a7572e18c45ecada627f806f5dbe8ed2130 + +alibabacloud-openapi-util==0.2.4 \ + --hash=sha256:87022b9dcb7593a601f7a40ca698227ac3ccb776b58cb7b06b8dc7f510995c34 \ + --hash=sha256:a2474f230b5965ae9a8c286e0dc86132a887928d02d20b8182656cf6b1b6c5bd + +alibabacloud-tea==0.4.3 \ + --hash=sha256:ec8053d0aa8d43ebe1deb632d5c5404339b39ec9a18a0707d57765838418504a + +alibabacloud-tea-openapi==0.3.8 \ + --hash=sha256:99796fa807433ee31e4e06239901d5dbc9cd22884f50f9bbb09b1dae9dbbb723 + +alibabacloud-tea-util==0.3.15 \ + --hash=sha256:79f78e596f6be03fb9565e34ac45420f3730e52888376cc9713bd07432a4c6cc \ + --hash=sha256:afb3dd8ad5cd2f93804258fbbac4360c050462100499269795ea055c1644e193 + +alibabacloud-tea-xml==0.0.3 \ + --hash=sha256:979cb51fadf43de77f41c69fc69c12529728919f849723eb0cd24eb7b048a90c + +annotated-doc==0.0.5 \ + --hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101 \ + --hash=sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb + +annotated-types==0.8.0 \ + --hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \ + --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 + +anyio==4.15.1 \ + --hash=sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101 \ + --hash=sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94 + +attrs==26.1.0 \ + --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ + --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32 + +bcrypt==4.1.2 \ + --hash=sha256:02d9ef8915f72dd6daaef40e0baeef8a017ce624369f09754baf32bb32dba25f \ + --hash=sha256:1c28973decf4e0e69cee78c68e30a523be441972c826703bb93099868a8ff5b5 \ + --hash=sha256:2a298db2a8ab20056120b45e86c00a0a5eb50ec4075b6142db35f593b97cb3fb \ + --hash=sha256:33313a1200a3ae90b75587ceac502b048b840fc69e7f7a0905b5f87fac7a1258 \ + --hash=sha256:3566a88234e8de2ccae31968127b0ecccbb4cddb629da744165db72b58d88ca4 \ + --hash=sha256:387e7e1af9a4dd636b9505a465032f2f5cb8e61ba1120e79a0e1cd0b512f3dfc \ + --hash=sha256:44290ccc827d3a24604f2c8bcd00d0da349e336e6503656cb8192133e27335e2 \ + --hash=sha256:57fa9442758da926ed33a91644649d3e340a71e2d0a5a8de064fb621fd5a3326 \ + --hash=sha256:68e3c6642077b0c8092580c819c1684161262b2e30c4f45deb000c38947bf483 \ + --hash=sha256:69057b9fc5093ea1ab00dd24ede891f3e5e65bee040395fb1e66ee196f9c9b4a \ + --hash=sha256:6cad43d8c63f34b26aef462b6f5e44fdcf9860b723d2453b5d391258c4c8e966 \ + --hash=sha256:71b8be82bc46cedd61a9f4ccb6c1a493211d031415a34adde3669ee1b0afbb63 \ + --hash=sha256:732b3920a08eacf12f93e6b04ea276c489f1c8fb49344f564cca2adb663b3e4c \ + --hash=sha256:9800ae5bd5077b13725e2e3934aa3c9c37e49d3ea3d06318010aa40f54c63551 \ + --hash=sha256:a97e07e83e3262599434816f631cc4c7ca2aa8e9c072c1b1a7fec2ae809a1d2d \ + --hash=sha256:ac621c093edb28200728a9cca214d7e838529e557027ef0581685909acd28b5e \ + --hash=sha256:b8df79979c5bae07f1db22dcc49cc5bccf08a0380ca5c6f391cbb5790355c0b0 \ + --hash=sha256:b90e216dc36864ae7132cb151ffe95155a37a14e0de3a8f64b49655dd959ff9c \ + --hash=sha256:ba4e4cc26610581a6329b3937e02d319f5ad4b85b074846bf4fef8a8cf51e7bb \ + --hash=sha256:ba55e40de38a24e2d78d34c2d36d6e864f93e0d79d0b6ce915e4335aa81d01b1 \ + --hash=sha256:be3ab1071662f6065899fe08428e45c16aa36e28bc42921c4901a191fda6ee42 \ + --hash=sha256:d75fc8cd0ba23f97bae88a6ec04e9e5351ff3c6ad06f38fe32ba50cbd0d11946 \ + --hash=sha256:e51c42750b7585cee7892c2614be0d14107fad9581d1738d954a262556dd1aab \ + --hash=sha256:ea505c97a5c465ab8c3ba75c0805a102ce526695cd6818c6de3b1a38f6f60da1 \ + --hash=sha256:eb3bd3321517916696233b5e0c67fd7d6281f0ef48e66812db35fc963a422a1c \ + --hash=sha256:f70d9c61f9c4ca7d57f3bfe88a5ccf62546ffbadf3681bb1e268d9d2e41c91a7 \ + --hash=sha256:fbe188b878313d01b7718390f31528be4010fed1faa798c5a1d0469c9c48c369 + +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ + --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 + +cffi==2.1.1 \ + --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \ + --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \ + --hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \ + --hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \ + --hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 \ + --hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \ + --hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \ + --hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \ + --hash=sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9 \ + --hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \ + --hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \ + --hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \ + --hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \ + --hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \ + --hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \ + --hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \ + --hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \ + --hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \ + --hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \ + --hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \ + --hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \ + --hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \ + --hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \ + --hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \ + --hash=sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659 \ + --hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \ + --hash=sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004 \ + --hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \ + --hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \ + --hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \ + --hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \ + --hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \ + --hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \ + --hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \ + --hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \ + --hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \ + --hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \ + --hash=sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9 \ + --hash=sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf \ + --hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \ + --hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \ + --hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \ + --hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \ + --hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \ + --hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \ + --hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \ + --hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \ + --hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \ + --hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \ + --hash=sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9 \ + --hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \ + --hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \ + --hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \ + --hash=sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41 \ + --hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \ + --hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \ + --hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \ + --hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \ + --hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \ + --hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \ + --hash=sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98 \ + --hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \ + --hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 \ + --hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \ + --hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \ + --hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \ + --hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \ + --hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \ + --hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \ + --hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \ + --hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \ + --hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \ + --hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \ + --hash=sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be \ + --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \ + --hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \ + --hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \ + --hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \ + --hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \ + --hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b \ + --hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \ + --hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \ + --hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \ + --hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \ + --hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \ + --hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \ + --hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \ + --hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \ + --hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \ + --hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \ + --hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \ + --hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \ + --hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \ + --hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \ + --hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \ + --hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \ + --hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \ + --hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \ + --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \ + --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264 + +charset-normalizer==3.5.2 \ + --hash=sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e \ + --hash=sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf \ + --hash=sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5 \ + --hash=sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56 \ + --hash=sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26 \ + --hash=sha256:0891b9d3903c5571c03771ca669a4b0ec5618ca722a5c957d3d29cd4e5062848 \ + --hash=sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718 \ + --hash=sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93 \ + --hash=sha256:114e4d0c92d618409ed82a99e22b5c5e768fe995f2973f78265f4524f49d4640 \ + --hash=sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3 \ + --hash=sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875 \ + --hash=sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e \ + --hash=sha256:1461ac396c4fdb983a675f20aa555624f0ee18ac83d832b9244ffff3d8055275 \ + --hash=sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204 \ + --hash=sha256:15bb4005af6320d259dc7593ca84a38d7fe06a421dbcf7b910ae23979101e787 \ + --hash=sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234 \ + --hash=sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3 \ + --hash=sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98 \ + --hash=sha256:195c26fb65950f8fce54e26349852b7bdd7c5f120aeefbcc440b8a20faaed4a3 \ + --hash=sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187 \ + --hash=sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d \ + --hash=sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f \ + --hash=sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7 \ + --hash=sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011 \ + --hash=sha256:211d5a3eb6af8f513b8d4ca19a8c1b7accab1b5f0d3175f9826b03c1a920dc1f \ + --hash=sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869 \ + --hash=sha256:254eb48b9fa5ee9898a3c445825a1f340fe53712a098904b39b0bddba8ea3cb1 \ + --hash=sha256:2625388c6c754520c37abaf3b41eb34d1cc4a373f457898f08606c8e362b891d \ + --hash=sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847 \ + --hash=sha256:28a15fdad492a99b6eccfaaed66ef3f74050680545ea61ec8b2f4c538f1f1320 \ + --hash=sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9 \ + --hash=sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93 \ + --hash=sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd \ + --hash=sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00 \ + --hash=sha256:2cc961b171b3f3440f410489ab3573e86aea8736134ebbb40ea1338b7f0831bc \ + --hash=sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0 \ + --hash=sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09 \ + --hash=sha256:304d5463e65a35d7bb0850550e0780395395f6fcf452f04db7d5ca7cecc425ac \ + --hash=sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621 \ + --hash=sha256:30fcd120b732aa79317f08dee04d7de0847822e4cf7ee0e9f445bb958832252c \ + --hash=sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8 \ + --hash=sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a \ + --hash=sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51 \ + --hash=sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0 \ + --hash=sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef \ + --hash=sha256:3d14b50de6bf4d0edf857a9386836846f982b8f524e188e2e68b96d702bcf4aa \ + --hash=sha256:3d21b8b13c7592db2ac5e544a6d83187b995257472b0c9e8351b6d507ae37ed6 \ + --hash=sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649 \ + --hash=sha256:3ddacd27458c45bdacd6bd6db644bfb730efbf9e830310186e3045c9c5be8fb2 \ + --hash=sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229 \ + --hash=sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e \ + --hash=sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd \ + --hash=sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115 \ + --hash=sha256:447441e76ec720b15e64418d32e092297340387053047c7c694f579efb0ee1d9 \ + --hash=sha256:4495c5002a7b28557e7e222e77e0b661183e432b7d6d2e788101e3f240e05b8c \ + --hash=sha256:44bd4fbb29dfbeba60e7d2bd000c59e4b21ddb3cc53912b14048d37092706d7c \ + --hash=sha256:4685902cf26edf013ed7a3da0f426ebba7a00ebb9541386d835afbf002c11cab \ + --hash=sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253 \ + --hash=sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995 \ + --hash=sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438 \ + --hash=sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0 \ + --hash=sha256:50e3adfb96fc189eb27b1cf62d3b598b89b4bb0420d93a3d3e42e137409011be \ + --hash=sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b \ + --hash=sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7 \ + --hash=sha256:55ea99acb17b9325618de155a0cd6a2e8f5d10be008113e1d433bbb58db543b2 \ + --hash=sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a \ + --hash=sha256:588461c2e8384d309bd63e5826019b6977bc66d629b99ac8737bb795d7b2cb5a \ + --hash=sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a \ + --hash=sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c \ + --hash=sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5 \ + --hash=sha256:59f63901b0031c3136cf64704dcb21de0bbae62ce2c9529bc39d27665463de37 \ + --hash=sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e \ + --hash=sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4 \ + --hash=sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800 \ + --hash=sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055 \ + --hash=sha256:619799369eeef6366ed3e8755a5670f4f2f0fb6b30a0fd7264dc0fdc2357058e \ + --hash=sha256:62588a277bfb59def052abd940703fa35107152bf479781a878617d60faf8fb5 \ + --hash=sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c \ + --hash=sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b \ + --hash=sha256:68eb192d85ab8e5f6ec69c2bc6ac0179fbf04a5ac1569d12fbef74883fe102d0 \ + --hash=sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80 \ + --hash=sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a \ + --hash=sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4 \ + --hash=sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2 \ + --hash=sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58 \ + --hash=sha256:75a3ceed0724d625d64b86ca20aba182e4df462e04c2414fc941c0f523f06aac \ + --hash=sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc \ + --hash=sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639 \ + --hash=sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf \ + --hash=sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d \ + --hash=sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f \ + --hash=sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c \ + --hash=sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc \ + --hash=sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4 \ + --hash=sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253 \ + --hash=sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade \ + --hash=sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858 \ + --hash=sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26 \ + --hash=sha256:87e50a3e7cb90af586b6c5faf23e302a970415ac73bd7bd90a515a04b427ef96 \ + --hash=sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8 \ + --hash=sha256:8a893cc101149f80a653f82062ebc95b34525a2614382e1da5458fe7c6997249 \ + --hash=sha256:8b2bfab86aa71ae13aa41a6a26aab338e0db2b8bc75434b05aea89e011ff35a4 \ + --hash=sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13 \ + --hash=sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1 \ + --hash=sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03 \ + --hash=sha256:93223adc95033dd47133a46ccfc316a0139176fd79085762e27202ec56018f03 \ + --hash=sha256:9373ad13ef0d2c0fb761e04e55bfdee5a08b52cef2c882c8fbe9935b1517152e \ + --hash=sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364 \ + --hash=sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4 \ + --hash=sha256:9bde855991b7e362c146535e3136a50bfaffc0487d38b33ca7e5edefc6e23849 \ + --hash=sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0 \ + --hash=sha256:9cf9b1a857e25c4baceeb3624e92a56df3668f398c4acba74e174d81fb4d1d3a \ + --hash=sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036 \ + --hash=sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3 \ + --hash=sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21 \ + --hash=sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3 \ + --hash=sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e \ + --hash=sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413 \ + --hash=sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21 \ + --hash=sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346 \ + --hash=sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429 \ + --hash=sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685 \ + --hash=sha256:b736353c0a625bbd5fcec108576e2385db3496f4f771f785ff32e108d3c3bc45 \ + --hash=sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f \ + --hash=sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c \ + --hash=sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d \ + --hash=sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad \ + --hash=sha256:bd16aabe4a02a297c23417aa17ac6299dbd8c49f673bcd645b4929b11f5a4400 \ + --hash=sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb \ + --hash=sha256:c6708715abcf3c73b99508253e961a9967f02fe536532834149574eda6de0d1c \ + --hash=sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc \ + --hash=sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c \ + --hash=sha256:c9790464842f85f437dbbb54417eda1e0e6bfc52dd8d22d6fd1c994b73b2dc74 \ + --hash=sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf \ + --hash=sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604 \ + --hash=sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f \ + --hash=sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105 \ + --hash=sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a \ + --hash=sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d \ + --hash=sha256:d4a7319f304a774bed22115bc891618e45f85065ab44ea6acd07d274e750519a \ + --hash=sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1 \ + --hash=sha256:d760fe2a4d7c3b226cb9026d6a842868d52a7901bd98420e1baf14e80da85cf5 \ + --hash=sha256:d913de495d90407cd859d263bee2e5d1a4ed3eb6573c04e70d9ec619a7cbed7f \ + --hash=sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e \ + --hash=sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709 \ + --hash=sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874 \ + --hash=sha256:ddf19c062bea7a0cc80f519243d2c01dd091be0cf952a0750d4ad576709559f5 \ + --hash=sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc \ + --hash=sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95 \ + --hash=sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd \ + --hash=sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0 \ + --hash=sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d \ + --hash=sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3 \ + --hash=sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c \ + --hash=sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3 \ + --hash=sha256:e80e6c2f55656b4824d72065abb4ddd6a525c74bd78a0aab5d9fc2cf4fb5af50 \ + --hash=sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491 \ + --hash=sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5 \ + --hash=sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5 \ + --hash=sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655 \ + --hash=sha256:ef4fcbf3327382cd4c9f540babd61248208af7b93eec4de397b4d5f58a09e288 \ + --hash=sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd \ + --hash=sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084 \ + --hash=sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d \ + --hash=sha256:f5833ad231be5eb6553de524a70f48d71b2c8563101750531e0b80184e175cd4 \ + --hash=sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915 \ + --hash=sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1 \ + --hash=sha256:fb9e68df06293761f9fe66ade60a9bc6d0f5e42b8acf2939a9158af86ab0e5bd \ + --hash=sha256:fc14a032f813bf5fe624d991960ea83e9715adc27e4c1830a2361eb1d02ac341 \ + --hash=sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424 \ + --hash=sha256:fd1fbe0f116b6e55da77aca2c6ddcddcfac2186cbf78bdebf40fc156efca389d \ + --hash=sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f + +click==8.5.0 \ + --hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360 \ + --hash=sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34 + +colorama==0.4.6 \ + --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 + +cryptography==50.0.2 \ + --hash=sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602 \ + --hash=sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2 \ + --hash=sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047 \ + --hash=sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c \ + --hash=sha256:1ba34f04897fcdaa73f74145c25f3ec146fbd56593853e88adc2e811303c5f42 \ + --hash=sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18 \ + --hash=sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51 \ + --hash=sha256:3dc4fd8058cea1644971207d530e1a03a184a805ffc8ebdddf0599d78a331b81 \ + --hash=sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856 \ + --hash=sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2 \ + --hash=sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de \ + --hash=sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7 \ + --hash=sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd \ + --hash=sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2 \ + --hash=sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be \ + --hash=sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45 \ + --hash=sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0 \ + --hash=sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e \ + --hash=sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c \ + --hash=sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5 \ + --hash=sha256:7b75de3c8b3be1cdb1052747c929440c3eea46c1bc2cb8a6e3a48388e9b7b452 \ + --hash=sha256:7c6d0330c472d96f6a6afe24d80dfdf15176c33096f0a4397ae4c60f3dd3be48 \ + --hash=sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05 \ + --hash=sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1 \ + --hash=sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93 \ + --hash=sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04 \ + --hash=sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e \ + --hash=sha256:92e665960f25fcdc73725b9cec7a3824f279ba97a98653afe9ffac2e43668f67 \ + --hash=sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7 \ + --hash=sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107 \ + --hash=sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079 \ + --hash=sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134 \ + --hash=sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227 \ + --hash=sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1 \ + --hash=sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539 \ + --hash=sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e \ + --hash=sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d \ + --hash=sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c \ + --hash=sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd \ + --hash=sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020 \ + --hash=sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd \ + --hash=sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94 \ + --hash=sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a \ + --hash=sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408 \ + --hash=sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37 \ + --hash=sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e \ + --hash=sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454 \ + --hash=sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c \ + --hash=sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc \ + --hash=sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37 \ + --hash=sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767 \ + --hash=sha256:eef4c2f3423810b3070ab391f85436d2f8bbfcb286ac15cbc73190b3563b1f1a \ + --hash=sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5 \ + --hash=sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc \ + --hash=sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67 \ + --hash=sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8 \ + --hash=sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480 \ + --hash=sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb \ + --hash=sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b + +fastapi==0.142.2 \ + --hash=sha256:06366626f2e70576367714d9ab2fe8472e6c8456dba69b399f9f797ab5e92570 \ + --hash=sha256:bd5f4d81f1e93a88bcd77caf4dfe3c2dbffc3805407a0007e9a114c18b3a670b + +filelock==4.0.12 \ + --hash=sha256:5f17ee83ecee8a6f3e389c75822fb70a1c2f0506b99438a6dffa1d56793588c8 \ + --hash=sha256:cf42711a7ac791818b299fab0332a088c65aeeefa36290de98db92c434303b0c + +frozenlist==1.8.0 \ + --hash=sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686 \ + --hash=sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0 \ + --hash=sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121 \ + --hash=sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd \ + --hash=sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7 \ + --hash=sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c \ + --hash=sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84 \ + --hash=sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d \ + --hash=sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b \ + --hash=sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79 \ + --hash=sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967 \ + --hash=sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f \ + --hash=sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4 \ + --hash=sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7 \ + --hash=sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef \ + --hash=sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9 \ + --hash=sha256:1a7607e17ad33361677adcd1443edf6f5da0ce5e5377b798fba20fae194825f3 \ + --hash=sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd \ + --hash=sha256:1aa77cb5697069af47472e39612976ed05343ff2e84a3dcf15437b232cbfd087 \ + --hash=sha256:1b9290cf81e95e93fdf90548ce9d3c1211cf574b8e3f4b3b7cb0537cf2227068 \ + --hash=sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7 \ + --hash=sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed \ + --hash=sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b \ + --hash=sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f \ + --hash=sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25 \ + --hash=sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe \ + --hash=sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143 \ + --hash=sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e \ + --hash=sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930 \ + --hash=sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37 \ + --hash=sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128 \ + --hash=sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2 \ + --hash=sha256:332db6b2563333c5671fecacd085141b5800cb866be16d5e3eb15a2086476675 \ + --hash=sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f \ + --hash=sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746 \ + --hash=sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df \ + --hash=sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8 \ + --hash=sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c \ + --hash=sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0 \ + --hash=sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad \ + --hash=sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82 \ + --hash=sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29 \ + --hash=sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c \ + --hash=sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30 \ + --hash=sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf \ + --hash=sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62 \ + --hash=sha256:48e6d3f4ec5c7273dfe83ff27c91083c6c9065af655dc2684d2c200c94308bb5 \ + --hash=sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383 \ + --hash=sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c \ + --hash=sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52 \ + --hash=sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d \ + --hash=sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1 \ + --hash=sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a \ + --hash=sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714 \ + --hash=sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65 \ + --hash=sha256:59a6a5876ca59d1b63af8cd5e7ffffb024c3dc1e9cf9301b21a2e76286505c95 \ + --hash=sha256:5a3a935c3a4e89c733303a2d5a7c257ea44af3a56c8202df486b7f5de40f37e1 \ + --hash=sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506 \ + --hash=sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888 \ + --hash=sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6 \ + --hash=sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41 \ + --hash=sha256:6dc4126390929823e2d2d9dc79ab4046ed74680360fc5f38b585c12c66cdf459 \ + --hash=sha256:7398c222d1d405e796970320036b1b563892b65809d9e5261487bb2c7f7b5c6a \ + --hash=sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608 \ + --hash=sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa \ + --hash=sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8 \ + --hash=sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1 \ + --hash=sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186 \ + --hash=sha256:7bf6cdf8e07c8151fba6fe85735441240ec7f619f935a5205953d58009aef8c6 \ + --hash=sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed \ + --hash=sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e \ + --hash=sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52 \ + --hash=sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231 \ + --hash=sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450 \ + --hash=sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496 \ + --hash=sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a \ + --hash=sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3 \ + --hash=sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24 \ + --hash=sha256:940d4a017dbfed9daf46a3b086e1d2167e7012ee297fef9e1c545c4d022f5178 \ + --hash=sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695 \ + --hash=sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7 \ + --hash=sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4 \ + --hash=sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e \ + --hash=sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e \ + --hash=sha256:9ff15928d62a0b80bb875655c39bf517938c7d589554cbd2669be42d97c2cb61 \ + --hash=sha256:a6483e309ca809f1efd154b4d37dc6d9f61037d6c6a81c2dc7a15cb22c8c5dca \ + --hash=sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad \ + --hash=sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b \ + --hash=sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a \ + --hash=sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8 \ + --hash=sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51 \ + --hash=sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011 \ + --hash=sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8 \ + --hash=sha256:b4f3b365f31c6cd4af24545ca0a244a53688cad8834e32f56831c4923b50a103 \ + --hash=sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b \ + --hash=sha256:b9be22a69a014bc47e78072d0ecae716f5eb56c15238acca0f43d6eb8e4a5bda \ + --hash=sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806 \ + --hash=sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042 \ + --hash=sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e \ + --hash=sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b \ + --hash=sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef \ + --hash=sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d \ + --hash=sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567 \ + --hash=sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a \ + --hash=sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2 \ + --hash=sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0 \ + --hash=sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e \ + --hash=sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b \ + --hash=sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d \ + --hash=sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a \ + --hash=sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52 \ + --hash=sha256:d8b7138e5cd0647e4523d6685b0eac5d4be9a184ae9634492f25c6eb38c12a47 \ + --hash=sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1 \ + --hash=sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94 \ + --hash=sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f \ + --hash=sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff \ + --hash=sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822 \ + --hash=sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a \ + --hash=sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11 \ + --hash=sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581 \ + --hash=sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51 \ + --hash=sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565 \ + --hash=sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40 \ + --hash=sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92 \ + --hash=sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2 \ + --hash=sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5 \ + --hash=sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4 \ + --hash=sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93 \ + --hash=sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027 \ + --hash=sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd + +fsspec==2026.9.0 \ + --hash=sha256:0f08147951c8cb31d844c3547d631053b127863b60be04cf06e121333ee0e2fe \ + --hash=sha256:8dd6e646e99ea382bd85f97a45e6b526a442d79423a7dc673f1e2756d05fcb5f + +greenlet==3.3.2 \ + --hash=sha256:02b0a8682aecd4d3c6c18edf52bc8e51eacdd75c8eac52a790a210b06aa295fd \ + --hash=sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082 \ + --hash=sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b \ + --hash=sha256:1e692b2dae4cc7077cbb11b47d258533b48c8fde69a33d0d8a82e2fe8d8531d5 \ + --hash=sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f \ + --hash=sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727 \ + --hash=sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e \ + --hash=sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2 \ + --hash=sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f \ + --hash=sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327 \ + --hash=sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd \ + --hash=sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2 \ + --hash=sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070 \ + --hash=sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99 \ + --hash=sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be \ + --hash=sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79 \ + --hash=sha256:508c7f01f1791fbc8e011bd508f6794cb95397fdb198a46cb6635eb5b78d85a7 \ + --hash=sha256:527fec58dc9f90efd594b9b700662ed3fb2493c2122067ac9c740d98080a620e \ + --hash=sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf \ + --hash=sha256:5d0e35379f93a6d0222de929a25ab47b5eb35b5ef4721c2b9cbcc4036129ff1f \ + --hash=sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506 \ + --hash=sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a \ + --hash=sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395 \ + --hash=sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4 \ + --hash=sha256:8c1fdd7d1b309ff0da81d60a9688a8bd044ac4e18b250320a96fc68d31c209ca \ + --hash=sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492 \ + --hash=sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab \ + --hash=sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358 \ + --hash=sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce \ + --hash=sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5 \ + --hash=sha256:97245cc10e5515dbc8c3104b2928f7f02b6813002770cfaffaf9a6e0fc2b94ef \ + --hash=sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d \ + --hash=sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac \ + --hash=sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55 \ + --hash=sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124 \ + --hash=sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4 \ + --hash=sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986 \ + --hash=sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd \ + --hash=sha256:ad0c8917dd42a819fe77e6bdfcb84e3379c0de956469301d9fd36427a1ca501f \ + --hash=sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb \ + --hash=sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4 \ + --hash=sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13 \ + --hash=sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab \ + --hash=sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff \ + --hash=sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a \ + --hash=sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9 \ + --hash=sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86 \ + --hash=sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd \ + --hash=sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71 \ + --hash=sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92 \ + --hash=sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643 \ + --hash=sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54 \ + --hash=sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9 + +h11==0.16.0 \ + --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ + --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 + +httpcore==1.0.9 \ + --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ + --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 + +httpx==0.25.2 \ + --hash=sha256:8b8fcaa0c8ea7b05edd69a094e63a2094c4efcb48129fb757361bc423c0ad9e8 \ + --hash=sha256:a05d3d052d9b2dfce0e3896636467f8a5342fb2b902c819428e1ac65413ca118 + +huggingface-hub==0.19.4 \ + --hash=sha256:176a4fc355a851c17550e7619488f383189727eab209534d7cef2114dae77b22 \ + --hash=sha256:dba013f779da16f14b606492828f3760600a1e1801432d09fe1c33e50b825bb5 + +idna==3.20 \ + --hash=sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44 \ + --hash=sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c + +multidict==6.9.1 \ + --hash=sha256:006c4478de0a1876f4834e14255776286f09b9846b505fe63f67f9d173a9487c \ + --hash=sha256:024123f0ab402ab33828e24eb80fa8f25167d0d3783ba5f287e39ed741e6abf9 \ + --hash=sha256:02f6d0c4b70f783305e73f9944d8efe6be1022550f0974ba0ae9d8893c0350fa \ + --hash=sha256:02fe09dc197b8ae7e355371e51e5dce2f39060cd5a8badf94904527e23a3f188 \ + --hash=sha256:03731f6fc036180700c9dc2308205a48e5ca6f3ff03087739ab746f294022201 \ + --hash=sha256:03d47df72f084f757c1cb771188d5f4e3a805e4abc4d67e32509272343ae9382 \ + --hash=sha256:03fac50ddfd8302175b77863a015eccfae76767cda5506eef86df559ba861e1f \ + --hash=sha256:042fb0196047e786936a730bd302de83143950da45f2c16078da8f35e1cf7919 \ + --hash=sha256:083735b7f395894e43adb278d5dae901448883a835ff8f1977e285fefdb10418 \ + --hash=sha256:095d900c242e00fbe5f321ee072e7278b4153e78c5ce9c1efde167d62c1e4771 \ + --hash=sha256:0a5769559e3312dd96731fbe15b4abb6033368ac1cad5a98dadd21946a4c7d6c \ + --hash=sha256:0b2fb8c349d1103863750b5d8cb5ace766917f4b35f3d883c8f778853eaa9f76 \ + --hash=sha256:0dd655518f136febd96c05131a76a863e32fc2a1d7acd4e3c959e3ceb77d8345 \ + --hash=sha256:0f06e60fa190aa7abd0914c2a766736fdc8e9f34878c4346338534b73d1b20e2 \ + --hash=sha256:0f2ce963299d42fa3f22a90adc0fdf174792ffef5ff4c7ffb68260548fb05580 \ + --hash=sha256:0f3bd290711c6e9486173a6ee7cd4e7f00c3971c7908c1ec1b6e5437c5e4c6f9 \ + --hash=sha256:10083a8a0f4e1b26b599889e90b9802504ce5d3f7722f925bbb7ca47dd22a7c1 \ + --hash=sha256:13849a1d4f54c3809ae721e9e83ab28f5ea602f33660cb84eb6ef261eac706c1 \ + --hash=sha256:14c56f73e78faa1f68bbb826197cd5871994e70e841b8590829c35912ece5c64 \ + --hash=sha256:15db6a102cbaf1949cf028ecf080aac76d20bcd29ad4e092574db6c6b7af78a5 \ + --hash=sha256:19e31815d41cefc365489e591d105d2baceb2f65aa75d29471fbdbda8651e006 \ + --hash=sha256:1a53de2772cfb74559df2eb4456ec4eeb908435ec55a84b69370d9d745d62aa8 \ + --hash=sha256:1a8adfcaf96f587ab138476eaddef95f29b8a2a8a9afbfea8d2fd62180995d02 \ + --hash=sha256:1a938761c77e0e6edb0c93d02f4e988d44a69e5195e5a3b893e5553311347132 \ + --hash=sha256:1c9f3c25df6c9d3bbae4f6fd3f514b1c5c740a2110f56ccf36069c85417e289c \ + --hash=sha256:1ca5ebe6d454f1e5496cf052386a559f1080bf2de75bf327ebca0a6003b79f19 \ + --hash=sha256:1d804e4caf5d5da37d6dac1325da5629ebef1e27a294c2b568b295814aa36c7b \ + --hash=sha256:1fee9a16d88a1c4865610de31ef5c666671020d7a81d1f510eaf3c97d00ebeba \ + --hash=sha256:23136f5a564654eb61061ec6d5620a4c1ea32c8f552b65e9982a12b72bff601b \ + --hash=sha256:2784090c30a586d5b45197bd9c32f87fb927216cde302f6cfd76d76577e90f08 \ + --hash=sha256:29138fef49828542e859828107e42e50d0e587c513b7eb4b2d92bade2b0860fe \ + --hash=sha256:2c1aeb92eea59d824f004341b26d5e4b47a8a441cf9726769b9a90abf9d0e08f \ + --hash=sha256:2c5d675da8f1cb5650271c8ad5e95c0a3e5a183c105e72d953b12877b1c8d0fd \ + --hash=sha256:3100d169ceb7bc8f05f89a6db11d1b21f119975fc26f29dc45a472e3569f0879 \ + --hash=sha256:31199204b3ced121ff5407a2c342326a5d27e3870abbf94bd80dbd2451b7bc8f \ + --hash=sha256:32217133dddc58c927805cf6c0731d8144584176b768042ee886d51e71860bc9 \ + --hash=sha256:33fa55b990f81c2927e01399ace0d18926c69d69baa8cdaa819424132fb97987 \ + --hash=sha256:35ba0263bae5dd3ad5aad767cc9afc01a8598c7dae30f1b3b2de98b1b32c28bd \ + --hash=sha256:35fc236507fb1b3138f0af5ecd5f94ed752d4d6d826248eae425f86204013eea \ + --hash=sha256:361f7206cf341ba94fb015688f5c8b480f8e63bd58a4c14a48aeca7851a241cc \ + --hash=sha256:369b5aa01b241cd3fea6890bdbb11a1425d87bf1515831500d518f4223e9d72c \ + --hash=sha256:37245ca4105386194dd1d292a6f2aae09bfe1bd7ac6f9ec25093e3cf8e9b143b \ + --hash=sha256:372c063f37480f62c1ae32dc3a1a0a5942b180c883f99789075a8a8ec3c4e709 \ + --hash=sha256:37a9ebe00c698279213d56e6c64e1962ab1e092918270649b397cac3dc196ca4 \ + --hash=sha256:38c9986f9ce50c459b10de216a05f4bd7ed5ac63887d56e500a52bb464b861ce \ + --hash=sha256:3adf06c66041aa21eeb8a71e82379b74773298c8e6d3d839b151aae441a99b94 \ + --hash=sha256:3c95601ed98fad3f6e2f8fe809c3b526b0fab31ef525e00a155e227f3d17f58a \ + --hash=sha256:40aec5299e1ed71fbb988389059da381c3c1a60e0c649acceb2a35d9b128848e \ + --hash=sha256:43124fe172ada86d03ac3c8dc8179091341f6724d5e5d5b160e1587e4cd3761b \ + --hash=sha256:4736d350371337825cac1793c9f7c40701c32a03912548c2a7608e51679cbb96 \ + --hash=sha256:475d04d5192eba487a3e2f935976340baa24529046e9c1c9c7a3b7bf80445ae1 \ + --hash=sha256:4a409ccc42aefec904038695d5d7fd6d8f3af2721b7b6401d55135ec7d6d298e \ + --hash=sha256:4d718fa1b5f0d0dd75e86fbbc5b0c93ea3a5d65216c85c61cd5d7cdddfe08455 \ + --hash=sha256:501ed8b02a5990c67a91c732843609d43a6be1f7576fcdfc867331239f37fbd3 \ + --hash=sha256:539c2cd5fed0947c135cd7eabaaac55f48300dfa1de0f3ca4edb5efa6606f471 \ + --hash=sha256:557a4e1708df428ebe6c3081c83a273d275dad2446ce0d81fc648ac71afdb18d \ + --hash=sha256:56d834b74c993a7d7cb2b8ab33a0d55c3e0d4a3d2f2da2808a4ad3d79189711b \ + --hash=sha256:5800368526647146978389dfaa46da3356291e9f0fff9a4ef12e8c2bef964a0d \ + --hash=sha256:59c123d0e948d760a5f930f316cfefa07e8d632ab84327c0693ee6a88171154f \ + --hash=sha256:5b30ddf7234e611ca877575b62840e6af5977f92f1f9d532eedbb05a44ff8004 \ + --hash=sha256:5ecace251ccfa705bf3d7d35c5032cf750f5c629809740405697bce5c118c4a4 \ + --hash=sha256:5f89dad732280e7a10b74d40b91364f88e13c3f2c08c2ef83a8cd42f7a61af2e \ + --hash=sha256:637f4ae36264bd7b8d9a60193acddc1d735ad52e8ed53a19931ea6d921fea8e5 \ + --hash=sha256:63ada7ee2e9345695f9e9bc4c65d72222253f07b1ac94fd0e37555cc6f3c7f60 \ + --hash=sha256:6441cc837aea58be7d9baef1b2383eb8311ab9303f500f99ac90b584cd78bb14 \ + --hash=sha256:658f90f49cf5af2441cad0a2b801c3ef520471989a1ec55bcb25b255b2ca8d2f \ + --hash=sha256:66987aa68b0f7c2a1cc5f388ca962b8ed92b10f79de38d6d0d8c716154f519d9 \ + --hash=sha256:696477ad71385c4795e3b8e4cf10b0d2c28c2a1ca6a955e031cb1e62993e9ee3 \ + --hash=sha256:6b7e54fd883671d1a8810704851c044b7173287c552b9f5c3d9e0eb9f00ae194 \ + --hash=sha256:6bc94fe17c3c56e5418f79515b786b101845f70609b0d19d0c1ba13448e5633a \ + --hash=sha256:6e6b7e3a1520c39a2772f414cd9ddf5995f77e4e823dfa1522af383addd465a3 \ + --hash=sha256:6ed30be8918e18c8bed0a2e8b70639ecf02feb61ed00ca2e41cfcb2a50fa3f42 \ + --hash=sha256:73533644e1f69ea1164d56cc6505f564c6c44072b646b66d67df2d31fed0348c \ + --hash=sha256:73bcafa21a78d0776b3ee7cd2a63c66f968eb7db8e8d594e32d7329950f6e828 \ + --hash=sha256:7814bbee202acd3bd240204c17d8b87a4c48c81064fd8674dbe527d94d5a4290 \ + --hash=sha256:783ba7d845d79ce976afd9c1e91a4e5714671defa198ee789e8b23316083a485 \ + --hash=sha256:79da2491348b30810728050b4a8ec0416f85884125c2fd44655b3d01150d9c3e \ + --hash=sha256:7a0c98f6a636adf0d7edd60c61589eaf52d149239af754d0bfeb0effedba53a8 \ + --hash=sha256:7ab379f95caee071a37cbd8be86d4c65accc651d391f9f97748fef006f38769c \ + --hash=sha256:7bf6478188f4e47bf5686e8a33da4ae28bf43b1b2528d9ee144d28492bfac60b \ + --hash=sha256:7c6eecfab7ce4cd9487ff8ba936fe38cdfd68c04faf3d5c710363c6a8e695659 \ + --hash=sha256:7c708566da8014b120a64b1eb6d200c6c0c8cb36296383723cdb6fc82038270b \ + --hash=sha256:7c8d5882ba25ac0282258be435d8a05aa0cbcacfce15799154a338f847f159b9 \ + --hash=sha256:7fc59e9ba821b220944ccfe0f89c9dc4745f6d097569992356eb03869f21e953 \ + --hash=sha256:7fd79c521f6290c69125fa2b85fa65d9e657e6a8ffaf722dc881b926bef4aa5c \ + --hash=sha256:803f8b575a71b1b299d677c28db0653459c79b5308874efec813f17b7457c7f1 \ + --hash=sha256:8050e75af7e4c6e2d5260b84eeedb618f3e452e66473432e085b5d1b81429299 \ + --hash=sha256:81cdc537e0a42e3c0170752fcadbe450246d5c3b4b7231d6eb9672456605ac94 \ + --hash=sha256:827c92145b3b976b39430129c89d213b250dacbe5fce678e9a03940e6e848983 \ + --hash=sha256:854fd2f1bc6e8a56b89910b5cd7261a8b40f13ebb31572da985ce59c7da0886d \ + --hash=sha256:877ca17fdcfdf5c397493a71e5ff97a87bb181417fe717fdadc77c08c09301ac \ + --hash=sha256:87cc632c88ee5dc80e12681047839304d98ee5c9a708d686505767001c9b8b9a \ + --hash=sha256:8879510a76940670517ea1cb589978da44b86e286ec3e50d664ef330817afce7 \ + --hash=sha256:8885e3808aedbd6725b921fb67dacaae0678933561ddd47c2b01315198c70e2e \ + --hash=sha256:8b193bf7a443c97d81c47052f60c486071a4bdef4a573fa2514f920089414d45 \ + --hash=sha256:8ed78ccad4c7b421804f5d524b7740946a7ef75d89dc0dac52e9d7c24c472410 \ + --hash=sha256:8f2973bbd2bebd9d2e0cd6394c1292a1a19ccd56bdcbe1e174059f1a39be5b40 \ + --hash=sha256:910d4260512660484c0dc1588a316fbb35a40c081c36fc51d1225351af17cfe4 \ + --hash=sha256:95052e8777a86bae87c0bd0b5ab22d809e3d1d02bf69e3e66ddda5ba75a05805 \ + --hash=sha256:95f273bea318a194f656527ee2ed19494327bc500b8e87d9358e3222579aa28d \ + --hash=sha256:963a8d8f97057082679523d0fd4c53a38f86bc58cabe4556faef682ae53fa2fa \ + --hash=sha256:976fd7689d69ec78d67d31d38d396d8adb562f7e8368279f76aed4aa451fa06d \ + --hash=sha256:989261c5f1735a165f2e4e87cf6d5f17ab734fa18f9ad0383d5adfcdaefce701 \ + --hash=sha256:9ae9614c317c50836689ce2dfde07c05fe0b16378562e2221746c3913ede3c80 \ + --hash=sha256:9b0124b9c17e9890f0819b2e7a5f65ec9a2f5aabd6c8e7ad090c1675cf70dee6 \ + --hash=sha256:9c4880d017555d70dea367dd49271830842d48e3891c2da97da7ce8c4abcee40 \ + --hash=sha256:9c62e71e6289d78c0108d8aeb495f8bd3cad4bc1632fedddc9297ddf287ecc20 \ + --hash=sha256:9d0a21cf76153de8f2d96a877991d6bc59b9ab5180b949e50db73cc193b4a694 \ + --hash=sha256:9e14d17773b1b3c758ff153659a1824608a0cb562c45f484b5ed8a433428444a \ + --hash=sha256:a16a1dc8529f9e734a41c3b856f3eae7ebacdc061dde3f8a844e0c7889c97203 \ + --hash=sha256:a2212a0c842c723d919ea4a22a9296cb6b244b386e4e6ea92adfc7fbf3095519 \ + --hash=sha256:a313bad717dde740959d50850c315b75fd4eb0c5e6b4dc8535db0f1c369be125 \ + --hash=sha256:a32b78c1e52ebd8e247bb68300b90b233300d8816faa008ed0713bc539fb6af0 \ + --hash=sha256:a3ffe881246d28a862f1985824f484cb7361f44d6b99c4c620436ef56462f38d \ + --hash=sha256:a49ff5cdb33654cb7d6a3c377aa2a83ddefaa1db31eb10bcf3c180aa84f9af8a \ + --hash=sha256:a8b75dd3d3638d9a19f23e84af4ffab3b8940422c0df2da2a77005ef5aa3d7ea \ + --hash=sha256:ab64ace1a68682d191d9bedd9d4c939406ad86b1d9f628180410644249ad46c2 \ + --hash=sha256:abc7c2e4b47bfe6a9aea434d3fdebb9597ee636e914e92ba352f2068b9142f4c \ + --hash=sha256:ac348379cf4de5538a0a213be1532d289aa801ec5d267c5909446b9ea2f8e2c3 \ + --hash=sha256:ac51cd64bae51c462ea58ad2492c9b8209667a4ef60c45c4a304518b67598d5d \ + --hash=sha256:acddcac38adc8342ba48aba98896faa7928854bebb62542362138655b5367ee3 \ + --hash=sha256:ad4528cdce058b684f75fad1faf4a6a6c992fe2f08376370ca66e4ce5916a84a \ + --hash=sha256:aec65b53a07f580606593f877eefbb29a45939bfc0d3fe6e6d9f42b41b749f68 \ + --hash=sha256:aef74e9beabbd6c4aafc091dabff86d046ccf013ce1e4396c0fbb01b4cad9de8 \ + --hash=sha256:af1b5a92315048c3e36bbebfa7d4760a9c3e910bc4166f11b20d77d20d6bcfca \ + --hash=sha256:b22ff30006a2f28f8bff878fb93413cbe3a4d1fd517c28081d848c90e9cfd2c8 \ + --hash=sha256:b2483da477932ad1983d1d33c18bc3771c6fb00cfbaaed70a875fd547ef8e840 \ + --hash=sha256:b2f0adc22a4eb31e545221d93fc73a0f6a8cc2379d0f4309f71d1d17ba938b82 \ + --hash=sha256:b4c9e5d05b126b267ac048a89a0e2d9b48b1b648add62d4872906304a8590610 \ + --hash=sha256:b4da208a63434d21a3df64d29758e650fc4aa8cb05848554b76949c296539cca \ + --hash=sha256:b5f8771aaaed7f80e84a4e471d2f29ab6721e4595075e54d03ae1ed951b2000a \ + --hash=sha256:b65121091567847a8cb520d364ab22ba90e00d3cc55fa9eb34bb439f0684bcd1 \ + --hash=sha256:b66ccc5c2cdd26e74fa5d4c29ffae424cc6148bf93ce574821783fb3b6d452c5 \ + --hash=sha256:b69651732c64afb691e50cdc3387cae305e0eeff8804fe3e3ce203876494932a \ + --hash=sha256:b828cf64d62dc09ac183f03c1aeedd164ade96a2ce4934109452edf29de1dd13 \ + --hash=sha256:b8a65621b98984a62e59403009591b8a5a7736273aefe1cab64cfb85b365cc07 \ + --hash=sha256:bb0d664505f4b112f384cffeee82e91e3f6448d8e574989479db4439b68cba05 \ + --hash=sha256:bb58ba73a3f96f9a3e46b1fab69929d7edbbddb3133ee74b5c3c54074a53c4f1 \ + --hash=sha256:bc94a68ea5e18f8e85dc6b522bcb53093f692c8eb62b4837ac047da73956cbe4 \ + --hash=sha256:bd82c4977196681a499bb6ca9e462afbc5c91c1c15b6a991dfbd72733fea4dd2 \ + --hash=sha256:c0c88085affd35c33e124e36930c5ad96aff9294ce195eaa0fd9cec962b64a82 \ + --hash=sha256:c148e8b596000dd3e4bfe206e70f3e666be18d72032e0012555f2373c52e35d6 \ + --hash=sha256:c57541034d12b215ab0a2bfa371d1a8a198da18176d0426c27105b9161a6862d \ + --hash=sha256:c9648ed33dc8179e4ec04bbc73bd7f0038e1e81217a69467f61f02a78bf07e88 \ + --hash=sha256:ca65cced0d67a9039e93bcd98a369920e499bf98296844ff56ead01c9085321f \ + --hash=sha256:cf22b43f35b7dbb9f71e8ee2041b5c00029cdfc28ede3a2f31cf8906f9a6c126 \ + --hash=sha256:d35a4f1c63f07fbb8c8f9946dea98b21eddf6c57421585f71d91864be3ba2a24 \ + --hash=sha256:d3b6e6840421c83ccb60398e333b44f910b0907bb409597685ab2eedd1e22eab \ + --hash=sha256:d73fed4e37158ff00cd271871170b79e40138db51e625fd352fa17c6acb34f67 \ + --hash=sha256:d7bf9e43282d69561618e8a0ea33368d532ebef42f15c096f427090521dd74f3 \ + --hash=sha256:d9d6544790ba50438a9c1a903c3c4afb1ec8a7832db5518549275b40ef0dd4b1 \ + --hash=sha256:db4d697b18b6ef5528b1f36bfa25072cd2a421869f5963bc0e92c8a34b9e2800 \ + --hash=sha256:dd9a137a4a9becda3094f3831cd026380f75f6855e051eefe4c73ade524f1cc3 \ + --hash=sha256:de7738b8c0bb74c4cc16bbd7fb49fc2bcf6430dba11b3432cd52768ae40933e8 \ + --hash=sha256:e3b1aa25f01238886a6baed9e13b9a9240ed344346c79ae280ae65e8603b21d5 \ + --hash=sha256:e58952f04772f59f11c6e007471449809a30165188669bca8fdb19dde40a8f24 \ + --hash=sha256:e5ccad4b7bac125722f48d6f862bed3b514d8526deea06316bb72f152cd30a7c \ + --hash=sha256:e7386aa18d98d6b8af44b92173654ec469237fda35f8e8523e43b581a86f476a \ + --hash=sha256:e81ae656b9935ac4528a71f96bb7a14d949778ed1897c573d3e7ebb9187f8841 \ + --hash=sha256:e8f1e362c9352b50ed120f001046fdbb80810c9d56580f4c3fc13bbe30823387 \ + --hash=sha256:e96ca64383efa107262ee3949f047af5ee4f1845ba09463466c04d35a83bd3ec \ + --hash=sha256:ea999ae6e80e66ad5eea287860951b033d0104ca34d6d87c7b5125ebe0e12721 \ + --hash=sha256:ecc89dbd4155b2f8a47f4bbd89242a35ed15e8e0ec581cab5ac65fa38407329d \ + --hash=sha256:ef01d29fca550ab871fd99154f82c6472aacf8e7def272dfb07b460123850390 \ + --hash=sha256:f04551dce5a7db8c9659f2e4245494c182d0663b83661803e08d46bfcae5eda1 \ + --hash=sha256:f0700527dd5bfa8b7204b08330542f4f388899d3c14d885d8a368992e0eb562d \ + --hash=sha256:f2524ec55b3e65cbe235a8b3c36e2af3b635be02ed05e20c94e70c5c943c009e \ + --hash=sha256:f5844e7befc707367807586f550fa97e23dcfef0728f02b98c7bc498a961a5df \ + --hash=sha256:f9dad513626a33670f17cddc6078e30e311f444c957e8dbfc5b2b4603c8b4edb \ + --hash=sha256:fc0dcb22fa9aeabfe3fa4e0430099acff985ec5d77a851382f76cc6146e780e5 \ + --hash=sha256:fd882aa29bf402b62bf1fd7c19fd5df4b6528cf468a908864b39368572b662a9 + +opentelemetry-api==1.45.1 \ + --hash=sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75 \ + --hash=sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb + +packaging==26.3 \ + --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c + +propcache==0.5.4 \ + --hash=sha256:004e685b315646c410771836e72a44f143bbe624f29653a42687815069a303d5 \ + --hash=sha256:02c0a34f16889cf800f10f0247a564d8ce6eeab6ffcd7c87198f769067eb8432 \ + --hash=sha256:03969626faf0783a592dfa17e28eac06018bd0b44dafae6943d53b92421a7f72 \ + --hash=sha256:03b229037d25b801e7af53fd52b9fc49d9439b036fca1e087e02780631adfa97 \ + --hash=sha256:0951315a6b3142ee2167404d707743f0157c110091342b1aa0accac5cf0e4acf \ + --hash=sha256:0a095db8e15a6020db149ecbed6461939fe74f6acaa3ae8b702a1fe8c38cd983 \ + --hash=sha256:0c889f6fa84957bc7e8b4eab71fd16a0455068d5045e3aa40c733071d2b2fd77 \ + --hash=sha256:0d21d0d2c82bbfeb1677a9711f38df968f9837576102bb4add1bd449d28d88f1 \ + --hash=sha256:10ef33a68a61ce317e095fd2e202a592ea92392b90944a78c993f0d9a73ab06c \ + --hash=sha256:12682126712ddc19b70ff819debbd279e58adf1f0c8f8f8138c18ade2044b284 \ + --hash=sha256:135036c5cfc93864affb0f9af9a27e5d7a71cb7bd745e7b6dbfc2d56cc30e827 \ + --hash=sha256:13e52b6e0bde97dee98ab66552dbff2931649c96f1ac432eac299fe689ec373b \ + --hash=sha256:141fdbd73748db0cf7636035030aaac383d2efde8f34e7bc24594cc776d225b8 \ + --hash=sha256:146f48a9e4812611a7581003b1a39de56c34967046310c4171a68ef908c9a745 \ + --hash=sha256:174507f82d3594622acb1dd2dafecf2d899d6d506335494e7107767bf05f3aae \ + --hash=sha256:1783582065a1f07f9d9ee1e992e13f15d7dc8fb1eb3a7476d43eb3f2e69d26bb \ + --hash=sha256:17a7400cec0256f0a71ae71f9da398f9894c956ff6668a1c9d317b3367316320 \ + --hash=sha256:1b2f3bec4261a94019575481c726c29850f72e27907773c75b1de421e20e9f9d \ + --hash=sha256:1d759d05634f1b038fb625a66662a8c85e5a8fec912da381b5149ddac107482b \ + --hash=sha256:1df8d8561b21465c5dd56110a01caf897e026d065b4b84e98a488209094272ec \ + --hash=sha256:213bb68d9ced5cf2bf717b1071bf2b09b4b04c426256f9fe6d054c60318424c4 \ + --hash=sha256:23278f808cd81d5ada7184a76606b925fb3389c60e1077b2cd7da7b1fcf0553c \ + --hash=sha256:251c63dd46a0659bb875cb254dc4c1e79ee91a847c737cd62373295afc2235dc \ + --hash=sha256:279655a16973f1ee2bd2fe79973137681642fd9ae0d89215bba263726eb0dc3a \ + --hash=sha256:2814ecd8e818f487bee4b0f921bc4d1c176cc5fc71ac0f072d0fa67eda4ac14b \ + --hash=sha256:286867fb156488c251a3721766e380ac4495e4fd6b51aaa1403d89ce7f4359d9 \ + --hash=sha256:2dba2f02d2d5c09ef8a0e6c1a42aeaa451f4be9898cb00b04fe98717da2eb23b \ + --hash=sha256:30cc1cebaf9aef49db06357a50398323ae04d70460c0491837d026ab7d6452ea \ + --hash=sha256:31eb43ba2edc704ab2ec27815315dd8a19def0fb16215be4cfe8d32fe78ffd51 \ + --hash=sha256:350b272b2279f4135a64fc0c304a5d08e28a137c9573442c606152446638a831 \ + --hash=sha256:36c0d9db44b523ef93d03341b1c42d69ff01d673c053d1b1c6c3a363bcaa39ba \ + --hash=sha256:3af0c8642b2da4815d86e631232ac8286e17644fad907c19508aa8e7cb4ba8ad \ + --hash=sha256:3cd3a7edb6b95b9b33998135ebfa18d709da82290fb8f27c858970b5a12c8b56 \ + --hash=sha256:3d605bb239b796e82a81c6709548b2bd460ab73b4590cb0c83de8a2dd9694d0f \ + --hash=sha256:3e413d7a4a9b4866b7a761d6060d434b64d23cd35122eda3b026a0bbe8196b25 \ + --hash=sha256:3eb2e820e8e2101407da93f17c57cbb7d225461955fc60105daaba14cd421ee2 \ + --hash=sha256:3fa15757fea1dfcd5b7745cad9f4638929605531bd4018ab2adff7955f1a403d \ + --hash=sha256:3fc24f209c1b7f7f688b66b98293954f5504279760999b58920ee12dd8471c1d \ + --hash=sha256:4054acf80d40456a0537f2913b349718649d8d6458a14ab7f48d0ce28c30869d \ + --hash=sha256:40e94adb1e7d39ff28a8bd8d8b8fbd1df6b9f40976dbe379134f1ce058e532dd \ + --hash=sha256:420162a77f94eb1cf5ef7893f500016dabd548e73de956785a1dd899cc73006a \ + --hash=sha256:425f8cc86ab5018b4b8d4a23bc8e74d964bd3d757c3702e301aa79be76c53f6c \ + --hash=sha256:44149f46500a0a41b95b4d99c2e586a77319539730607b9892974a092788b111 \ + --hash=sha256:445ee3bfb46e85838387fb3c536a73cc0b994dc192b004e40e170adc54aa2a7e \ + --hash=sha256:45488d1a5f9ab5bd90aaa1ca20f50fe1922b8ffad71a2009d2adf41355897aac \ + --hash=sha256:45bebbe252550fec975ba3b62bc6f931643cfd3b5464ef47619cf3fef154e01c \ + --hash=sha256:45bf2e730ab8905d0527fe05a86500f406e64305c34cc81ebe64b4617cab9760 \ + --hash=sha256:48cb48c5346a97de792254af77715aa2529c2a1ebc5f586aa0aae44a02f1fe57 \ + --hash=sha256:4a1f4f5ffa55dce6307631f3cb2948e117e665966ea512e0d502b16c24f567e7 \ + --hash=sha256:4cfe0a92ae30151869e67a4b5f5e105e4e03ad30b3f38e5211b5bf77d0881993 \ + --hash=sha256:4d86476a935c88963d9b8e1a9a0d38188790e9622169bfbafa173046846709d3 \ + --hash=sha256:4e985382be6d15da8d0c2710a6fa7b9070fc9ecdeefb7f580e88373984ec8be3 \ + --hash=sha256:4f2d880ff60f45898f4acfa152aac8d04e3ee627d90ff4003491bf92239d5757 \ + --hash=sha256:4fbc1a15dc8cd1689508758d626b372b1f09d28d9577667feaf9e6bfcd8efcbc \ + --hash=sha256:50e337653721d20ead710da33bf44487fbe8a0db8782714b60306481e9f95b51 \ + --hash=sha256:53eaa697c4d0422ff4cb714d00231b43352064d97b944033b30c1d57cc506ec0 \ + --hash=sha256:56fc3f7599528db40b1efa0889a620116e2704144495273d66066e8164e45838 \ + --hash=sha256:58134228927cee6c047d626c08e60a81be604a20578a12ce752cc5c9a84d4826 \ + --hash=sha256:594eb4c6ec35e7179b058481f4e9f02521b56de16fa577c4b85c76fb1bf8a9f8 \ + --hash=sha256:5cacf3c9efd09df409dc33654dd077e1c245ba8fb747b0f0236ef41b7c49b589 \ + --hash=sha256:60a64cbccaa11b7760ce705a14ada17ba459e7ca9f23ba587eb013821032d7ef \ + --hash=sha256:62530ca89187827e4a4fe733f971abe81a7542eeea48ff61995f19b64d7199c8 \ + --hash=sha256:62c60aec739ed00124573cce1178138fd690c7676352d67a37328c1cf51d7468 \ + --hash=sha256:69fc35c0779522da366c563e5faf203ffc1f8ff0021d5b1337fa4efa5be73177 \ + --hash=sha256:6af4693716bfb03f1752ef1b30faa593db2c01d5272e9b8564a1549452a979ab \ + --hash=sha256:6c7599df2b57ebeea8de011b5f2f7b85de95e76037d43d34b95e328430275487 \ + --hash=sha256:6e9368e87a3efc285e559131092c5db643eb8e56de4ee42064d5baec22ef2bb5 \ + --hash=sha256:6f0093ac3e9daada202c2082439d414a625c57184727a46e112a3fb2a81cb788 \ + --hash=sha256:7177c43eddf10a0893c4fec52ebb408fdcd7f7d63962caace9180d8f81b14ece \ + --hash=sha256:720cf832eb2d0b0dfee129cb3335a26f6ce3cc45ee1187e8f0731758caa16792 \ + --hash=sha256:770e8209d018175fc0063936fa9583b6d27e88c5ad31543f3383d66080efdd62 \ + --hash=sha256:7a8d5ff04eb1f85698a78d20c62a14676e7b960dcafde09a388d60ad377d355d \ + --hash=sha256:7b9100a93b372418d8688f3f2a3e5b45c64d70ca4d6176e121aca1e3bfc1e32f \ + --hash=sha256:7cc528e760a8af06f2b13e9b9f362cd90c7c718ea61228a96dbd31ba16ed7f47 \ + --hash=sha256:7ffafcbfc7b549ab940047e505c831eabac5e67de53e1bc174adbc5285c55944 \ + --hash=sha256:87a3caecf8095e48dc72f84bfa42e23a848cf410cc9cc13031fba4869b706a21 \ + --hash=sha256:886b59c4d28ca97dd23b025fdfc50a0356be934efbbbca89ad26230067f86fe5 \ + --hash=sha256:8876b39961e33d912afe3c1bee18ee564fdad0206f873cc15d522756b7f50737 \ + --hash=sha256:897d1ddf6716e8f47200f7aad9a0efa6cc7586df66c6defa572f9eab379c078e \ + --hash=sha256:8a1fc236528c457cd739c88abe823da851b7ab645d72792f88658114cc340c12 \ + --hash=sha256:8a235f73d6e020855dc29dff012d920c02ee0feab8d73a24185a7569f4be1161 \ + --hash=sha256:8f911c395cef73c510bac566da9507bb6a43e7763d0c79138dc60ee53f11207e \ + --hash=sha256:96f7c5c15656040ddcbc51e56dc59b58aa25999d743c126abd425b9766ab43e9 \ + --hash=sha256:978f28401afbc76cdc3df9e1717b4229a06b626a1dcc75db4e1f2beb3884c3e9 \ + --hash=sha256:98914de2c4d7f0f9f4a8c6ea4bf05841f4175796941e3ef7d47eb718f22311fb \ + --hash=sha256:9a2a8a50a93dee0268a860a07fa3b4bd968f8ce4dbd794957da772f395368526 \ + --hash=sha256:9cbfff4423eef4cc6cafc021469641a2b835f610b2647a6c5281903e21b8670d \ + --hash=sha256:9e9ab13760aa8b6d0881ae7cb04fd891d8d490cd2554ea8e79bb278399169bcc \ + --hash=sha256:9f3551b8a35c1df3e7ea4d2d86edee15f0dde1bddd434a71744048683544d0ef \ + --hash=sha256:9f86f7259efe2c951f43e57d471c9b41daa5bfc7db9f67189059cf1ae6d77fd9 \ + --hash=sha256:9fb0a5be8d9aa213150e8d8148a42aca4984b285bcad1e69587dc4298edd929b \ + --hash=sha256:a219f0ac59817a9114dd2aa57c13180f993e819ba658c7ddab4b66ed1ee0d370 \ + --hash=sha256:a419ee85e654927baabda3929c03c0cc1112bf472ff0dfd6142f4e3a81ca4162 \ + --hash=sha256:a4d7a54719b67338a305dca2ce6aafe366817df94ddfd4b5514374356f5ca546 \ + --hash=sha256:a5793c7698a53f56f4a1889a4737c7eeb1b7ad0842fa6b1abca22913ff79c8c1 \ + --hash=sha256:a5e8ef588c109725dc713ba69aadcac00a1ef90c2ce9c0a8c7075128f569f47f \ + --hash=sha256:a74bfa37147cc08fb29df10bd9c16f40fa7f860cd3a6d2fff853323a94f6e17f \ + --hash=sha256:ada748108a43d29b7c328ba7db3755327cd94f028bcc1a7ee3f0addcfacd9c38 \ + --hash=sha256:ae58f361bd5dae942717c65d3413b478c70aea9c462599e7b9adad3731db3894 \ + --hash=sha256:b28f41fa3b8c6900457f858ec5b03998f3a6d535fbc1bb2edec5961ea05ec429 \ + --hash=sha256:b3083bfe87f95c756e610bd8025f26cbd1cd4aaa03a422f2d65efb7a97cd53d8 \ + --hash=sha256:b61805357d966680acf68b3b6d49772631ed9df44ebece10ff1460e117a7da8a \ + --hash=sha256:b77c313314524ca9c38fbd70f73515d04597ac58c40c939bc0e71eeb4abff680 \ + --hash=sha256:bee7d3aed13d56f54e681df38c3a23031bc9e3863f687d9d598825c9146acd7d \ + --hash=sha256:c02c0e570c5c7e077b0181a9f3cdb7d4c3617d1cda6b5c95bd5d34022923d82c \ + --hash=sha256:c174bfd1c48a1b51a3078e95586dde718374bac79719ab3541ec9e74aec40574 \ + --hash=sha256:c2ba30a89035b57b73e00475de948521602f543d79ce01db10b04b36c4c76fc8 \ + --hash=sha256:c3e98c55bde2bcf7db3c70d1aed7ae9aa8aebbf19a250c66645cde44cdb8b867 \ + --hash=sha256:c3ef2818d63bc86071e9d2989ae75a1bc32b8f7059cfd9f5abbbee70c32e2ed6 \ + --hash=sha256:c83acbce9f2b5e3f5f5eda9e53d2001fed22fcdfef81274a9e02d8fd53b70a30 \ + --hash=sha256:c9281e922c072158c91974d4589f1dbe0fee6d467f284c28e463f9f5a4d933f4 \ + --hash=sha256:cc07876cfb079b6f6f36d21ce75784ad6c2c6b563eeac0ed26c2fa2669b85df9 \ + --hash=sha256:ccf4f7a79e26bb7efb06ecd50c177833b71df05cbc748701372325e6bcc17f6f \ + --hash=sha256:cdee8205a44d0be91bbac4c41b95d86641b72dfc7aef1279400e4fda3f26a937 \ + --hash=sha256:ceb3e879afac028f93d272c957814695dc5569e4904262dbee92f6c41bd5e4a3 \ + --hash=sha256:d1f5a500bfcbb2c0ab85e98a0dcd70f5899d34efe365a0187700369a79603031 \ + --hash=sha256:d42a9a856a4a6e2f6c10f1318c07e7daa498d6593abe745c71dae4521a26ca39 \ + --hash=sha256:d83b12902eb8bce151259c86c03ba746600b2d994543de46e370cecf96c452f2 \ + --hash=sha256:d8e017eeb7482bed34cdb0d61cf2bcfc88d104bbab296a17cd16a6af8aabc70e \ + --hash=sha256:db3ae52ccc150dbc84704e9d642743897f3e1c54742ff34cacb661e52e3818a9 \ + --hash=sha256:dbab5f5ff6897c81f355d079010cdae85b02e5a0b518b5251523b8ad8ae9ac3c \ + --hash=sha256:dc4242ca653c9b30ab51c5f8193323e7bc0928f897ee9103201e59a43abcb72e \ + --hash=sha256:dcbf346a318a5e30063f547630b02bb787ce2f45b6368d5da143660b6a3835d8 \ + --hash=sha256:dd2ac8f5b643454c2cc6b6118b13da16e88f4a6434fc3ba61aca384029f04f36 \ + --hash=sha256:e1d52a05dc417279f7e5c7618c5dfbbc29923aaf9bc0a5c1802ddcebf54c61a0 \ + --hash=sha256:e6720ba44ad7e72174314d0e1fb0172494cff5c73a3a8a2159c3d2402ff15565 \ + --hash=sha256:e738ab81179510ce79b2eac9a6ecf47feffd9e76d1c72e403005dddb6e36c06c \ + --hash=sha256:e904d4d01f36bd6e197590be1533c44e06058771e0746dd073a8ebb3ef880858 \ + --hash=sha256:e9f165403b81fea7e89c932d89046a1e3d9a3a60e8d7ef2f249dccdcb0982bf5 \ + --hash=sha256:ec6a85f424afa8d23e0d9a094e5dbb6eda01da91c92b9183cd433768247ffc97 \ + --hash=sha256:ee19113bce2f3acd46432050688b70f61acd6857d75abb9ec96341b7e9ced123 \ + --hash=sha256:ef3b928d9c984322b5c44e6964d8dbc653da87d2d8ee1647fa6da43072e650a9 \ + --hash=sha256:f273dcf7149a50527c4fd1f55cfe9eac0f60753f5af544b4c9352578e20c0874 \ + --hash=sha256:f5470694918830da62fac9e69133b53d23b736d7070e587b27a4a2be37e08e68 \ + --hash=sha256:f574e460d1c8a08384a016fdb09ccf3543433263ed6b2f97104f979e64ea57c2 \ + --hash=sha256:f85915e00dcb1cd9f2f890ead064ed40a27df06f0db65be427b29482ae357572 \ + --hash=sha256:fc2461ecc45f17893f8207e73b46ea8ba93e33630e51cf4af3fbc21d47462b1a \ + --hash=sha256:ff6b113f50bc066a698db5d944d2c6dc7507168dd3341e255a8892fd0715a558 + +psycopg2-binary==2.9.9 \ + --hash=sha256:03ef7df18daf2c4c07e2695e8cfd5ee7f748a1d54d802330985a78d2a5a6dca9 \ + --hash=sha256:0a602ea5aff39bb9fac6308e9c9d82b9a35c2bf288e184a816002c9fae930b77 \ + --hash=sha256:0c009475ee389757e6e34611d75f6e4f05f0cf5ebb76c6037508318e1a1e0d7e \ + --hash=sha256:0ef4854e82c09e84cc63084a9e4ccd6d9b154f1dbdd283efb92ecd0b5e2b8c84 \ + --hash=sha256:1236ed0952fbd919c100bc839eaa4a39ebc397ed1c08a97fc45fee2a595aa1b3 \ + --hash=sha256:143072318f793f53819048fdfe30c321890af0c3ec7cb1dfc9cc87aa88241de2 \ + --hash=sha256:15208be1c50b99203fe88d15695f22a5bed95ab3f84354c494bcb1d08557df67 \ + --hash=sha256:1873aade94b74715be2246321c8650cabf5a0d098a95bab81145ffffa4c13876 \ + --hash=sha256:18d0ef97766055fec15b5de2c06dd8e7654705ce3e5e5eed3b6651a1d2a9a152 \ + --hash=sha256:1ea665f8ce695bcc37a90ee52de7a7980be5161375d42a0b6c6abedbf0d81f0f \ + --hash=sha256:2293b001e319ab0d869d660a704942c9e2cce19745262a8aba2115ef41a0a42a \ + --hash=sha256:246b123cc54bb5361588acc54218c8c9fb73068bf227a4a531d8ed56fa3ca7d6 \ + --hash=sha256:275ff571376626195ab95a746e6a04c7df8ea34638b99fc11160de91f2fef503 \ + --hash=sha256:281309265596e388ef483250db3640e5f414168c5a67e9c665cafce9492eda2f \ + --hash=sha256:2d423c8d8a3c82d08fe8af900ad5b613ce3632a1249fd6a223941d0735fce493 \ + --hash=sha256:2e5afae772c00980525f6d6ecf7cbca55676296b580c0e6abb407f15f3706996 \ + --hash=sha256:30dcc86377618a4c8f3b72418df92e77be4254d8f89f14b8e8f57d6d43603c0f \ + --hash=sha256:31a34c508c003a4347d389a9e6fcc2307cc2150eb516462a7a17512130de109e \ + --hash=sha256:323ba25b92454adb36fa425dc5cf6f8f19f78948cbad2e7bc6cdf7b0d7982e59 \ + --hash=sha256:34eccd14566f8fe14b2b95bb13b11572f7c7d5c36da61caf414d23b91fcc5d94 \ + --hash=sha256:3a58c98a7e9c021f357348867f537017057c2ed7f77337fd914d0bedb35dace7 \ + --hash=sha256:3f78fd71c4f43a13d342be74ebbc0666fe1f555b8837eb113cb7416856c79682 \ + --hash=sha256:4154ad09dac630a0f13f37b583eae260c6aa885d67dfbccb5b02c33f31a6d420 \ + --hash=sha256:420f9bbf47a02616e8554e825208cb947969451978dceb77f95ad09c37791dae \ + --hash=sha256:4686818798f9194d03c9129a4d9a702d9e113a89cb03bffe08c6cf799e053291 \ + --hash=sha256:57fede879f08d23c85140a360c6a77709113efd1c993923c59fde17aa27599fe \ + --hash=sha256:60989127da422b74a04345096c10d416c2b41bd7bf2a380eb541059e4e999980 \ + --hash=sha256:64cf30263844fa208851ebb13b0732ce674d8ec6a0c86a4e160495d299ba3c93 \ + --hash=sha256:68fc1f1ba168724771e38bee37d940d2865cb0f562380a1fb1ffb428b75cb692 \ + --hash=sha256:6e6f98446430fdf41bd36d4faa6cb409f5140c1c2cf58ce0bbdaf16af7d3f119 \ + --hash=sha256:729177eaf0aefca0994ce4cffe96ad3c75e377c7b6f4efa59ebf003b6d398716 \ + --hash=sha256:72dffbd8b4194858d0941062a9766f8297e8868e1dd07a7b36212aaa90f49472 \ + --hash=sha256:75723c3c0fbbf34350b46a3199eb50638ab22a0228f93fb472ef4d9becc2382b \ + --hash=sha256:77853062a2c45be16fd6b8d6de2a99278ee1d985a7bd8b103e97e41c034006d2 \ + --hash=sha256:78151aa3ec21dccd5cdef6c74c3e73386dcdfaf19bced944169697d7ac7482fc \ + --hash=sha256:7f01846810177d829c7692f1f5ada8096762d9172af1b1a28d4ab5b77c923c1c \ + --hash=sha256:804d99b24ad523a1fe18cc707bf741670332f7c7412e9d49cb5eab67e886b9b5 \ + --hash=sha256:81ff62668af011f9a48787564ab7eded4e9fb17a4a6a74af5ffa6a457400d2ab \ + --hash=sha256:8359bf4791968c5a78c56103702000105501adb557f3cf772b2c207284273984 \ + --hash=sha256:83791a65b51ad6ee6cf0845634859d69a038ea9b03d7b26e703f94c7e93dbcf9 \ + --hash=sha256:8532fd6e6e2dc57bcb3bc90b079c60de896d2128c5d9d6f24a63875a95a088cf \ + --hash=sha256:876801744b0dee379e4e3c38b76fc89f88834bb15bf92ee07d94acd06ec890a0 \ + --hash=sha256:8dbf6d1bc73f1d04ec1734bae3b4fb0ee3cb2a493d35ede9badbeb901fb40f6f \ + --hash=sha256:8f8544b092a29a6ddd72f3556a9fcf249ec412e10ad28be6a0c0d948924f2212 \ + --hash=sha256:911dda9c487075abd54e644ccdf5e5c16773470a6a5d3826fda76699410066fb \ + --hash=sha256:977646e05232579d2e7b9c59e21dbe5261f403a88417f6a6512e70d3f8a046be \ + --hash=sha256:9dba73be7305b399924709b91682299794887cbbd88e38226ed9f6712eabee90 \ + --hash=sha256:a148c5d507bb9b4f2030a2025c545fccb0e1ef317393eaba42e7eabd28eb6041 \ + --hash=sha256:a6cdcc3ede532f4a4b96000b6362099591ab4a3e913d70bcbac2b56c872446f7 \ + --hash=sha256:ac05fb791acf5e1a3e39402641827780fe44d27e72567a000412c648a85ba860 \ + --hash=sha256:b0605eaed3eb239e87df0d5e3c6489daae3f7388d455d0c0b4df899519c6a38d \ + --hash=sha256:b58b4710c7f4161b5e9dcbe73bb7c62d65670a87df7bcce9e1faaad43e715245 \ + --hash=sha256:b6356793b84728d9d50ead16ab43c187673831e9d4019013f1402c41b1db9b27 \ + --hash=sha256:b76bedd166805480ab069612119ea636f5ab8f8771e640ae103e05a4aae3e417 \ + --hash=sha256:bc7bb56d04601d443f24094e9e31ae6deec9ccb23581f75343feebaf30423359 \ + --hash=sha256:c2470da5418b76232f02a2fcd2229537bb2d5a7096674ce61859c3229f2eb202 \ + --hash=sha256:c332c8d69fb64979ebf76613c66b985414927a40f8defa16cf1bc028b7b0a7b0 \ + --hash=sha256:c6af2a6d4b7ee9615cbb162b0738f6e1fd1f5c3eda7e5da17861eacf4c717ea7 \ + --hash=sha256:c77e3d1862452565875eb31bdb45ac62502feabbd53429fdc39a1cc341d681ba \ + --hash=sha256:ca08decd2697fdea0aea364b370b1249d47336aec935f87b8bbfd7da5b2ee9c1 \ + --hash=sha256:ca49a8119c6cbd77375ae303b0cfd8c11f011abbbd64601167ecca18a87e7cdd \ + --hash=sha256:cb16c65dcb648d0a43a2521f2f0a2300f40639f6f8c1ecbc662141e4e3e1ee07 \ + --hash=sha256:d2997c458c690ec2bc6b0b7ecbafd02b029b7b4283078d3b32a852a7ce3ddd98 \ + --hash=sha256:d3f82c171b4ccd83bbaf35aa05e44e690113bd4f3b7b6cc54d2219b132f3ae55 \ + --hash=sha256:dc4926288b2a3e9fd7b50dc6a1909a13bbdadfc67d93f3374d984e56f885579d \ + --hash=sha256:ead20f7913a9c1e894aebe47cccf9dc834e1618b7aa96155d2091a626e59c972 \ + --hash=sha256:ebdc36bea43063116f0486869652cb2ed7032dbc59fbcb4445c4862b5c1ecf7f \ + --hash=sha256:ed1184ab8f113e8d660ce49a56390ca181f2981066acc27cf637d5c1e10ce46e \ + --hash=sha256:ee825e70b1a209475622f7f7b776785bd68f34af6e7a46e2e42f27b659b5bc26 \ + --hash=sha256:f7ae5d65ccfbebdfa761585228eb4d0df3a8b15cfb53bd953e713e09fbb12957 \ + --hash=sha256:f7fc5a5acafb7d6ccca13bfa8c90f8c51f13d8fb87d95656d3950f0158d3ce53 \ + --hash=sha256:f9b5571d33660d5009a8b3c25dc1db560206e2d2f89d3df1cb32d72c0d117d52 + +pycparser==3.0 \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 + +pycryptodomex==3.24.0 \ + --hash=sha256:0428f19f13452c6b89bbaf2c530f84f369873811dfa77f0cee0da4f40fb0474f \ + --hash=sha256:0afd3f4cb2bd690cbf2b24e5d330e883cd5d8dcf3370aafc2aed16f676fff91e \ + --hash=sha256:3b6276fe007464502df796710fb57dcfeb813b6ccac0eabaf85100be1ad358f2 \ + --hash=sha256:408fb76e0af11937381532c992d9f7c0dcf987878cbe61d772b0e788343e81c5 \ + --hash=sha256:42c8221e3a4ca04b070cb388fca004815f6544951bfb2cc6c7958e293b7caf64 \ + --hash=sha256:556e063b52f338bd924fe5c40692cc1f50cb0453bd1d6580b7ffff2eade1a894 \ + --hash=sha256:55f22d72250d7923aac3c2442ec6a1dd8774d2efa3bdcb0f68d5e137d372268e \ + --hash=sha256:582efdf767d72c12538949b876a64b9a7af30f2cff063c06dd63ece32c4e5c1d \ + --hash=sha256:5be79564e05ed620a63f353ea50e0d474eb598f70cda1b28c2ca8e9e2f02c139 \ + --hash=sha256:5fa46af1281be707e0ec5878aea0f77d58d8d4b465f63f7385a71ae0080bfff9 \ + --hash=sha256:6a62122fb4c85befcc01ec3a1ae5b2ba81689481aba1d383b4e21e34f18900dd \ + --hash=sha256:72742921e388856044163e711643244b6d5c7062846dc0676b1f9f0743846ab3 \ + --hash=sha256:770db9a5b9d30d8a48486bc163870763f3fe0f983c780403cd15c332618fa51c \ + --hash=sha256:78bb214b8016aade97a357071d753a5c11687bce2ba693da48218c3472ece287 \ + --hash=sha256:793b9f5ba7e9147a6a6d8e284854e3711e12f43d4ef57a26b3415b2455a9801a \ + --hash=sha256:7ffcedf4210d8c206fe9852ccde9f78ec9ce35e8039f0a989900ffe710e7a5db \ + --hash=sha256:8293a92e6f71d6de57364432ff0ba48244959a81c632574eb5586e7e67f5de59 \ + --hash=sha256:88d37b28b450c06a03bd5570467c601fe8c7dbe493ca39771716f56fdbe35d6e \ + --hash=sha256:8a2a634c86c0a59b28b0d51bbf7e5d1feed00dc7ebdc789fe968f771f1430b47 \ + --hash=sha256:8d6c5e01e4fb1ddd20cc24d4d8706643dd78b5279453dabcf10ba4e0683bf9b2 \ + --hash=sha256:908f92fbc307b642b65580d0295698f423b057018d87ae43d441f1d1a00674a2 \ + --hash=sha256:96b60c0f36ab7c11a826c116633b08afeac58b9b9c066a5ad7cd373ed996363c \ + --hash=sha256:99c19dcd9846d6bf9886eb758c6d8e06c5bfd8a0c83802551f1aa56e80fe13dc \ + --hash=sha256:abf768c285d289c4a0e0678f055663162c9d6257c79f5904fbd2692ade94da1c \ + --hash=sha256:b4f7f9c737ca1af9a79962d3289eaa6521869915ee9fbb9168e578277126691b \ + --hash=sha256:b5d0afc29fc461d599379f814f5ba378e58c69418588034619d76747bc2b5d5d \ + --hash=sha256:b6690bfae043c81108e28e31dc7e3afa8cb1b1eb0ba590fdbff2b66e4c6913e7 \ + --hash=sha256:bb393480b5845ef4acbc9f713e4c473ad08be81bc3cdb5ef017cd02bdafd36e1 \ + --hash=sha256:c2f42f24d7112ebb93d3aed581d011c82c20101da7d7a6248145c530e99bd2eb \ + --hash=sha256:c55e6c42f9eb43d90c15792bf69bc09cb3e13a5e55a30eecb555695a02b0d4c0 \ + --hash=sha256:c7055400f773856cd219680d15525c6fb147237534cbe5394a5f92f57f7efaed \ + --hash=sha256:c7b8ad51baf3505dd3d94ec974855359d15769e45c58c2b89c6d4612b1f7ed94 \ + --hash=sha256:da9996407d03684221a4e1603b70fad97d8772185e5f75addcd97fcbc4c3281f \ + --hash=sha256:dc685ab0b420597fe9a3187555c14d5b4d87c1518a99646397018457dc2a70c8 \ + --hash=sha256:e42a2e9531c40315290278d9eee93ea5031eda3c83969f5709ada51a1c7491b4 \ + --hash=sha256:e635c7ac056f2a47d79e2bf7042fb3fc6d175f47796a954388a3cec1f2e082a4 \ + --hash=sha256:e8a7463d346015b42a0fb9c9179ebb7cccfd86bf40199c6796b546ac7be0b85c \ + --hash=sha256:eb14a078429434b017f1fb26a75a8fcd62c12befb6b3d37ca39353dc2b5843fd \ + --hash=sha256:ef2bd306328a8447d4d2af6037efddd67ef16630876ed3ec84babd16ca558941 \ + --hash=sha256:f6af77f15a91bb6c16607067cfd201330cf5dfefe1c715e955be1fb06d368613 \ + --hash=sha256:f96e10f0edff9d4b6b253e0a78f7c819749521d3ffe6af0139e08fcd35384405 \ + --hash=sha256:fab40542a863cc142a763aa92630d613925fe3e937bea3ba5fb735e0c42d4f96 \ + --hash=sha256:ff8c1a125b2a7a2933219e9e523aabca31c920839c3c10968b56ec9a76c81d1e + +pydantic==2.13.5 \ + --hash=sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73 \ + --hash=sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08 + +pydantic-core==2.46.5 \ + --hash=sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942 \ + --hash=sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821 \ + --hash=sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5 \ + --hash=sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c \ + --hash=sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184 \ + --hash=sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2 \ + --hash=sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc \ + --hash=sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5 \ + --hash=sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0 \ + --hash=sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931 \ + --hash=sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e \ + --hash=sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25 \ + --hash=sha256:1e5aad1220a1192c42341c8fd4a8686657e73ab2a920c970bdc4de334fe3193d \ + --hash=sha256:200aa3dc9f8d54f0754f43247c0bad0999fdcfbfd2488384dd44f37279271fe6 \ + --hash=sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47 \ + --hash=sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038 \ + --hash=sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8 \ + --hash=sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b \ + --hash=sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a \ + --hash=sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e \ + --hash=sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074 \ + --hash=sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0 \ + --hash=sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433 \ + --hash=sha256:356c8368cbc321050b169595683a2e1d63413b1e0e2868b330af9fc14c616d3f \ + --hash=sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034 \ + --hash=sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21 \ + --hash=sha256:3a3e26b6a8274211bddee2d0e4d0d42778f17a34510f49d2ec44b58abfc41736 \ + --hash=sha256:3aa166e99c4f2985407fb8714aebede877ecb5455cf321b606adca926d30d5a0 \ + --hash=sha256:3d2652072b2d774947ba5cf78a9e59644ac62ee572daf6dd2e1dfe905e15b2b7 \ + --hash=sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c \ + --hash=sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4 \ + --hash=sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c \ + --hash=sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c \ + --hash=sha256:4d44cf99ddebf875f9b68cc267aa684c99b7b44fe63ee1cac4ec163807290069 \ + --hash=sha256:4dedce55295becb61921e386b99d4f2706045306e7fa52249a33004c837379fb \ + --hash=sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061 \ + --hash=sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29 \ + --hash=sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3 \ + --hash=sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa \ + --hash=sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e \ + --hash=sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38 \ + --hash=sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f \ + --hash=sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b \ + --hash=sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8 \ + --hash=sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e \ + --hash=sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c \ + --hash=sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be \ + --hash=sha256:657b40d6240c0a7b6a64b30f22d1e3aa631c7e846c621b0c0f6d1d75e2e15ea6 \ + --hash=sha256:6d30e1a4f138b8951063e9a394752a9179b51da288ffa507b1e659222f4c1793 \ + --hash=sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1 \ + --hash=sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b \ + --hash=sha256:771cf63ae0b1b50dd22e5f3e3549fab5f3f4ff1635d352a9e1a97fe01c7b2e64 \ + --hash=sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f \ + --hash=sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761 \ + --hash=sha256:7b0fc826b16c55e561e5d2a0c5c77b051ba1d92808118c4e4b5390f5e0cf191d \ + --hash=sha256:7c6be839a5a8312626b32029a415644a0846b420bc8b52b95b28cd92da162168 \ + --hash=sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869 \ + --hash=sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111 \ + --hash=sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5 \ + --hash=sha256:850a08d167dde16db8702c274f320c7be9d7da6f6dff2b58b18f9e815bd94f5b \ + --hash=sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7 \ + --hash=sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129 \ + --hash=sha256:895395f8918627b04efb1ad2a4cf605387143300ba03304cd1dfa6d03f5e095e \ + --hash=sha256:8b10e3e8fd7ddc2bd915848a2768e44c15b22936f1cc54c462ad1164deb02655 \ + --hash=sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c \ + --hash=sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec \ + --hash=sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689 \ + --hash=sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f \ + --hash=sha256:978e7b97d4824b5be09c69fb70507cbde3b0323fc147332ca40a94d9a6a0ebbf \ + --hash=sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea \ + --hash=sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9 \ + --hash=sha256:9b68938dd5b0c783d88ff8e2dcc69451b5eb936fe212d516b21b9d5567f6d464 \ + --hash=sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519 \ + --hash=sha256:9f47b8a949e60f027f0aa0a6f6c7b7e9c55cbf4380d10b344e282fa4e7ab1e1b \ + --hash=sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f \ + --hash=sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee \ + --hash=sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a \ + --hash=sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e \ + --hash=sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6 \ + --hash=sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2 \ + --hash=sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f \ + --hash=sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a \ + --hash=sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f \ + --hash=sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a \ + --hash=sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47 \ + --hash=sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda \ + --hash=sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0 \ + --hash=sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4 \ + --hash=sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d \ + --hash=sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3 \ + --hash=sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2 \ + --hash=sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355 \ + --hash=sha256:c3471e5c4a949c26ec00a77f01df59096aa9495877de76fd60a980f8ee6be461 \ + --hash=sha256:c583b927a8838dab890706a6fa7573fbb8b70e24000ef9f7238e2d6f6435a5ed \ + --hash=sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f \ + --hash=sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5 \ + --hash=sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2 \ + --hash=sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829 \ + --hash=sha256:cdc8b74ecc48c0cb1e9607a05ec4e9e88db60a19ffcc9a1d5f9088ede40c8dc0 \ + --hash=sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266 \ + --hash=sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575 \ + --hash=sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290 \ + --hash=sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f \ + --hash=sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62 \ + --hash=sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f \ + --hash=sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a \ + --hash=sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7 \ + --hash=sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed \ + --hash=sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f \ + --hash=sha256:eb7d8d0e5886a89a55d2eef490e272fa965a9d57c6b29a5b5088a7997ec2cad1 \ + --hash=sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615 \ + --hash=sha256:ef3fbbf161dc9351a2fe0422e51b129f9e97e42385bd0320b309c15f7d287dd8 \ + --hash=sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3 \ + --hash=sha256:f077d0b97ab11fa7dcc633fca53515f290bca8a8a633e966d5b6d1879d9ed01a \ + --hash=sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff \ + --hash=sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084 \ + --hash=sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0 \ + --hash=sha256:fc5d783bd4a2387e97b8a2d5ec781cfb92b3d893bf82370548e99db5915935d3 \ + --hash=sha256:fc8515076c11f3cfdf4fb142dcca0fe384b1230a3b5415458ac84f3e0903ec13 \ + --hash=sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9 + +pyopenssl==26.4.0 \ + --hash=sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7 \ + --hash=sha256:f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c + +python-alipay-sdk==3.4.0 \ + --hash=sha256:e1bbe2f026cb331500878bfc32ff007b533d0fbc64ce73dd1ff1671bf2d9d01a + +python-multipart==0.0.32 \ + --hash=sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e \ + --hash=sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23 + +pyyaml==6.0.3 \ + --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ + --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ + --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ + --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ + --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ + --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ + --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ + --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ + --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ + --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ + --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ + --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ + --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ + --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ + --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ + --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ + --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ + --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ + --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ + --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ + --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ + --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ + --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ + --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ + --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ + --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ + --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ + --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ + --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ + --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ + --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ + --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ + --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ + --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ + --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ + --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ + --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ + --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ + --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ + --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ + --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ + --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ + --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ + --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ + --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ + --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ + --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ + --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ + --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ + --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ + --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ + --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ + --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ + --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ + --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ + --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ + --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ + --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ + --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ + --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ + --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ + --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ + --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ + --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ + --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ + --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ + --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ + --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ + --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ + --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ + --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ + --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ + --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 + +requests==2.34.2 \ + --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ + --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed + +sniffio==1.3.1 \ + --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ + --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc + +sqlalchemy==2.0.23 \ + --hash=sha256:0666031df46b9badba9bed00092a1ffa3aa063a5e68fa244acd9f08070e936d3 \ + --hash=sha256:0a8c6aa506893e25a04233bc721c6b6cf844bafd7250535abb56cb6cc1368884 \ + --hash=sha256:0e680527245895aba86afbd5bef6c316831c02aa988d1aad83c47ffe92655e74 \ + --hash=sha256:14aebfe28b99f24f8a4c1346c48bc3d63705b1f919a24c27471136d2f219f02d \ + --hash=sha256:1e018aba8363adb0599e745af245306cb8c46b9ad0a6fc0a86745b6ff7d940fc \ + --hash=sha256:227135ef1e48165f37590b8bfc44ed7ff4c074bf04dc8d6f8e7f1c14a94aa6ca \ + --hash=sha256:31952bbc527d633b9479f5f81e8b9dfada00b91d6baba021a869095f1a97006d \ + --hash=sha256:3e983fa42164577d073778d06d2cc5d020322425a509a08119bdcee70ad856bf \ + --hash=sha256:42d0b0290a8fb0165ea2c2781ae66e95cca6e27a2fbe1016ff8db3112ac1e846 \ + --hash=sha256:42ede90148b73fe4ab4a089f3126b2cfae8cfefc955c8174d697bb46210c8306 \ + --hash=sha256:4895a63e2c271ffc7a81ea424b94060f7b3b03b4ea0cd58ab5bb676ed02f4221 \ + --hash=sha256:4af79c06825e2836de21439cb2a6ce22b2ca129bad74f359bddd173f39582bf5 \ + --hash=sha256:5f94aeb99f43729960638e7468d4688f6efccb837a858b34574e01143cf11f89 \ + --hash=sha256:616fe7bcff0a05098f64b4478b78ec2dfa03225c23734d83d6c169eb41a93e55 \ + --hash=sha256:62d9e964870ea5ade4bc870ac4004c456efe75fb50404c03c5fd61f8bc669a72 \ + --hash=sha256:638c2c0b6b4661a4fd264f6fb804eccd392745c5887f9317feb64bb7cb03b3ea \ + --hash=sha256:63bfc3acc970776036f6d1d0e65faa7473be9f3135d37a463c5eba5efcdb24c8 \ + --hash=sha256:6463aa765cf02b9247e38b35853923edbf2f6fd1963df88706bc1d02410a5577 \ + --hash=sha256:64ac935a90bc479fee77f9463f298943b0e60005fe5de2aa654d9cdef46c54df \ + --hash=sha256:683ef58ca8eea4747737a1c35c11372ffeb84578d3aab8f3e10b1d13d66f2bc4 \ + --hash=sha256:75eefe09e98043cff2fb8af9796e20747ae870c903dc61d41b0c2e55128f958d \ + --hash=sha256:787af80107fb691934a01889ca8f82a44adedbf5ef3d6ad7d0f0b9ac557e0c34 \ + --hash=sha256:7c424983ab447dab126c39d3ce3be5bee95700783204a72549c3dceffe0fc8f4 \ + --hash=sha256:7e0dc9031baa46ad0dd5a269cb7a92a73284d1309228be1d5935dac8fb3cae24 \ + --hash=sha256:87a3d6b53c39cd173990de2f5f4b83431d534a74f0e2f88bd16eabb5667e65c6 \ + --hash=sha256:89a01238fcb9a8af118eaad3ffcc5dedaacbd429dc6fdc43fe430d3a941ff965 \ + --hash=sha256:9585b646ffb048c0250acc7dad92536591ffe35dba624bb8fd9b471e25212a35 \ + --hash=sha256:964971b52daab357d2c0875825e36584d58f536e920f2968df8d581054eada4b \ + --hash=sha256:967c0b71156f793e6662dd839da54f884631755275ed71f1539c95bbada9aaab \ + --hash=sha256:9ca922f305d67605668e93991aaf2c12239c78207bca3b891cd51a4515c72e22 \ + --hash=sha256:a86cb7063e2c9fb8e774f77fbf8475516d270a3e989da55fa05d08089d77f8c4 \ + --hash=sha256:aeb397de65a0a62f14c257f36a726945a7f7bb60253462e8602d9b97b5cbe204 \ + --hash=sha256:b41f5d65b54cdf4934ecede2f41b9c60c9f785620416e8e6c48349ab18643855 \ + --hash=sha256:bd45a5b6c68357578263d74daab6ff9439517f87da63442d244f9f23df56138d \ + --hash=sha256:c14eba45983d2f48f7546bb32b47937ee2cafae353646295f0e99f35b14286ab \ + --hash=sha256:c1bda93cbbe4aa2aa0aa8655c5aeda505cd219ff3e8da91d1d329e143e4aff69 \ + --hash=sha256:c4722f3bc3c1c2fcc3702dbe0016ba31148dd6efcd2a2fd33c1b4897c6a19693 \ + --hash=sha256:c80c38bd2ea35b97cbf7c21aeb129dcbebbf344ee01a7141016ab7b851464f8e \ + --hash=sha256:cabafc7837b6cec61c0e1e5c6d14ef250b675fa9c3060ed8a7e38653bd732ff8 \ + --hash=sha256:cc1d21576f958c42d9aec68eba5c1a7d715e5fc07825a629015fe8e3b0657fb0 \ + --hash=sha256:d0f7fb0c7527c41fa6fcae2be537ac137f636a41b4c5a4c58914541e2f436b45 \ + --hash=sha256:d4041ad05b35f1f4da481f6b811b4af2f29e83af253bf37c3c4582b2c68934ab \ + --hash=sha256:d5578e6863eeb998980c212a39106ea139bdc0b3f73291b96e27c929c90cd8e1 \ + --hash=sha256:e3b5036aa326dc2df50cba3c958e29b291a80f604b1afa4c8ce73e78e1c9f01d \ + --hash=sha256:e599a51acf3cc4d31d1a0cf248d8f8d863b6386d2b6782c5074427ebb7803bda \ + --hash=sha256:f3420d00d2cb42432c1d0e44540ae83185ccbbc67a6054dcc8ab5387add6620b \ + --hash=sha256:f48ed89dd11c3c586f45e9eec1e437b355b3b6f6884ea4a4c3111a3358fd0c18 \ + --hash=sha256:f508ba8f89e0a5ecdfd3761f82dda2a3d7b678a626967608f4273e0dba8f07ac \ + --hash=sha256:fd54601ef9cc455a0c61e5245f690c8a3ad67ddb03d3b91c361d076def0b4c60 + +starlette==1.7.0 \ + --hash=sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e \ + --hash=sha256:c79f74ea63cff761804fbbfb182f1e0b440c2d07b164d24700c5a1bab5d6ff5d + +tqdm==4.70.1 \ + --hash=sha256:c293e525e6fef9c20e8728fd4612df02a0aa31bb5fe91ecd93e123b1b7bffa73 \ + --hash=sha256:cefd0eca11b2a37a3aee776544d4f4ae913f02688135b5556b8788dfa474afc4 + +typing-extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 + +typing-inspection==0.4.4 \ + --hash=sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47 \ + --hash=sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147 + +urllib3==2.8.0 \ + --hash=sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3 \ + --hash=sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63 + +uvicorn==0.24.0 \ + --hash=sha256:368d5d81520a51be96431845169c225d771c9dd22a58613e1a181e6c4512ac33 \ + --hash=sha256:3d19f13dfd2c2af1bfe34dd0f7155118ce689425fdf931177abe832ca44b8a04 + +yarl==1.25.1 \ + --hash=sha256:0136d640dfa9b0523853e411430a99f8a91eca85774c6420285a33b755bc6de3 \ + --hash=sha256:03dd38de09bc213e9a8b29761eec33ee1d5318dac0e49d8af36e4d27830e23a7 \ + --hash=sha256:0a191bfdb30a79b98e5d175d75285f9fcb78bf0e46ba5efda042e1c72071a0de \ + --hash=sha256:0a66db89ea473abeac4b70523cafd94db3772380e565f9d28af7a179b7af71fa \ + --hash=sha256:0ae12ff2b805fa02c4dab838005caef735e39986322698c48588d3beacb65c62 \ + --hash=sha256:0f12afda4eea8c8994a76d4df1875c765194f5fbe8a9d197929ea303caee29ec \ + --hash=sha256:10b2fd95332f0d716d5eee3c9fb2ce8eada19082de7fee83d32e37992fd75c26 \ + --hash=sha256:126a2533570c554719ca40a1288fdee1700b6bc82e7131aa69fa85252d92e651 \ + --hash=sha256:12b6bc4906e11f5e1a1cdcb12296e7afbd366c783cc8073403cd2fb74334e453 \ + --hash=sha256:142c06c4d6a35ee3ec5da08499805e879cb3ca7c1fbfbecb0140fe72403818d6 \ + --hash=sha256:14b79a30a93a3ce2e8832603fd0ab780ada281b0ba5110b519a634f2d7d7d1fc \ + --hash=sha256:17c9877a89fb6e2bca6f9087eb24cd7fb434653946ef5075e470d23d49b52287 \ + --hash=sha256:192a866877a49993949ef1975864ad8728bea28ee810f6abe1a0729c2b500426 \ + --hash=sha256:1ab7618921a93767387a4b83776f751588f5b5ae9bb5bc96620e2e2e00bca868 \ + --hash=sha256:1e80dcf1446e1b080b1932b0d103c464a04112f5bc31f0f983ad418172063cde \ + --hash=sha256:1f51020b2eb8a003c84925638ec63c21a750a4bddd3a22ec8eac6a742dadf1b9 \ + --hash=sha256:1fb2a01ba8cd9c5d2c5dc1ec35e0fc951d04b4f037541d4ac090c993ce58b3d7 \ + --hash=sha256:2239a02249d9326655419e0168a28ca9008938eaab31dc29fc875c217927a6c0 \ + --hash=sha256:23bf5b403c879a54964e0feac7285688e04bb220074878d737d331522da0a5bf \ + --hash=sha256:24ce942011a61953e7d313438038f4d32ff21387b775f58a957f7a07dd55ef95 \ + --hash=sha256:25868beca8b6765f8f7d0e11fe6dd7c66dd4b0793b9500286d20cc92352126a5 \ + --hash=sha256:287e99ff5aa4dc1c7630bfc683ded6f106d756c99dec432a2d7f197a784f51c6 \ + --hash=sha256:29273edf1530e397bd07cb784db1fbe0d2590b77569f2e24679a9c0a2d763b94 \ + --hash=sha256:2b49375d22299b0a834c2bca72f39aaecc270d96fb24c30424899676f487b22a \ + --hash=sha256:30eec96e8a91bd588ce897c9543f6d5d8d34b28fbcba28a4dedf20ebeae9fe57 \ + --hash=sha256:319e070a01db9920fb63761843f96a104c8e2b9427266731810dc1e22595b17c \ + --hash=sha256:35dcbea443fafb3eece757ad4e514560ddeb6c34cfae1582c620d7b293d7feee \ + --hash=sha256:3f4d48a6112712973e676bd792121fee470e432d749177162d9949d5c9460a1b \ + --hash=sha256:3feb99222553a8cbedfa52c2f59dd84c3f50d5b582c728d522caf8d72769a54b \ + --hash=sha256:419f392a1da624877975709e3864dfe833af6cc7671b39318086d456e288380c \ + --hash=sha256:42a66563d8cc056ee32e6191e05097a7b2b3bc302e0bc3133daf8710eb18bd26 \ + --hash=sha256:48796ea00a303961507dc6c8437c4b325a6fc3f95f7c36c71b91ea9a8150963c \ + --hash=sha256:4bd6340d20ae2c7ca719b87b426e808e90743b676d05d4c26c4fb5ca71f41184 \ + --hash=sha256:4ca89e4e21854ed27ec753297dde84b16c9f8e53b14a4866fb44457d643c19f8 \ + --hash=sha256:4d781294bb815ecb5ea57ff6bbf8038e0a31a95fdf3e1788f66e0dc100d64b58 \ + --hash=sha256:4f1c91f5a5980a937ff8e238e98e6897e1ad74a4b1e2c0d68c73b5ffbb3f5c0b \ + --hash=sha256:564fdc7085d2245ab84f88882fdb1d6ac0723124bff6ded35bfb1c00f812630d \ + --hash=sha256:59ba3a6e1aa8cfe5adf4bd270fd965db21955401b7ca6f1696010c55ed4daec2 \ + --hash=sha256:5df89f769cc8ff94c3d7e7603386fba309d25ce5240132d26c15baa8d0e96c4c \ + --hash=sha256:632da579b2d879f6bad20f2cfa35ded1efe2f4f77f8abb26a6234a5b236acd2f \ + --hash=sha256:65b5b2066651b7432d389e9799d979c703bcc6ef44266bb8153ef54e91e4aab3 \ + --hash=sha256:664ec6a520b74a1df2810666eb67695fcb77fa663e6ea0a25aaf2e529cb24dfa \ + --hash=sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3 \ + --hash=sha256:683e362b8ba453080f7489c66f4ea794e751c35b72e7eab3575ef784c2fbc7fb \ + --hash=sha256:68782fdb4027b8d1eee25ec35e9a6db05e863b899eb0310b3a33b6c3fef55707 \ + --hash=sha256:6efaf45df6a849cef613a03a94c845647456662f85438c886bb67a9c027c8c2c \ + --hash=sha256:71f42c5b9a948c113bbdebfa544598321431d064ff959d32e99b1feb61d68345 \ + --hash=sha256:72849d892954be4d09e569b8b831ac39ce58417fedc767d4308a0fe542018a40 \ + --hash=sha256:72c34ac7ad4314c19362d5ce27626dcc8429bd30bbf8c179f4234078851f9492 \ + --hash=sha256:734f6e5400352ac4254456003d462866c684703570929cff7a7bde015d0cb371 \ + --hash=sha256:75baa6cf9b6d1c52f3e111a130e202fd8cf0a5b3a066c3f73d615e885092e4ec \ + --hash=sha256:77716e245c90f058466a05e6a465bb8600f767a8f4b18b4d40f3aff958e5f73c \ + --hash=sha256:77e5099b99b37f3cf79c246998ca9f7313a78054cd1809ec46bc1afad47e1c4c \ + --hash=sha256:783dd1467083f4d3f7722ad6a313f24c173e7571372738fcb7a6e6d1ba48df25 \ + --hash=sha256:7a5c3115595995779ee21f2567035793911c3802a43c74f3fbb0314929ec67ac \ + --hash=sha256:7c88edaec8c349ad4c5ad4c486a3defcc4b80ceb2f074436ffa0a87caf5e76a6 \ + --hash=sha256:7cb414a73e21a7ab58254926073f2930cb22f5b4314ea4260a687e2b3fd4dce3 \ + --hash=sha256:7d42e7e3ca399555578b4d617e3a6ecf13371b3743a115995fa010c7bf341459 \ + --hash=sha256:7d575b54cb3863ef9bc290ea4b009999d55dc237326131e4853cf33e888fee03 \ + --hash=sha256:7e4de3ac4adbad3d0bc7c6f4360a7dbff5de2f15e3b723be3198074e17fd9c40 \ + --hash=sha256:7efc9f082dfed77c316edffa9deb52888e1bc6789171887cc1f68e06d65465c8 \ + --hash=sha256:80a063f8297fc796296f00f100be520f209b23dc98f93ce8eba6ee7122598209 \ + --hash=sha256:80e47012e730da131c9f059c80936783f9659aae22dc31c03c0595590d11ed54 \ + --hash=sha256:83d4a37e4b95da4d8bda930d6d35b75b4cdadbacbb4980cae290ea3100b5d51d \ + --hash=sha256:83e9f4a25085bd4b7214701a0794ff1f50fc633ffb8bdfebf07abdd81c2db126 \ + --hash=sha256:85a18376073f8a39aa07be34f9fc77e2869aa72c55c441efdd2cf79a0407504d \ + --hash=sha256:87796fedc3ba97ec14fab55acb48584276e6c1e4c1e89c422bda62c838e754a9 \ + --hash=sha256:882569ff613758cac762a457a5d72d6e211b28d4bcfea89d1d71ea942b02eac0 \ + --hash=sha256:8ce4d6ccafb33d39bd78444612d14938ead674c25702ded2ee9c54a47735d225 \ + --hash=sha256:8e7d98cdbb6d71e726f7d525952867096053d1f290dd4e3c50d7d313a136f414 \ + --hash=sha256:8ee202350cf57abf0e9502a41601841019c25d3db7ff52d980aaf31446254059 \ + --hash=sha256:8fb0eb4955adf0579001581f2f71a126e8781ba61bcd120f127b0401163c6c2d \ + --hash=sha256:90c30ed53546da833c700115c0064c22120d1b1560f474699fd31f22dd668233 \ + --hash=sha256:9489e6abf47ba37f332075a91444c7cfedb03e6ce99fbb2f116bfe1ce810da3b \ + --hash=sha256:94d7aa6debf92a1dd14cb5280b083a764169a13cfb23a452111160274ed989f4 \ + --hash=sha256:98d370568f393215d605304cdb77b3d5539bd192c75b623c7304c42c8d6d8273 \ + --hash=sha256:9b1bdaae98bc016825dd3c9d8ee1832f829b3341f9cc6ebd1a1b0a7fef7367cc \ + --hash=sha256:9d693bf4bf534e9ba3ae2780cfd577f5135629f7b5ac653490859d0b77864865 \ + --hash=sha256:9d6ed3d17bccce4c05343e1ca8da13bc5c02c812a4e7282ddd05e8769322d3fc \ + --hash=sha256:9e23c82b63cd7652fc24d33ed6cc17099d607aa3b4fc4ddc75e95062f3d82df4 \ + --hash=sha256:a1daf47cd95a7c3a63456336bc5aaa8c86dd3a47d07ed3d0e76132ae4666a5a1 \ + --hash=sha256:a1e32763e641a1566507d90a8d3b19bfc3cc04a9d4e5ae3e32189874ed4b58a3 \ + --hash=sha256:a2059a2d891bd156bc5184e7ab7a56e78a84dfcfdeac8c501b552533ad1c36ee \ + --hash=sha256:a2ed0ba415ccdf08f14bf544cb78346d0f76086707ffee24921a2c84dbf1305a \ + --hash=sha256:a3faadac7d812ddac258feb57b9846b60c1b437c4f4b9ad42595c6f6fe4390df \ + --hash=sha256:a5877f2255aab518ebe528289037699201d5dc5f045f2396cb30aa02db22f57f \ + --hash=sha256:a78b50b4f7918a3de71105d5c0b93bbc57bb8339a4d03a9dfd449f9068e76f3d \ + --hash=sha256:a8c2b841478068440d8b733005d13a5ef535b9928cbc05f17182d410f32ba449 \ + --hash=sha256:a9ca696eb02e5c02a8afd872ada510eba9b7fe6e68b9572c2e9a9b1941e31e2e \ + --hash=sha256:aa4ed3dd308548f9e707d9caaf005d2d7f8c1e7868f858dfeb47fe76e16b391d \ + --hash=sha256:ab2054c5531af2a9ba7b69b8ec91e4f884420e83a8c5e579b013084cb57e5e5d \ + --hash=sha256:abb1384477f5901d436b5d2e5465954de46ea6098f59163d243660b5c4461d35 \ + --hash=sha256:acae6b45d1ace09b6ba3876da43b88366ef368f73b988c7f57e14231753d4420 \ + --hash=sha256:acfa7e22aa6c6e7a5996a41d275bfa01efa7ea56ab890590280e9063e2cf5c1b \ + --hash=sha256:af4ea5b37403ef4e30f3927eaed540db942bde01d8d3ff083527c0704d1c9c68 \ + --hash=sha256:b10dd0557ba422715b5206b3743192135a6022acca8baec51aa127d0a75db8fe \ + --hash=sha256:b13b88747769537f3d32e89e3a735da10c0a9e35d7322928c701b5f93d3afffd \ + --hash=sha256:b51c159a9794633f5e0db7ecec7b2b6e3734eca1f5d17dc989ff3552a43ff78b \ + --hash=sha256:b5402a340723fa7da00b5cff987ddab61276be6d11251ea71ae02bcac54890d8 \ + --hash=sha256:b7abffdf37af1cec6a2ad69b827aa84320db5894791bc8ed932dc93fb274b7e9 \ + --hash=sha256:b8075fe90bc08e40b8b8a1874fab42ee4c7b56af05c5886e9cc841397f916908 \ + --hash=sha256:bc3ac7bf569f6b64dad04dd7808c7872dae8a97df657856eac05e9b7e3614a85 \ + --hash=sha256:bd0912757081f89b107d6c00b2ff8a194401b0b87eadcf4481de2b865a8fd44f \ + --hash=sha256:bdc8d8b8c22e9e43ac68316b5e6cf083dec537f4ec213cb4aa967b583bc3fa64 \ + --hash=sha256:be80550d9bfe83d9b62398a37081a90434e6df2d978ec345c3d2820de6beddab \ + --hash=sha256:c6f117789d22dce188e5754e8bc65b7e6ebf8cb73963b9fa761f672a5883769d \ + --hash=sha256:ca32926d7d77bcc8838425c4c95e040a3ace1cb7dfdae599013458dcda2607ca \ + --hash=sha256:cce0727fd5ac04d372fa9bbfde9febc2bcf209aadfcf0468e45dec72719895d1 \ + --hash=sha256:d0f1489233a254bb3643d2f05de7d59019254d81daeca6b9162fe9edef57e0c7 \ + --hash=sha256:d1c557dfd5e3db046053a0bdc72261ade790ebe8e2c7a41b36b0ca1f14cb95f3 \ + --hash=sha256:d21f0fa80a02d05299207eeaafef345d812ace96d5306e4ef265e1d419a615fa \ + --hash=sha256:d45673badd08456d0340e9364eddafe1c53a9d2896424294de4d7dd71ad3ee57 \ + --hash=sha256:d5add7b4ca7afeea91d52e4d4e4db3b1fe9885b71f07054560d8c4296b7441a2 \ + --hash=sha256:d5f90e44653c4e0f78501ed9bb7d3fce835a8d62b7c6ed0cb16557534087e743 \ + --hash=sha256:d7306dee25b8a0e737363f347362b875094b4dc4e367311470656ae420fdbf8e \ + --hash=sha256:dbcef5a9119ef653653132cccaf999b30a0af6f33bb0a4ba80bec30056868487 \ + --hash=sha256:def538065f9e4d4cf1ae164bd59aba00dfa84f03923e0de4c3788f252d6bcd17 \ + --hash=sha256:df23df54b5114a17c2d0ef192433e2e5a9f0c5178c32375e90b7cfc965f349d0 \ + --hash=sha256:dfbf531053a0935f2e871bcd4753f90313688772ff8c017f5ea402e315a78c1f \ + --hash=sha256:e029648f9c951db30e98a7d7ec90835db88ec4b32820efe2a9bdc2287e032eb6 \ + --hash=sha256:e07595c7d6f4db270ceede356a1bd1c07a34f1c26f958d1ed0cd7b48e0d2bba3 \ + --hash=sha256:e12c538e00e7c1b286a07061046b90e8124e6a9793efae2c70db6a4aad07faad \ + --hash=sha256:e546fe1d4a93ebc2910f0d768baff19faa09843ab3f2036a67ed6e69fae4419d \ + --hash=sha256:e5637ca8d0bd7fb72648a6c7934af4baaccb697657f7438c9d264fc2abb8b0b1 \ + --hash=sha256:e636b64d24fd9c38053c5e389a1174c66361fa49dcfd220f4dd35b4abde7cb89 \ + --hash=sha256:e7011b8fb8c4054bf0c12e5edc6cd83778b0028e99ce59b18586ed036f92cfdc \ + --hash=sha256:e80f557716fd765439577131e526b8942ffc2c07bdbc5e39fa62f660ba1e963f \ + --hash=sha256:e92b6bcc741b86d67606c40d3cb9c7cc8e6c737f81e31f4a94efc204456c92e3 \ + --hash=sha256:eb96ed1ae6c7d072d60840c0434aef07a2df611812810807fbc54263a6053e9a \ + --hash=sha256:eda19ea5ee88742f47a2340816e6f2d40b53bed3ab5b69794769f36af9f35bb4 \ + --hash=sha256:ef74070ac553c59eb4f04258722066d6c6135b7baa03b2e9f2da65c096e96d98 \ + --hash=sha256:efb01a106f971cb3752856bca2318bbdf7f01bd8823779c461586cbe5ffd5258 \ + --hash=sha256:f074e8d4aa0a5798920ddb6de3d08b228c614ff3724c3e8bd7577f4bafea867b \ + --hash=sha256:f38a70074041d3b7e138e452799f5174198bae5bd5ab2000917badf403908c5f \ + --hash=sha256:f41753a76f4f63927d03a0d8ba8f5ce0f2083bec29a8cfaccc55371b1564b96b \ + --hash=sha256:f53dcd26694f148f738edc052b5a69234833e739f10f4c3287bdfd8ec0f7b326 \ + --hash=sha256:f61964f235a43738bfac50da46fc4254943a7eea3051aeb0b6fc7c992c29fadc \ + --hash=sha256:fe01645169a2112aa1d4ebc3e4c5f029c5c8f97adfc32e5d37c993b39a994d75 diff --git a/requirements.txt b/requirements.txt index 5ecad38e851de6f00ecc901da8660a97b1db8773..9d45727e390c1cc1ad49fda3b49d6faebede842f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,19 +1,23 @@ -# 🔒 P0安全修复:依赖版本锁定,防止供应链攻击和不兼容更新 -fastapi==0.104.1 +# 直接依赖入口;生产部署以requirements.lock固定完整依赖并校验SHA256 +fastapi==0.142.2 uvicorn==0.24.0 -pydantic==2.5.2 +pydantic==2.13.5 huggingface_hub==0.19.4 # 🧹 P2清理:datasets/aiofiles 全库零引用,已移除 -python-multipart==0.0.6 +python-multipart==0.0.32 alibabacloud_dysmsapi20170525==2.0.24 # 🧹 P2修复:verify_code_engine.py 直接导入 alibabacloud_tea_openapi,显式声明避免隐式依赖漂移 alibabacloud_tea_openapi==0.3.8 sqlalchemy==2.0.23 psycopg2-binary==2.9.9 httpx==0.25.2 -# 🚀 P2优化:速率限制 -slowapi==0.1.9 +# HTTPX 0.25.2 使用的连接后端直接依赖,供下载代理绑定已校验 DNS 地址到 TCP 连接 +httpcore==1.0.9 # 🔒 P0安全增强:bcrypt密码哈希 bcrypt==4.1.2 # 🔧 P0修复:升级支付宝SDK,打破旧版死锁,自动适配最新安全加密库 -python-alipay-sdk>=3.3.0 \ No newline at end of file +python-alipay-sdk==3.4.0 + +# 表单解析与异步资源管理的安全修复版本,与FastAPI组合验证 +starlette==1.7.0 +anyio==4.15.1 diff --git a/resource_deletion.py b/resource_deletion.py new file mode 100644 index 0000000000000000000000000000000000000000..f7010b2156e3d91e7e04b1d642dac781bf78f18d --- /dev/null +++ b/resource_deletion.py @@ -0,0 +1,208 @@ +"""资源删除的本地恢复记录与关联清理;不访问或修改资金数据。""" +import asyncio +import copy +import datetime +import logging +import re +import threading +import uuid +from resource_store import ResourceStoreError, _records + +DELETION_FILE = 'resource_deletions.json' +resource_mutation_lock = threading.RLock() +logger = logging.getLogger(__name__) + + +def _queue(source): + if not isinstance(source, dict): + raise ValueError('invalid resource deletion source') + for item_id, entry in source.items(): + if (not isinstance(item_id, str) or not item_id.strip() or not isinstance(entry, dict) + or entry.get('item_id') != item_id or type(entry.get('schema')) is not int or entry['schema'] != 1 + or not isinstance(entry.get('author'), str) or not entry['author'].strip() + or not isinstance(entry.get('ticket'), str) or not re.fullmatch(r'[0-9a-f]{32}', entry['ticket']) + or entry.get('state') not in ('pending', 'complete')): + raise ValueError('invalid resource deletion record') + return source + + +def _read_queue(store): + return _queue(store.load_data(DELETION_FILE, default_data={}, strict=True, skip_cache=True)) + + +def _read_items(store): + return _records(store.load_data('items.json', default_data=[], strict=True, skip_cache=True)) + + +def assert_id_not_deleted(store, item_id): + if item_id in _read_queue(store): + raise ResourceStoreError(409, '资源编号已用于删除,不能重复使用') + + +def assert_comment_target_active(store, item_id): + if item_id not in _read_queue(store): + return + if not any(item['id'] == item_id for item in _read_items(store)): + raise ResourceStoreError(404, '资源已删除,不能添加评论') + + +def _authorized(author, account, can_moderate): + if not isinstance(author, str) or not author.strip(): + raise ValueError('invalid resource author') + if author != account and can_moderate is not True: + raise ResourceStoreError(403, '无权删除他人发布的内容') + + +def _prepare(store, item): + receipt = dict(schema=1, item_id=item['id'], author=item['author'], ticket=uuid.uuid4().hex, + state='pending', created_at=datetime.datetime.now(datetime.timezone.utc).isoformat()) + expected = None + def update(source): + nonlocal expected + queue = _queue(source) + old = queue.get(item['id']) + if old: + if old['author'] != item['author']: + raise ValueError('ambiguous deletion owner') + if old['state'] == 'pending': + expected = copy.deepcopy(old) + return copy.deepcopy(old) + queue[item['id']] = copy.deepcopy(receipt) + expected = copy.deepcopy(receipt) + return copy.deepcopy(receipt) + try: + return store.atomic_update(DELETION_FILE, update, default_data={}) + except Exception: + confirmed = _read_queue(store).get(item['id']) + if expected and confirmed == expected and confirmed['state'] == 'pending': + return copy.deepcopy(confirmed) + raise + + +def _drop_bucket(store, filename, item_id): + source = store.load_data(filename, default_data={}, strict=True, skip_cache=True) + if not isinstance(source, dict): + raise ValueError('invalid associated resource source') + if item_id not in source: + return False + def update(current): + if not isinstance(current, dict): + raise ValueError('invalid associated resource source') + return current.pop(item_id, None) is not None + store.atomic_update(filename, update, default_data={}) + return True + + +def _cleanup(store, receipt): + """调用者持协调锁;严格确认主资源已不存在才清理本ID关联数据。""" + item_id = receipt['item_id'] + if any(item['id'] == item_id for item in _read_items(store)): + return False, False + current = _read_queue(store).get(item_id) + if not current or current['ticket'] != receipt['ticket']: + return False, False + changed = _drop_bucket(store, 'comments.json', item_id) + changed = _drop_bucket(store, 'versions.json', item_id) or changed + if current['state'] == 'complete': + return True, changed + def acknowledge(source): + queue = _queue(source) + entry = queue.get(item_id) + if not entry or entry['ticket'] != receipt['ticket']: + return False + entry['state'] = 'complete' + entry.setdefault('completed_at', datetime.datetime.now(datetime.timezone.utc).isoformat()) + return True + acknowledged = store.atomic_update(DELETION_FILE, acknowledge, default_data={}) + return bool(acknowledged), bool(acknowledged) or changed + + +def delete_resource(store, item_id, account, can_moderate=False): + try: + with resource_mutation_lock: + source = _read_items(store) + item = next((item for item in source if item['id'] == item_id), None) + if item is None: + receipt = _read_queue(store).get(item_id) + if receipt is None: + raise ResourceStoreError(404, '找不到该内容记录') + _authorized(receipt['author'], account, can_moderate) + else: + _authorized(item.get('author'), account, can_moderate) + receipt = _prepare(store, item) + attempted = False + def remove(current): + nonlocal attempted + records = _records(current) + index = next((index for index, value in enumerate(records) if value['id'] == item_id), None) + if index is not None: + _authorized(records[index].get('author'), account, can_moderate) + if records[index]['author'] != receipt['author']: + raise ValueError('changed resource author') + records.pop(index) + attempted = True + try: + store.atomic_update('items.json', remove, default_data=[]) + except ResourceStoreError: + raise + except Exception: + if not attempted or any(item['id'] == item_id for item in _read_items(store)): + raise + logger.warning('资源主删除本地已核实,后续同步暂未确认') + # 清理失败不能把已经核实的主删除误报为失败;保留恢复记录继续重试。 + try: + complete, _ = _cleanup(store, receipt) + except Exception as error: + logger.warning('资源删除关联清理待重试: error_type=%s', type(error).__name__) + complete = False + return dict(cleanup_pending=not complete) + except ResourceStoreError: + raise + except Exception: + raise ResourceStoreError(503, '资源删除暂时无法核实,请稍后核对') from None + + +def repair_resource_deletions(store, cursor=0, limit=50, should_stop=lambda: False): + queue = _read_queue(store) + ids = list(queue) + if not ids: + return 0, 0 + start = cursor % len(ids) + selected = (ids[start:] + ids[:start])[:limit] + changed = 0 + processed = 0 + for item_id in selected: + if should_stop(): + break + try: + with resource_mutation_lock: + _, updated = _cleanup(store, queue[item_id]) + changed += bool(updated) + except Exception as error: + logger.warning('资源删除恢复失败: error_type=%s', type(error).__name__) + processed += 1 + return (start + processed) % len(ids), changed + + +async def resource_deletion_worker(store, stop, invalidate=None, interval=30): + initialized = False + cursor = 0 + while not stop.is_set(): + try: + if not initialized: + await asyncio.to_thread(store.atomic_update, DELETION_FILE, _queue, default_data={}) + initialized = True + cursor, changed = await asyncio.to_thread(repair_resource_deletions, store, cursor, 50, stop.is_set) + if changed and invalidate: + try: + invalidate('items:') + except Exception: + logger.warning('资源删除恢复后的排序失效失败') + except Exception as error: + logger.warning('资源删除恢复轮次失败: error_type=%s', type(error).__name__) + if stop.is_set(): + break + try: + await asyncio.wait_for(stop.wait(), timeout=interval) + except asyncio.TimeoutError: + pass diff --git a/resource_probe_service.py b/resource_probe_service.py new file mode 100644 index 0000000000000000000000000000000000000000..f73faee5bbc3de67c0977b6737763c1b53c62543 --- /dev/null +++ b/resource_probe_service.py @@ -0,0 +1,93 @@ +"""资源可用性预检:可信资源记录决定来源;不替代真实下载所有权核查。""" +import logging +import os +import time +import urllib.error +import urllib.parse +import urllib.request +import uuid +from fastapi.responses import JSONResponse +from github_source import repository_parts +from proxy_access import read_proxy_item, ProxyAccessError +from workflow_source import authorized_workflow_path, WorkflowSourceError + +logger = logging.getLogger("ComfyUI-Ranking") + + +def probe_resource(item_id, *, store, download, open_url, token_selector, error_response): + # 购买前只探测资源可用性;真实下载仍须通过独立所有权授权。 + try: + item = read_proxy_item(store, item_id) + except ProxyAccessError as error: + return JSONResponse(content={"error": error.message}, status_code=error.status_code) + target_url = item.get("link") + if not isinstance(target_url, str): + return JSONResponse(content={"error": "无效的下载链接"}, status_code=400) + try: + parsed = urllib.parse.urlsplit(target_url) + valid_origin = parsed.scheme == "https" and not parsed.username and not parsed.password and parsed.port in (None, 443) + except ValueError: + valid_origin = False + if not valid_origin: + return JSONResponse(content={"error": "无效的下载链接"}, status_code=400) + if (parsed.hostname == "zhiwei666-comfyui-ranking-api.hf.space" + and parsed.path == "/api/image_proxy"): + return JSONResponse(content={"error": "工作流下载不使用公开媒体地址,请重新上传"}, status_code=400) + if parsed.hostname not in ("github.com", "huggingface.co"): + return JSONResponse(content={"error": "无效的下载链接"}, status_code=400) + try: + if target_url.startswith("https://github.com/"): + owner, repo = repository_parts(target_url) + active_token = token_selector(item) + headers = {"User-Agent": "ComfyUI-Ranking-SaaS"} + if active_token: + headers["Authorization"] = f"Bearer {active_token}" + api_url = f"https://api.github.com/repos/{owner}/{repo}" + + req = urllib.request.Request(api_url, headers=headers) + with open_url(req, timeout=10) as response: + if response.status != 200: + return JSONResponse(content={"error": "资源仓库不可访问,可能已被作者删除或设为私有"}, status_code=404) + return {"status": "success", "message": "资源有效"} + + elif target_url.startswith("https://huggingface.co/datasets/"): + hf_token = os.environ.get("HF_TOKEN") + if not hf_token: + return JSONResponse(content={"error": "资源源站暂时无法访问"}, status_code=503) + expected_prefix = f"https://huggingface.co/datasets/{store.DATASET_REPO_ID}/resolve/main/" + if not target_url.startswith(expected_prefix) or parsed.query or parsed.fragment: + return JSONResponse(content={"error": "无效的下载链接"}, status_code=400) + repo_path_encoded = target_url[len(expected_prefix):] + repo_path = urllib.parse.unquote(repo_path_encoded) + if not repo_path or any(part in ("", ".", "..") for part in repo_path.split("/")) or "\\" in repo_path: + return JSONResponse(content={"error": "无效的下载链接"}, status_code=400) + + authorized_workflow_path(store, item, target_url) + cached_file_path = download( + repo_id=store.DATASET_REPO_ID, + repo_type="dataset", + filename=repo_path, + token=hf_token + ) + if not os.path.exists(cached_file_path): + return JSONResponse(content={"error": "云端文件不存在,可能已被作者删除"}, status_code=404) + + return {"status": "success", "message": "资源有效"} + + except WorkflowSourceError as error: + return JSONResponse(content={"error": error.message}, status_code=error.status_code) + except urllib.error.HTTPError as e: + return JSONResponse(content={"error": f"资源探测失败,源站返回: {e.code}。请联系作者处理。"}, status_code=400) + except Exception as e: + error_id = f"ERR_{int(time.time())}_{uuid.uuid4().hex[:8]}" + upstream_status = getattr(getattr(e, "response", None), "status_code", None) + if not isinstance(upstream_status, int): + upstream_status = getattr(e, "code", None) + status_text = f" status={upstream_status}" if isinstance(upstream_status, int) and 100 <= upstream_status <= 599 else "" + logger.warning(f"RESOURCE_PROBE_FAIL | error_id={error_id} | error_type={type(e).__name__}{status_text}") + return JSONResponse( + status_code=502, + content=error_response(502, "资源源站暂时无法访问,请稍后重试", "upstream_error", error_id=error_id), + ) + + return JSONResponse(content={"error": "无效的下载链接"}, status_code=400) diff --git a/resource_publication.py b/resource_publication.py new file mode 100644 index 0000000000000000000000000000000000000000..acdf1c02921ac019350ce56aa26c931fa5962987 --- /dev/null +++ b/resource_publication.py @@ -0,0 +1,188 @@ +"""持久发布身份先入SQL;JSON失败可恢复,同次重试不制造新资源。""" +import asyncio +import copy +import hashlib +import json +import logging +import re +from sqlalchemy.orm import Session +from models_sql import ResourcePublication +from resource_store import ResourceStoreError, _records + + +def _serialize(value): + return json.dumps(value, sort_keys=True, ensure_ascii=True, allow_nan=False) + + +CREATE_FIELDS = frozenset(('type', 'title', 'shortDesc', 'fullDesc', 'link', 'coverUrl', 'imageUrls', + 'author', 'price', 'github_token', 'netdisk_password', 'is_netdisk', 'is_original', 'allow_refund')) + + +def _request_receipt(request): + if not isinstance(request, dict) or set(request) - CREATE_FIELDS or not {'author', 'price', 'type'} <= set(request): + raise ValueError('invalid publication request') + return _serialize({'fields': sorted(request), 'sha256': hashlib.sha256(_serialize(request).encode()).hexdigest()}) + + +def _receipt(row): + record = json.loads(row.payload) + request = json.loads(row.request_payload) + if (not isinstance(record, dict) or not isinstance(request, dict) or set(request) != {'fields', 'sha256'} + or not isinstance(request.get('fields'), list) or not all(isinstance(key, str) for key in request['fields']) + or len(set(request['fields'])) != len(request['fields']) or set(request['fields']) - CREATE_FIELDS + or not {'author', 'price', 'type'} <= set(request['fields']) + or not re.fullmatch(r'[0-9a-f]{64}', request.get('sha256') or '') + or not re.fullmatch(r'[0-9a-f]{64}', row.request_key or '') + or not re.fullmatch(r'[A-Za-z0-9_-]{1,100}', row.item_id or '') + or not isinstance(row.author, str) or not row.author.strip() + or record.get('id') != row.item_id or record.get('author') != row.author + or not isinstance(record.get('type'), str) or not record['type'] + or type(record.get('price')) is not int or record['price'] < 0 + or type(record.get('created_at')) is not int or type(row.projected) is not bool): + raise ValueError('invalid resource publication receipt') + if not row.projected: + original = {key: record[key] for key in request['fields']} + if _request_receipt(original) != _serialize(request): + raise ValueError('publication request differs from payload') + elif set(record) != {'id', 'author', 'type', 'price', 'created_at'}: + raise ValueError('invalid publication summary') + return copy.deepcopy(record), _serialize(request) + + +def _project(store, record, *, allow_insert=True): + from resource_deletion import resource_mutation_lock, assert_id_not_deleted + def select(records): + matches = [entry for entry in _records(records) if entry['id'] == record['id']] + if not matches: + return None + item = matches[0] + if any(item.get(key) != record.get(key) for key in ('author', 'type', 'created_at')): + raise ResourceStoreError(409, '资源发布记录冲突') + return copy.deepcopy(item) + with resource_mutation_lock: + assert_id_not_deleted(store, record['id']) + current = select(store.load_data('items.json', default_data=[], strict=True, skip_cache=True)) + if current is not None: + return current, False + if not allow_insert: + raise ResourceStoreError(503, '已发布资源当前无法核实,请先核对个人作品') + result = None + def append(records): + nonlocal result + assert_id_not_deleted(store, record['id']) + result = select(records) + if result is None: + records.insert(0, copy.deepcopy(record)) + result = copy.deepcopy(record) + try: + store.atomic_update('items.json', append, default_data=[], force_sync=bool(record.get('github_token'))) + except Exception: + # 本地替换后远端同步可能失败,只在最新完整记录可核实时确认。 + current = select(store.load_data('items.json', default_data=[], strict=True, skip_cache=True)) + if current is None: + raise + return current, True + return result, True + + +def publish_once(bind, store, record, request_id, request): + if not isinstance(request_id, str) or not re.fullmatch(r'[0-9a-f]{32}', request_id): + raise ResourceStoreError(400, '发布请求编号无效') + author = record.get('author') + if not isinstance(author, str) or not author.strip(): + raise ResourceStoreError(400, '发布作者无效') + key = hashlib.sha256(_serialize([author, request_id]).encode()).hexdigest() + try: + encoded_request = _request_receipt(request) + candidate = ResourcePublication(request_key=key, item_id=record['id'], author=author, + request_payload=encoded_request, payload=_serialize(record), projected=False) + if request.get('author') != author or _serialize({key: record.get(key) for key in request}) != _serialize(request): + raise ValueError('publication payload differs from request') + _receipt(candidate) + with Session(bind=bind, autoflush=False) as session: + try: + if session.get(ResourcePublication, key) is None: + session.add(candidate) + session.commit() + except Exception: + session.rollback() + # 唯一键并发或提交响应未知,下一独立读取核实持久结果。 + with Session(bind=bind, autoflush=False) as session: + row = session.get(ResourcePublication, key) + if row is None: + raise ValueError('resource publication not confirmed') + saved, original_request = _receipt(row) + if type(row.projected) is not bool: + raise ValueError('invalid projection state') + projected = row.projected + if row.author != author or original_request != encoded_request: + raise ResourceStoreError(409, '同次发布内容已改变,请先核对原记录') + result, _ = _project(store, saved, allow_insert=not projected) + if not projected: + _confirm_projection(bind, key, saved['id']) + return result + except ResourceStoreError: + raise + except Exception: + raise ResourceStoreError(503, '资源发布结果暂时无法核实,请先核对个人作品') from None + + +def _confirm_projection(bind, key, item_id): + with Session(bind=bind, autoflush=False) as session: + try: + row = session.get(ResourcePublication, key) + if row is None or row.item_id != item_id: + raise ValueError('publication receipt missing') + record, _ = _receipt(row) + summary = _serialize({field: record[field] for field in ('id', 'author', 'type', 'price', 'created_at')}) + count = session.query(ResourcePublication).filter(ResourcePublication.request_key == key, + ResourcePublication.item_id == item_id, ResourcePublication.payload == row.payload, + ResourcePublication.projected.is_(False)).update( + {'projected': True, 'payload': summary}, synchronize_session=False) + if count != 1: + session.expire_all() + confirmed = session.get(ResourcePublication, key) + if confirmed is None or confirmed.item_id != item_id or confirmed.projected is not True: + raise ValueError('publication projection confirmation changed') + _receipt(confirmed) + session.commit() + except Exception: + session.rollback() + raise + + +def repair_publications(bind, store, invalidate, cursor='', limit=50): + """轮转未确认投影,坏记录逐项隔离;已确认而后丢失的内容不猜测重建。""" + with Session(bind=bind, autoflush=False) as session: + rows = session.query(ResourcePublication).filter(ResourcePublication.projected.is_(False), ResourcePublication.request_key > cursor).order_by( + ResourcePublication.request_key).limit(limit).all() + snapshots = [] + for row in rows: + try: + record, _ = _receipt(row) + snapshots.append((row.request_key, record)) + except Exception: + logging.getLogger(__name__).warning('资源发布恢复跳过损坏记录') + next_cursor = rows[-1].request_key if rows else '' + for key, record in snapshots: + try: + _, changed = _project(store, record) + _confirm_projection(bind, key, record['id']) + if changed: + invalidate('items:') + except Exception: + logging.getLogger(__name__).warning('资源发布恢复暂未完成') + return next_cursor + + +async def publication_worker(bind, store, invalidate, stop, interval=30): + cursor = '' + while not stop.is_set(): + try: + cursor = await asyncio.to_thread(repair_publications, bind, store, invalidate, cursor) + except Exception: + logging.getLogger(__name__).warning('资源发布恢复轮次暂未完成') + try: + await asyncio.wait_for(stop.wait(), timeout=interval) + except asyncio.TimeoutError: + pass diff --git a/resource_store.py b/resource_store.py new file mode 100644 index 0000000000000000000000000000000000000000..68311eac932c7b44f045164b6a11d2b3fadbb0c9 --- /dev/null +++ b/resource_store.py @@ -0,0 +1,148 @@ +"""资源发布/编辑的锁内局部修改与保存故障核实。""" +import copy +import logging +import re +import json +import datetime +from purchase_transaction import effective_resource_price + + +class ResourceStoreError(Exception): + def __init__(self, status_code, message): + self.status_code = status_code + self.message = message + super().__init__(message) + + +def _records(source): + if not isinstance(source, list): + raise ValueError('invalid resource source') + ids = [] + for entry in source: + if not isinstance(entry, dict) or not isinstance(entry.get('id'), str) or not entry['id'].strip(): + raise ValueError('invalid resource identity') + ids.append(entry['id']) + if len(ids) != len(set(ids)): + raise ValueError('ambiguous resource identities') + return source + + +def publish_resource(store, record): + if not isinstance(record, dict): + raise ResourceStoreError(400, '资源发布参数无效') + saved = copy.deepcopy(record) + serialized = json.dumps(saved, sort_keys=True, ensure_ascii=True, allow_nan=False) + def matches(entry): + return json.dumps(entry, sort_keys=True, ensure_ascii=True, allow_nan=False) == serialized + if (not isinstance(saved.get('id'), str) or not re.fullmatch(r'[A-Za-z0-9_-]{1,100}', saved['id']) + or not isinstance(saved.get('author'), str) or not saved['author'].strip() + or type(saved.get('price')) is not int or saved['price'] < 0): + raise ResourceStoreError(400, '资源发布参数无效') + def append(source): + records = _records(source) + from resource_deletion import assert_id_not_deleted + assert_id_not_deleted(store, saved['id']) + matches = [entry for entry in records if entry['id'] == saved['id']] + if matches: + if json.dumps(matches[0], sort_keys=True, ensure_ascii=True, allow_nan=False) == serialized: + return + raise ResourceStoreError(409, '资源编号冲突,请重新提交') + records.insert(0, copy.deepcopy(saved)) + try: + store.atomic_update('items.json', append, default_data=[], force_sync=bool(saved.get('github_token'))) + except Exception as error: + # 本地替换成功后,HF排队/同步也可能失败;只有完整确认已保存才报告成功。 + try: + records = _records(store.load_data('items.json', default_data=[], strict=True, skip_cache=True)) + committed = any(matches(entry) for entry in records) + except Exception: + committed = False + if not committed: + if isinstance(error, ResourceStoreError): + raise error + raise ResourceStoreError(503, '资源保存暂时无法核实,请稍后核对') from None + logging.getLogger(__name__).warning('资源本地已保存,发布后同步暂未确认') + return copy.deepcopy(saved) + + +def published_response(record): + response = copy.deepcopy(record) + response['has_private_token'] = bool(response.pop('github_token', None)) + response.pop('netdisk_password', None) + return response + + +def edit_resource(store, item_id, account, changes): + fields = {'title', 'shortDesc', 'fullDesc', 'link', 'coverUrl', 'imageUrls', 'price', + 'github_token', 'netdisk_password', 'is_netdisk', 'is_original', 'allow_refund', 'expected_revision'} + if not isinstance(changes, dict) or set(changes) - fields: + raise ResourceStoreError(400, '资源编辑参数无效') + changes = {key: copy.deepcopy(value) for key, value in changes.items() if value is not None} + expected_revision = changes.pop('expected_revision', None) + if expected_revision is not None and (type(expected_revision) is not int or not 0 <= expected_revision <= 9007199254740991): + raise ResourceStoreError(400, '资源编辑版本无效') + if 'price' in changes and (type(changes['price']) is not int or changes['price'] < 0): + raise ResourceStoreError(400, '资源价格无效') + expected = None + result = None + def update(source): + nonlocal expected, result + records = _records(source) + index = next((index for index, record in enumerate(records) if record['id'] == item_id), None) + if index is None: + raise ResourceStoreError(404, '找不到该内容记录') + original = records[index] + author = original.get('author') + if not isinstance(author, str) or not author.strip(): + raise ValueError('invalid resource author') + if author != account: + raise ResourceStoreError(403, '无权修改他人发布的内容') + revision = original.get('edit_revision', 0) + if type(revision) is not int or not 0 <= revision < 9007199254740991: + raise ValueError('invalid resource edit revision') + if expected_revision is not None and revision != expected_revision: + raise ResourceStoreError(409, 'resource_revision_conflict') + edited = copy.deepcopy(original) + edited['edit_revision'] = revision + 1 + current_price = effective_resource_price(edited) + edited['price'] = current_price + pending = edited.get('pending_price') + effective = edited.get('pending_price_effective_at') + if type(pending) is int and pending == current_price and effective: + when = datetime.datetime.fromisoformat(effective) + now = datetime.datetime.now(when.tzinfo) if when.tzinfo else datetime.datetime.now() + if now >= when: + edited['pending_price'] = None + edited['pending_price_effective_at'] = None + price_change = None + for key, value in changes.items(): + if key != 'price': + edited[key] = value + if 'price' in changes: + if changes['price'] != current_price: + effective_at = (datetime.datetime.now() + datetime.timedelta(hours=24)).isoformat() + edited.update(pending_price=changes['price'], pending_price_effective_at=effective_at) + price_change = dict(current_price=current_price, new_price=changes['price'], effective_at=effective_at) + else: + edited.update(pending_price=None, pending_price_effective_at=None) + result = dict(edit_revision=revision + 1, price_change_info=price_change, + source_changed=any(original.get(key) != edited.get(key) for key in ('link', 'github_token'))) + expected = copy.deepcopy(edited) + records[index] = edited + return copy.deepcopy(result) + try: + return store.atomic_update('items.json', update, default_data=[], force_sync=bool(changes.get('github_token'))) + except ResourceStoreError: + raise + except Exception: + if expected is not None: + try: + source = _records(store.load_data('items.json', default_data=[], strict=True, skip_cache=True)) + serialized = json.dumps(expected, sort_keys=True, allow_nan=False) + confirmed = any(json.dumps(record, sort_keys=True, allow_nan=False) == serialized for record in source) + except Exception: + confirmed = False + if confirmed: + logging.getLogger(__name__).warning('资源编辑本地已保存,后续同步暂未确认') + return copy.deepcopy(result) + raise ResourceStoreError(503, '资源编辑保存暂时无法核实,请稍后核对') from None diff --git a/response_privacy.py b/response_privacy.py new file mode 100644 index 0000000000000000000000000000000000000000..b531a25c3bd7647eb4e6b2f4b1059c0e134659a1 --- /dev/null +++ b/response_privacy.py @@ -0,0 +1,14 @@ +"""浏览器/共享 HTTP 缓存的私有响应边界,无数据库或应用依赖。""" +import re + +_PRIVATE_PATH = re.compile( + r"^/api/(wallet|messages|chats|admin|disputes|users)(?:/|$)" + r"|^/api/my-(posts|prompts|tasks)(?:/|$)" + r"|^/api/(tasks|prompts)/[^/]+$" +) + + +def requires_no_store(path, authorization=None): + # 含 Authorization 的响应即使路径公开也不交给共享 HTTP 缓存。 + # 匿名访问私有路径的错误响应及登录返回的 Token 同样不能缓存。 + return bool(authorization or _PRIVATE_PATH.search(path)) diff --git a/router_admin_plugins.py b/router_admin_plugins.py index dfa0c0fea1075955ef9a34a8e9686cff194444d9..d9a0d3bb0a749406dc5eefe7b362a176e8f42849 100644 --- a/router_admin_plugins.py +++ b/router_admin_plugins.py @@ -13,19 +13,22 @@ import re import json +import hmac +import hashlib import secrets import logging import datetime from typing import Optional, List -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request +from rate_limit import shared_limit from fastapi.responses import JSONResponse from sqlalchemy.orm import Session from pydantic import BaseModel, Field, field_validator # 🧹 P2修复:pydantic v2 已废弃 v1 风格 validator from database_sql import get_db from models_sql import RegisteredPlugin -from 安全认证 import require_auth, is_admin +from 安全认证 import require_auth, is_admin, JWT_SECRET logger = logging.getLogger("ComfyUI-Ranking.AdminPlugins") @@ -42,6 +45,13 @@ _PLUGIN_ID_PATTERN = re.compile(r"^[a-z0-9_]{3,50}$") _DEFAULT_PERMISSIONS = ["verify", "balance", "deduct", "refund"] +def _audit_actor_id(account: str) -> str: + """稳定关联管理员审计事件,但不把可识别账号写入运行日志。""" + return hmac.new( + JWT_SECRET.encode("utf-8"), str(account).encode("utf-8"), hashlib.sha256 + ).hexdigest()[:16] + + def generate_plugin_key() -> str: """生成 plugin_key,格式: rk_plug_ + 32位hex随机串""" return f"rk_plug_{secrets.token_hex(16)}" @@ -67,7 +77,7 @@ def _notify_registry_reload(): reload_plugin_registry() logger.info("插件注册表热更新成功") except (ImportError, Exception) as e: - logger.warning(f"插件注册表热更新失败: {e}") + logger.warning(f"插件注册表热更新失败: error_type={type(e).__name__}") def _serialize_plugin(p: RegisteredPlugin, include_key: bool = False, raw_key: Optional[str] = None) -> dict: @@ -178,7 +188,7 @@ async def list_plugins( except HTTPException: raise except Exception as e: - logger.exception(f"列出插件失败: {e}") + logger.error(f"列出插件失败: error_type={type(e).__name__}") return JSONResponse(status_code=500, content={"success": False, "error": "服务器内部错误"}) @@ -197,7 +207,9 @@ async def get_plugin( @router.post("/api/admin/plugins") +@shared_limit(20) async def create_plugin( + request: Request, payload: PluginCreateRequest, account: str = Depends(require_auth), db: Session = Depends(get_db), @@ -234,10 +246,10 @@ async def create_plugin( db.refresh(plugin) except Exception as e: db.rollback() - logger.exception(f"创建插件失败: {e}") + logger.error(f"创建插件失败: error_type={type(e).__name__}") return JSONResponse(status_code=500, content={"success": False, "error": "创建插件失败"}) - logger.info(f"管理员 {account} 创建插件 plugin_id={payload.plugin_id}") + logger.info(f"ADMIN_PLUGIN_CREATE | actor={_audit_actor_id(account)} | plugin_id={payload.plugin_id}") _notify_registry_reload() return JSONResponse(status_code=201, content={ @@ -251,7 +263,9 @@ async def create_plugin( @router.put("/api/admin/plugins/{plugin_id}") +@shared_limit(30) async def update_plugin( + request: Request, plugin_id: str, payload: PluginUpdateRequest, account: str = Depends(require_auth), @@ -282,10 +296,13 @@ async def update_plugin( db.refresh(plugin) except Exception as e: db.rollback() - logger.exception(f"更新插件失败: {e}") + logger.error(f"更新插件失败: error_type={type(e).__name__}") return JSONResponse(status_code=500, content={"success": False, "error": "更新插件失败"}) - logger.info(f"管理员 {account} 更新插件 plugin_id={plugin_id}, fields={list(update_fields.keys())}") + logger.info( + f"ADMIN_PLUGIN_UPDATE | actor={_audit_actor_id(account)} | plugin_id={plugin_id} | " + f"fields={','.join(sorted(update_fields.keys()))}" + ) _notify_registry_reload() return JSONResponse(content={ @@ -296,7 +313,9 @@ async def update_plugin( @router.delete("/api/admin/plugins/{plugin_id}") +@shared_limit(10) async def delete_plugin( + request: Request, plugin_id: str, account: str = Depends(require_auth), db: Session = Depends(get_db), @@ -313,17 +332,19 @@ async def delete_plugin( db.commit() except Exception as e: db.rollback() - logger.exception(f"删除插件失败: {e}") + logger.error(f"删除插件失败: error_type={type(e).__name__}") return JSONResponse(status_code=500, content={"success": False, "error": "删除插件失败"}) - logger.info(f"管理员 {account} 删除插件 plugin_id={plugin_id}") + logger.info(f"ADMIN_PLUGIN_DELETE | actor={_audit_actor_id(account)} | plugin_id={plugin_id}") _notify_registry_reload() return JSONResponse(content={"success": True, "message": "插件已删除", "plugin_id": plugin_id}) @router.post("/api/admin/plugins/{plugin_id}/rotate-key") +@shared_limit(5) async def rotate_plugin_key( + request: Request, plugin_id: str, account: str = Depends(require_auth), db: Session = Depends(get_db), @@ -346,10 +367,10 @@ async def rotate_plugin_key( db.refresh(plugin) except Exception as e: db.rollback() - logger.exception(f"轮换 plugin_key 失败: {e}") + logger.error(f"轮换 plugin_key 失败: error_type={type(e).__name__}") return JSONResponse(status_code=500, content={"success": False, "error": "轮换 plugin_key 失败"}) - logger.info(f"管理员 {account} 轮换插件 plugin_id={plugin_id} 的 plugin_key") + logger.info(f"ADMIN_PLUGIN_ROTATE_KEY | actor={_audit_actor_id(account)} | plugin_id={plugin_id}") _notify_registry_reload() return JSONResponse(content={ @@ -362,7 +383,9 @@ async def rotate_plugin_key( @router.post("/api/admin/plugins/{plugin_id}/toggle") +@shared_limit(20) async def toggle_plugin( + request: Request, plugin_id: str, account: str = Depends(require_auth), db: Session = Depends(get_db), @@ -381,10 +404,13 @@ async def toggle_plugin( db.refresh(plugin) except Exception as e: db.rollback() - logger.exception(f"切换插件状态失败: {e}") + logger.error(f"切换插件状态失败: error_type={type(e).__name__}") return JSONResponse(status_code=500, content={"success": False, "error": "切换插件状态失败"}) - logger.info(f"管理员 {account} 切换插件 plugin_id={plugin_id} 状态为 enabled={plugin.enabled}") + logger.info( + f"ADMIN_PLUGIN_TOGGLE | actor={_audit_actor_id(account)} | plugin_id={plugin_id} | " + f"enabled={bool(plugin.enabled)}" + ) _notify_registry_reload() return JSONResponse(content={ diff --git a/router_comments.py b/router_comments.py index 86071812a4e225a5cb954bc5660281912d927a32..167791db23d88657c220d4458255e87bd4388832 100644 --- a/router_comments.py +++ b/router_comments.py @@ -4,19 +4,20 @@ import time import uuid from html import escape import 数据库连接 as db +from comment_store import append_comment, delete_comment from notifications import add_notification from models import InteractionToggle, CommentCreate from 安全认证 import require_auth, is_admin # 🔒 频次限制(与 router_users_auth.py / router_wallet.py / router_prompts.py 同一写法) -from slowapi import Limiter -from slowapi.util import get_remote_address -limiter = Limiter(key_func=get_remote_address) +from rate_limit import shared_limit +from db_utils import sort_cache router = APIRouter() @router.post("/api/interactions/toggle") -async def toggle_interaction(interaction: InteractionToggle, current_user: str = Depends(require_auth)): +@shared_limit(30) +async def toggle_interaction(request: Request, interaction: InteractionToggle, current_user: str = Depends(require_auth)): interaction.user_id = current_user # 强制使用当前用户身份,防止伪造 list_key = f"{interaction.action_type}d_by" @@ -54,6 +55,9 @@ async def toggle_interaction(interaction: InteractionToggle, current_user: str = if result_container.get("error"): raise HTTPException(status_code=404, detail=result_container["error"]) + + # 统一互动接口也会改变资源排序,成功提交后失效派生排序缓存。 + sort_cache.invalidate("items:") # 通知在原子操作外发送(通知失败不影响主流程) if interaction.is_active: @@ -62,7 +66,7 @@ async def toggle_interaction(interaction: InteractionToggle, current_user: str = return {"status": "success", "new_count": result_container["count"]} @router.post("/api/comments") -@limiter.limit("10/minute") # 🔒 安全限流:防止刷评论洗版(正常用户不会一分钟发 10 条以上) +@shared_limit(10) # 🔒 安全限流:防止刷评论洗版(正常用户不会一分钟发 10 条以上) async def post_comment(request: Request, comment: CommentCreate, current_user: str = Depends(require_auth)): comment.author = current_user # 强制使用认证用户身份,防止伪造 # 评论内容长度校验 @@ -70,12 +74,10 @@ async def post_comment(request: Request, comment: CommentCreate, current_user: s raise HTTPException(status_code=400, detail="评论长度必须在1-5000字符之间") # HTML 转义,防止 XSS comment.content = escape(comment.content) - comments_db = db.load_data("comments.json", default_data={}) users_db = db.load_data("users.json", default_data={}) author_info = users_db.get(comment.author, {}) reply_name = users_db.get(comment.reply_to_user, {}).get("name", comment.reply_to_user) if comment.reply_to_user else None - item_comments = comments_db.get(comment.item_id, []) new_comment = { "id": f"c_{int(time.time())}_{uuid.uuid4().hex[:6]}", "author": comment.author, "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 "isDeleted": False, "replies": [], "created_at": int(time.time()) } + notification = None if comment.parent_id: - parent = next((c for c in item_comments if c["id"] == comment.parent_id), None) - if parent: - parent["replies"].append(new_comment) - if comment.reply_to_user: - add_notification(comment.reply_to_user, {"type": "reply", "from_user": comment.author, "target_item_id": comment.item_id, "target_item_title": "收到新的回复", "content": comment.content}) - else: raise HTTPException(status_code=404, detail="找不到要回复的评论") + if comment.reply_to_user: + notification = (comment.reply_to_user, {"type": "reply", "from_user": comment.author, "target_item_id": comment.item_id, "target_item_title": "收到新的回复", "content": comment.content}) else: - item_comments.append(new_comment) items_db = db.load_data("items.json", default_data=[]) posts_db = db.load_data("posts.json", default_data=[]) target_item = next((item for item in items_db if item["id"] == comment.item_id), None) target_post = next((post for post in posts_db if post["id"] == comment.item_id), None) if target_item: - add_notification(target_item.get("author", ""), {"type": "comment", "from_user": comment.author, "target_item_id": comment.item_id, "target_item_title": target_item["title"], "content": comment.content}) + 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}) elif target_post: - add_notification(target_post.get("author", ""), {"type": "comment", "from_user": comment.author, "target_item_id": comment.item_id, "target_item_title": target_post.get("title", "") or "", "content": comment.content}) + 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}) elif comment.item_id in users_db: - add_notification(comment.item_id, {"type": "comment", "from_user": comment.author, "target_item_id": comment.item_id, "target_item_title": "您的个人留言板", "content": comment.content}) + notification = (comment.item_id, {"type": "comment", "from_user": comment.author, "target_item_id": comment.item_id, "target_item_title": "您的个人留言板", "content": comment.content}) - comments_db[comment.item_id] = item_comments - db.save_data("comments.json", comments_db) + append_comment(comment.item_id, new_comment, parent_id=comment.parent_id) + if notification: + add_notification(*notification) return {"status": "success", "data": new_comment} @router.delete("/api/comments/{item_id}/{comment_id}") -async def soft_delete_comment(item_id: str, comment_id: str, account: str = Depends(require_auth)): +@shared_limit(10) +async def soft_delete_comment(request: Request, item_id: str, comment_id: str, account: str = Depends(require_auth)): """ 软删除评论 权限规则:评论作者可删除自己的评论,帖子/内容作者可删除其帖子下的评论,管理员可删除任何评论 """ - comments_db = db.load_data("comments.json", default_data={}) items_db = db.load_data("items.json", default_data=[]) posts_db = db.load_data("posts.json", default_data=[]) users_db = db.load_data("users.json", default_data={}) tasks_db = db.load_data("tasks.json", default_data=[]) - - item_comments = comments_db.get(item_id, []) - target_comment = None - - def find_comment(comments_list, depth=0): - """查找目标评论""" - if depth > 20: # 防止极端嵌套导致栈溢出 - return False - nonlocal target_comment - for c in comments_list: - if c["id"] == comment_id: - target_comment = c - return True - if "replies" in c and find_comment(c["replies"], depth + 1): - return True - return False - - if not find_comment(item_comments): - raise HTTPException(status_code=404, detail="找不到该评论") - - # 权限检查 - is_comment_author = target_comment["author"] == account - is_admin_user = is_admin(account) - - # 检查是否为内容作者(帖子作者或资源作者) - is_content_author = False - # 检查是否为帖子作者 - for post in posts_db: - if post["id"] == item_id and post.get("author") == account: - is_content_author = True - break - # 检查是否为资源作者 - if not is_content_author: - for item in items_db: - if item["id"] == item_id and item.get("author") == account: - is_content_author = True - break - # 任务发布者 - if not is_content_author: - for task in tasks_db: - if task["id"] == item_id and task.get("publisher") == account: - is_content_author = True - break - # 检查是否为个人主页留言板作者 - if not is_content_author: - if item_id in users_db and item_id == account: - is_content_author = True - - # 权限判断:评论作者、内容作者或管理员可删除 - if not (is_comment_author or is_content_author or is_admin_user): - raise HTTPException(status_code=403, detail="无权删除此评论") - - # 执行软删除 - def mark_deleted(comments_list, depth=0): - if depth > 20: # 防止极端嵌套导致栈溢出 - return False - for c in comments_list: - if c["id"] == comment_id: - c["isDeleted"] = True - c["content"] = "" - return True - if "replies" in c and mark_deleted(c["replies"], depth + 1): - return True - return False - - # 原子操作软删除,防止并发覆盖 - max_retries = 3 - for attempt in range(max_retries): - try: - comments_db = db.load_data("comments.json", default_data={}) - item_comments = comments_db.get(item_id, []) - if not find_comment(item_comments): - raise HTTPException(status_code=404, detail="找不到该评论") - mark_deleted(item_comments) - comments_db[item_id] = item_comments - db.save_data("comments.json", comments_db) - break - except HTTPException: - raise - except Exception: - if attempt == max_retries - 1: - raise - - return {"status": "success"} \ No newline at end of file + is_content_author = any(entry.get("id") == item_id and entry.get("author") == account + for entry in posts_db + items_db) + is_content_author = is_content_author or any(entry.get("id") == item_id and entry.get("publisher") == account + for entry in tasks_db) + is_content_author = is_content_author or (item_id == account and item_id in users_db) + delete_comment(item_id, comment_id, account, can_moderate=is_content_author or is_admin(account)) + return {"status": "success"} diff --git a/router_items.py b/router_items.py index 3ad518bc1cab5deb341b4710737bc1239aea8469..2e8bad7a1a40f380e13ee53ffb9f4704a2e33549 100644 --- a/router_items.py +++ b/router_items.py @@ -1,18 +1,25 @@ # router_items.py -from fastapi import APIRouter, HTTPException, Depends +from fastapi import APIRouter, HTTPException, Depends, Request +from rate_limit import shared_limit import time import uuid import datetime import os +import re import urllib.request import urllib.error import json import asyncio import 数据库连接 as db from models import ItemCreate, ItemUpdate, RatingRequest -from 安全认证 import require_auth, check_ownership +from 安全认证 import require_auth, check_ownership, is_admin from 数据库连接 import invalidate_cache from db_utils import record_view, sort_cache +from github_source import repository_parts, select_github_token +from github_versions import refresh_resource_version, invalidate_source +from resource_store import publish_resource, published_response, edit_resource, ResourceStoreError +from purchase_transaction import effective_resource_price +from resource_deletion import delete_resource router = APIRouter() @@ -30,18 +37,21 @@ def _get_version_str(versions_db: dict, item_id: str) -> str: return val or "" def _apply_effective_price(item: dict) -> None: - """检查 pending_price 是否已过生效时间,若是则更新 price 字段供前端显示""" - pending_price = item.get("pending_price") - pending_effective = item.get("pending_price_effective_at") - if pending_price is not None and pending_effective: - try: - effective_time = datetime.datetime.fromisoformat(pending_effective) - if datetime.datetime.now() >= effective_time: - item["price"] = pending_price - item["pending_price"] = None - item["pending_price_effective_at"] = None - except (ValueError, TypeError): - pass + """计算当前有效价格;调用者须传响应副本或处于编辑锁内。""" + try: + price = effective_resource_price(item) + pending = item.get('pending_price') + effective = item.get('pending_price_effective_at') + if type(pending) is int and pending == price and effective: + when = datetime.datetime.fromisoformat(effective) + now = datetime.datetime.now(when.tzinfo) if when.tzinfo else datetime.datetime.now() + if now >= when: + item['pending_price'] = None + item['pending_price_effective_at'] = None + item['price'] = price + except (ValueError, TypeError): + raise HTTPException(status_code=503, detail="资源价格暂时无法核实") from None + def get_last_6_months(): res = [] @@ -94,17 +104,10 @@ async def get_items(type: str = "tool", sort: str = "time", limit: int = 50): # filtered_items = sort_cache.get_sorted(cache_key, filtered_items, sort_fn) - price_updated = False result_items = [] for item in filtered_items: - # 💰 检查延迟价格是否已生效(在原始字典上执行,确保生效后可持久化) - old_price = item.get("price", 0) - _apply_effective_price(item) - if item.get("price", 0) != old_price: - price_updated = True - - # 🔒 P0数据修复:浅拷贝后再做下发加工,避免 pop/注入字段污染 items.json 源数据 item_data = {**item} + _apply_effective_price(item_data) item_data["commentsData"] = comments_db.get(item_data["id"], []) item_data["comments"] = len(item_data["commentsData"]) 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): # item_data.pop("viewed_by", None) # 👁️ 访问者列表不暴露给前端 result_items.append(item_data) - # 如果有价格生效了,持久化回 items.json - if price_updated: - db.save_data("items.json", items_db) - invalidate_cache("items.json") - return {"status": "success", "data": result_items[:limit]} def _build_creator_trend_data(account: str, u_items: list, months: list) -> dict: @@ -327,18 +325,14 @@ async def get_creator_details(account: str): } @router.post("/api/items") -async def create_item(item: ItemCreate, current_user: str = Depends(require_auth)): +@shared_limit(5) +async def create_item(request: Request, item: ItemCreate, current_user: str = Depends(require_auth)): # 🔒 P0安全修复:使用 JWT 认证的用户账号作为作者,防止伪造 item.author = current_user - if not item.type: - raise HTTPException(status_code=400, detail="资源类型不能为空") - item.price = int(item.price or 0) - if item.price < 0: - raise HTTPException(status_code=400, detail="🚨 安全拦截:商品价格不能为负数") - - items_db = db.load_data("items.json", default_data=[]) + if not re.fullmatch(r'[A-Za-z0-9_-]{1,50}', item.type or ''): + raise HTTPException(status_code=400, detail="资源类型无效") new_item = { - "id": f"{item.type}_{int(time.time())}_{uuid.uuid4().hex[:6]}", "type": item.type, "title": item.title, "author": item.author, + "id": f"{item.type}_{int(time.time())}_{uuid.uuid4().hex}", "type": item.type, "title": item.title, "author": item.author, "shortDesc": item.shortDesc, "fullDesc": item.fullDesc, "link": item.link, "coverUrl": item.coverUrl, "imageUrls": item.imageUrls or [], # 🖼️ 效果展示图列表 "price": item.price, @@ -357,167 +351,80 @@ async def create_item(item: ItemCreate, current_user: str = Depends(require_auth "rating_dist": {"1": 0, "2": 0, "3": 0, "4": 0, "5": 0}, "rated_by": {} } - items_db.insert(0, new_item) - db.save_data("items.json", items_db, force_sync=bool(item.github_token)) - # 🗂️ 清除排序缓存 - sort_cache.invalidate("items:") + try: + if item.request_id is not None: + from database_sql import engine + from resource_publication import publish_once + payload = {key: new_item[key] for key in item.model_dump(exclude={'request_id'})} + new_item = await asyncio.to_thread(publish_once, engine, db, new_item, item.request_id, payload) + else: + new_item = await asyncio.to_thread(publish_resource, db, new_item) + except ResourceStoreError as error: + raise HTTPException(status_code=error.status_code, detail=error.message) from None + # 已提交内容不因派生排序缓存故障而误报发布失败。 + try: + sort_cache.invalidate("items:") + except Exception as error: + print(f"[资源发布] 缓存失效失败: error_type={type(error).__name__}") # 🚀 首次发布立即触发版本检测与预缓存 try: link = item.link or "" if "github.com" in link: - from 云端_定时版本检测引擎 import fetch_latest_github_hash, precache_github_zip + from 云端_定时版本检测引擎 import precache_github_zip async def _first_publish_precache(): try: - token = item.github_token or os.environ.get("GITHUB_PAT", "") - latest_hash = await fetch_latest_github_hash(link, token) - if latest_hash: - # 写入 versions.json - versions_db = db.load_data("versions.json", default_data={}) - versions_db[new_item["id"]] = {"hash": latest_hash} - db.save_data("versions.json", versions_db) - # 触发预缓存 - await precache_github_zip(link, token, new_item["id"], latest_hash) - print(f"[缓存刷新] 首次发布预缓存完成: {new_item['id']}") + refreshed = await refresh_resource_version(db, new_item['id']) + if refreshed: + await precache_github_zip(refreshed['item']['link'], refreshed['token'], new_item['id'], refreshed['commit']) except Exception as e: - import traceback - print(f"[缓存刷新] 首次发布预缓存失败(不影响发布): {e}") - print(f"[缓存刷新] 详细堆栈: {traceback.format_exc()}") + print(f"[缓存刷新] 首次发布预缓存失败(不影响发布): item_id={new_item['id']}, error_type={type(e).__name__}") asyncio.create_task(_first_publish_precache()) except Exception as e: - print(f"[缓存刷新] 触发首次发布缓存异常(不影响发布): {e}") + print(f"[缓存刷新] 触发首次发布缓存异常(不影响发布): item_id={new_item['id']}, error_type={type(e).__name__}") - return {"status": "success", "data": new_item} + return {"status": "success", "data": published_response(new_item)} @router.put("/api/items/{item_id}") -async def update_item(item_id: str, update_data: ItemUpdate, current_user: str = Depends(require_auth)): +@shared_limit(10) +async def update_item(request: Request, item_id: str, update_data: ItemUpdate, current_user: str = Depends(require_auth)): """ 更新内容接口 🔒 P0安全修复:使用 JWT Token 验证用户身份,而非前端传入的 author 参数 🔄 P7后悔模式:价格修改延迟24小时生效 🔧 Bug修复:使用 atomic_update 避免并发竞态条件 """ - if update_data.price is not None: - update_data.price = int(update_data.price) - if update_data.price < 0: - raise HTTPException(status_code=400, detail="🚨 安全拦截:商品价格不能为负数") - - result_holder = {} - - def updater(items_db): - for item in items_db: - if item["id"] == item_id: - # 🔒 P0安全修复:使用 JWT 解析出的真实用户账号进行校验 - if item.get("author") != current_user: - result_holder["error"] = "forbidden" - return False - - price_change_info = None - - if update_data.title is not None: item["title"] = update_data.title - if update_data.shortDesc is not None: item["shortDesc"] = update_data.shortDesc - if update_data.fullDesc is not None: item["fullDesc"] = update_data.fullDesc - old_link = item.get("link", "") - if update_data.link is not None: item["link"] = update_data.link - result_holder["link_changed"] = old_link != item.get("link", "") - result_holder["new_link"] = item.get("link", "") - if update_data.coverUrl is not None: item["coverUrl"] = update_data.coverUrl - if update_data.imageUrls is not None: item["imageUrls"] = update_data.imageUrls # 🖼️ 效果展示图列表 - - # 🔄 P7后悔模式:价格修改延迟24小时生效 - if update_data.price is not None: - current_price = item.get("price", 0) - new_price = update_data.price - - if current_price != new_price: - # 设置待生效价格,24小时后生效 - import datetime - effective_time = datetime.datetime.now() + datetime.timedelta(hours=24) - item["pending_price"] = new_price - item["pending_price_effective_at"] = effective_time.isoformat() - price_change_info = { - "current_price": current_price, - "new_price": new_price, - "effective_at": effective_time.isoformat() - } - # 不立即修改 price,等待24小时后生效 - else: - # 价格未变,清除待生效价格 - item["pending_price"] = None - item["pending_price_effective_at"] = None - - if update_data.github_token is not None: item["github_token"] = update_data.github_token - if update_data.netdisk_password is not None: item["netdisk_password"] = update_data.netdisk_password # ☁️ - if update_data.is_netdisk is not None: item["is_netdisk"] = update_data.is_netdisk # ☁️ - if update_data.is_original is not None: item["is_original"] = update_data.is_original # 🎨 - if update_data.allow_refund is not None: item["allow_refund"] = update_data.allow_refund # 💸 - - result_holder["success"] = True - result_holder["price_change_info"] = price_change_info - result_holder["current_price"] = price_change_info.get("current_price") if price_change_info else None - result_holder["new_price"] = price_change_info.get("new_price") if price_change_info else None - return True - - result_holder["error"] = "not_found" - return False - - db.atomic_update("items.json", updater, default_data=[], force_sync=bool(update_data.github_token)) - - if result_holder.get("error") == "forbidden": - raise HTTPException(status_code=403, detail="无权修改他人发布的内容") - if result_holder.get("error") == "not_found": - raise HTTPException(status_code=404, detail="找不到该内容记录") - - # 🗂️ 清除排序缓存和主数据缓存 - sort_cache.invalidate("items:") - invalidate_cache("items.json") - - # 🔄 [缓存刷新] 资源 link 变更时清除 ZIP 缓存元信息 - if result_holder.get("link_changed"): + try: + result_holder = await asyncio.to_thread(edit_resource, db, item_id, current_user, + update_data.model_dump(exclude_unset=True)) + except ResourceStoreError as error: + raise HTTPException(status_code=error.status_code, detail=error.message) from None + # 编辑已经提交,派生缓存故障不改写主操作的成功结果。 + for invalidate, key in ((sort_cache.invalidate, "items:"), (invalidate_cache, "items.json")): try: - versions_db = db.load_data("versions.json", default_data={}) - entry = versions_db.get(item_id) - if entry is not None: - if isinstance(entry, str): - entry = {"hash": entry} - had_meta = "cached_at" in entry or "zip_size" in entry - entry.pop("cached_at", None) - entry.pop("zip_size", None) - if had_meta: - versions_db[item_id] = entry - db.save_data("versions.json", versions_db) - print(f"[缓存刷新] 已清除 item {item_id} 的 ZIP 缓存元信息") - - # 可选:触发异步预缓存 - new_link = result_holder.get("new_link", "") - if new_link and new_link.startswith("https://github.com/"): + invalidate(key) + except Exception as error: + print(f"[资源编辑] 缓存失效失败: error_type={type(error).__name__}") + + # 来源变更撤销旧版本;后台重新检测当前资源,绝不用旧仓库hash请求新仓库。 + if result_holder.get('source_changed'): + try: + await asyncio.to_thread(invalidate_source, db, item_id) + async def refresh_changed_source(): try: - from 云端_定时版本检测引擎 import precache_github_zip - # 获取当前 item 的 token 和 version_hash - items_db = db.load_data("items.json", default_data=[]) - token = None - for it in items_db: - if it["id"] == item_id: - token = it.get("github_token") - break - - versions_db = db.load_data("versions.json", default_data={}) - entry = versions_db.get(item_id, {}) - version_hash = entry.get("hash", "") if isinstance(entry, dict) else entry - - if version_hash: - asyncio.create_task(precache_github_zip(new_link, token, item_id, version_hash)) - print(f"[缓存刷新] 已触发 item {item_id} 的异步预缓存") - except ImportError: - print(f"[缓存刷新] 预缓存模块导入失败,跳过异步预缓存") - except Exception as e: - print(f"[缓存刷新] 触发异步预缓存失败: {e}") - except Exception as e: - print(f"[缓存刷新] 缓存刷新失败(不影响主流程): {e}") - - result = {"status": "success"} + refreshed = await refresh_resource_version(db, item_id) + if refreshed: + from 云端_定时版本检测引擎 import precache_github_zip + await precache_github_zip(refreshed['item']['link'], refreshed['token'], item_id, refreshed['commit']) + except Exception as error: + print(f"[缓存刷新] 来源检测失败: error_type={type(error).__name__}") + asyncio.create_task(refresh_changed_source()) + except Exception as error: + print(f"[缓存刷新] 来源撤销失败: error_type={type(error).__name__}") + + result = {"status": "success", "edit_revision": result_holder["edit_revision"]} if result_holder.get("price_change_info"): price_change_info = result_holder["price_change_info"] result["price_change"] = price_change_info @@ -529,80 +436,25 @@ async def update_item(item_id: str, update_data: ItemUpdate, current_user: str = # 可通过外部调度器 (cron-job.org / GitHub Actions) 每日 02:00 触发 # ========================================== @router.post("/api/check_updates") -async def check_github_updates(): +@shared_limit(30) +async def check_github_updates(request: Request, current_user: str = Depends(require_auth)): """ 遍历所有 GitHub 类型的工具,获取最新 commit hash,存入 versions.json """ - items_db = db.load_data("items.json", default_data=[]) - versions_db = db.load_data("versions.json", default_data={}) - + if not is_admin(current_user): + raise HTTPException(status_code=403, detail="无权限") + items_db = await asyncio.to_thread(db.load_data, "items.json", default_data=[], strict=True, skip_cache=True) updated_count = 0 - for item in items_db: - # 只处理 GitHub 仓库类型的工具 - link = item.get("link", "") - if not link.startswith("https://github.com/"): + if not item.get('link', '').startswith('https://github.com/'): continue - - item_id = item["id"] - try: - # 解析仓库信息 - repo_parts = link.rstrip("/").replace(".git", "").split("/") - if len(repo_parts) < 2: - continue - owner, repo = repo_parts[-2], repo_parts[-1] - - # 获取创作者 Token 或使用全局兜底 Token - creator_token = item.get("github_token") - fallback_token = os.environ.get("GITHUB_PAT") - active_token = creator_token if creator_token else fallback_token - - # 请求 GitHub API 获取最新 commit - api_url = f"https://api.github.com/repos/{owner}/{repo}/commits?per_page=1" - headers = { - "Accept": "application/vnd.github.v3+json", - "User-Agent": "ComfyUI-Ranking-VersionChecker" - } - if active_token: - headers["Authorization"] = f"Bearer {active_token}" - - req = urllib.request.Request(api_url, headers=headers) - - # ⚡ 性能修复:urlopen 是同步阻塞调用(timeout=10),在 async 处理器里 - # 逐个 item 串行等待会占死唯一的事件循环(N 个仓库最坏 N×10 秒全站无响应); - # 抽成同步小函数交给线程池执行,异常仍由下方 except 分支按原样捕获。 - def _fetch_commits(): - with urllib.request.urlopen(req, timeout=10) as response: - return json.loads(response.read().decode("utf-8")) - - commits = await asyncio.to_thread(_fetch_commits) - if commits and len(commits) > 0: - latest_sha = commits[0].get("sha", "")[:7] # 取前7位作为版本标识 - - # 如果版本有变化,更新 versions.json - # 🧹 P2修复(格式统一):统一以新格式 dict 读写,保留 cached_at/zip_size 等缓存元字段; - # 旧逻辑拿字符串与 dict 比较会把 dict 条目永远判为"有更新"并覆盖成字符串丢失元信息 - entry = versions_db.get(item_id) - if isinstance(entry, str): - entry = {"hash": entry} - elif not isinstance(entry, dict): - entry = {} - old_version = entry.get("hash", "") - if latest_sha and latest_sha != old_version: - entry["hash"] = latest_sha - versions_db[item_id] = entry - updated_count += 1 - print(f"[版本更新] {item['title']}: {old_version} -> {latest_sha}") - - except urllib.error.HTTPError as e: - print(f"[版本检查失败] {item.get('title', item_id)}: HTTP {e.code}") - except Exception as e: - print(f"[版本检查异常] {item.get('title', item_id)}: {str(e)}") - - # 保存更新后的版本库 - db.save_data("versions.json", versions_db) - + refreshed = await refresh_resource_version(db, item['id']) + if refreshed and refreshed['changed']: + updated_count += 1 + except Exception as error: + print(f"[版本检查异常] error_type={type(error).__name__}") + return { "status": "success", "message": f"版本检查完成,共更新 {updated_count} 个工具的版本号", @@ -618,16 +470,8 @@ async def get_item_by_id(item_id: str): for item in items_db: if item["id"] == item_id: - # 💰 检查延迟价格是否已生效(在原字典上操作,确保生效结果可持久化) - old_price = item.get("price", 0) - _apply_effective_price(item) - if item.get("price", 0) != old_price: - db.save_data("items.json", items_db) - invalidate_cache("items.json") - - # 🔒 P2修复(与 get_items 同款):浅拷贝后再做下发加工, - # 避免 pop 敏感字段污染 items.json 源数据/共享缓存(导致 github_token 永久丢失) item_data = {**item} + _apply_effective_price(item_data) # 添加关联数据 item_data["commentsData"] = comments_db.get(item_id, []) item_data["comments"] = len(item_data["commentsData"]) @@ -650,56 +494,28 @@ async def get_item_version(item_id: str): return {"status": "success", "version": _get_version_str(versions_db, item_id)} @router.delete("/api/items/{item_id}") -async def delete_item(item_id: str, current_user: str = Depends(require_auth)): +@shared_limit(10) +async def delete_item(request: Request, item_id: str, current_user: str = Depends(require_auth)): """ 删除内容(仅作者或管理员可操作) 🔧 Bug修复:使用 atomic_update 避免并发竞态条件 """ - result_holder = {} - - def items_updater(items_db): - for i, item in enumerate(items_db): - if item["id"] == item_id: - # 🔒 权限检查:仅作者或管理员可删除 - if not check_ownership(item, current_user, owner_field="author", allow_admin=True): - result_holder["error"] = "forbidden" - return False - - # 1. 从 items.json 中删除该条目 - items_db.pop(i) - result_holder["item_deleted"] = True - return True - - result_holder["error"] = "not_found" - return False - - db.atomic_update("items.json", items_updater, default_data=[]) - - if result_holder.get("error") == "forbidden": - raise HTTPException(status_code=403, detail="无权删除他人发布的内容") - if result_holder.get("error") == "not_found": - raise HTTPException(status_code=404, detail="找不到该内容记录") - - # 2. 清理关联评论:从 comments.json 中删除该内容的所有评论 - def comments_updater(comments_db): - if item_id in comments_db: - del comments_db[item_id] - return True - return False - - db.atomic_update("comments.json", comments_updater, default_data={}) - - # 3. 清理缓存:使 items.json 和 comments.json 的缓存失效 - invalidate_cache("items.json") - invalidate_cache("comments.json") - # 🗂️ 清除排序缓存 - sort_cache.invalidate("items:") - - return {"status": "success", "message": "内容已删除"} + try: + result = await asyncio.to_thread(delete_resource, db, item_id, current_user, is_admin(current_user)) + except ResourceStoreError as error: + raise HTTPException(status_code=error.status_code, detail=error.message) from None + for invalidate, key in ((invalidate_cache, "items.json"), (invalidate_cache, "comments.json"), + (invalidate_cache, "versions.json"), (sort_cache.invalidate, "items:")): + try: + invalidate(key) + except Exception as error: + print(f"[资源删除] 缓存失效失败: error_type={type(error).__name__}") + return {"status": "success", "message": "内容已删除", **result} @router.post("/api/items/{item_id}/view") -async def record_item_view(item_id: str, current_user: str = Depends(require_auth)): +@shared_limit(60) +async def record_item_view(request: Request, item_id: str, current_user: str = Depends(require_auth)): """ 记录资源访问量 👁️ 需要用户认证,每个用户只计算一次总访问量,日访问量每次调用都增加 @@ -716,7 +532,8 @@ async def record_item_view(item_id: str, current_user: str = Depends(require_aut @router.post("/api/items/{item_id}/use") -async def record_item_use(item_id: str, current_user: str = Depends(require_auth)): +@shared_limit(30) +async def record_item_use(request: Request, item_id: str, current_user: str = Depends(require_auth)): """ 记录资源使用量/下载量(原子操作,并发安全) 📥 每个用户对同一资源只计一次,防止重复计数 @@ -760,12 +577,13 @@ async def record_item_use(item_id: str, current_user: str = Depends(require_auth # ========================================== @router.post("/api/items/{item_id}/rating") -async def rate_item(item_id: str, request: RatingRequest, current_user: str = Depends(require_auth)): +@shared_limit(20) +async def rate_item(request: Request, item_id: str, payload: RatingRequest, current_user: str = Depends(require_auth)): """ 为资源评分(原子操作,并发安全) ⭐ score: 1-5 """ - score = request.score + score = payload.score if score < 1 or score > 5: raise HTTPException(status_code=400, detail="评分必须在1-5之间") @@ -834,7 +652,8 @@ async def rate_item(item_id: str, request: RatingRequest, current_user: str = De @router.post("/api/items/{item_id}/like") -async def toggle_item_like(item_id: str, current_user: str = Depends(require_auth)): +@shared_limit(30) +async def toggle_item_like(request: Request, item_id: str, current_user: str = Depends(require_auth)): """ 点赞/取消点赞(原子操作,并发安全) """ @@ -869,7 +688,8 @@ async def toggle_item_like(item_id: str, current_user: str = Depends(require_aut @router.post("/api/items/{item_id}/favorite") -async def toggle_item_favorite(item_id: str, current_user: str = Depends(require_auth)): +@shared_limit(30) +async def toggle_item_favorite(request: Request, item_id: str, current_user: str = Depends(require_auth)): """ 收藏/取消收藏(原子操作,并发安全) """ @@ -907,7 +727,8 @@ async def toggle_item_favorite(item_id: str, current_user: str = Depends(require # 🔄 手动触发预缓存刷新接口 # ========================================== @router.post("/api/items/{item_id}/refresh_cache") -async def refresh_item_cache(item_id: str, current_user: str = Depends(require_auth)): +@shared_limit(3) +async def refresh_item_cache(request: Request, item_id: str, current_user: str = Depends(require_auth)): """手动触发指定资源的 ZIP 预缓存刷新,仅限作者调用""" items_db = db.load_data("items.json", default_data=[]) @@ -931,31 +752,16 @@ async def refresh_item_cache(item_id: str, current_user: str = Depends(require_a raise HTTPException(status_code=400, detail="仅支持 GitHub 仓库类型的资源") try: - from 云端_定时版本检测引擎 import fetch_latest_github_hash, precache_github_zip + from 云端_定时版本检测引擎 import precache_github_zip except ImportError: raise HTTPException(status_code=500, detail="预缓存模块不可用") - # 获取 token - token = target_item.get("github_token") or os.environ.get("GITHUB_PAT", "") - - # 获取最新 hash - latest_hash = await fetch_latest_github_hash(link, token) - if not latest_hash: - raise HTTPException(status_code=500, detail="获取最新版本号失败,请稍后重试") - - # 写入 versions.json - versions_db = db.load_data("versions.json", default_data={}) - entry = versions_db.get(item_id, {}) - if isinstance(entry, str): - entry = {"hash": entry} - entry["hash"] = latest_hash - versions_db[item_id] = entry - db.save_data("versions.json", versions_db) - - # 执行预缓存 - success = await precache_github_zip(link, token, item_id, latest_hash) - + refreshed = await refresh_resource_version(db, item_id) + if not refreshed: + raise HTTPException(status_code=503, detail="资源版本暂时无法核实,请稍后重试") + success = await precache_github_zip(refreshed['item']['link'], refreshed['token'], item_id, refreshed['commit']) + if success: return {"status": "success", "message": "缓存刷新完成"} else: - return {"status": "error", "message": "缓存刷新失败,请查看云端日志获取详细信息"} \ No newline at end of file + return {"status": "error", "message": "缓存刷新失败,请查看云端日志获取详细信息"} diff --git a/router_messages.py b/router_messages.py index 8da3bdc9ae4eb0c3cb84c3334bf64779d60ed4d4..1ba2f1ed17ecc1dd28365f197bbd11f7a1a613dd 100644 --- a/router_messages.py +++ b/router_messages.py @@ -1,5 +1,6 @@ # router_messages.py from fastapi import APIRouter, HTTPException, Depends, Request +from rate_limit import shared_limit from pydantic import BaseModel import asyncio import time @@ -7,13 +8,12 @@ import uuid import subprocess import os import 数据库连接 as db +import chat_store +import notification_store from models import PrivateMessage from 安全认证 import require_auth, is_admin # 🔒 频次限制(与 router_users_auth.py / router_wallet.py / router_prompts.py 同一写法) -from slowapi import Limiter -from slowapi.util import get_remote_address -limiter = Limiter(key_func=get_remote_address) router = APIRouter() @@ -28,29 +28,13 @@ class SystemAnnouncement(BaseModel): # 新增:发布系统公告接口 (仅限管理员,使用JWT验证) # ========================================== @router.post("/api/system/announcement") -async def publish_announcement(ann: SystemAnnouncement, current_user: str = Depends(require_auth)): +@shared_limit(5) +async def publish_announcement(request: Request, ann: SystemAnnouncement, current_user: str = Depends(require_auth)): # 🔒 P0安全修复:使用环境变量配置的管理员列表 if not is_admin(current_user): raise HTTPException(status_code=403, detail="无权发布系统公告,仅管理员可操作") - # 查询管理员信息 - users_db = db.load_data("users.json", default_data={}) - admin_info = users_db.get(current_user, {}) - - announcements_db = db.load_data("announcements.json", default_data=[]) - - new_ann = { - "id": f"sys_{int(time.time())}_{uuid.uuid4().hex[:6]}", - "type": "system", - "from_user": current_user, # 使用真实的管理员账号 - "from_name": admin_info.get("name", current_user), # 使用真实昵称,fallback 为账号 - "from_avatar": admin_info.get("avatarDataUrl", ""), # 使用真实头像 - "content": ann.content, - "created_at": int(time.time()) - } - - announcements_db.append(new_ann) - db.save_data("announcements.json", announcements_db) + await asyncio.to_thread(notification_store.publish_announcement, current_user, ann.content) return {"status": "success"} @@ -71,7 +55,8 @@ ALLOWED_SCRIPTS = { } @router.post("/api/admin/run-script") -async def run_admin_script(req: AdminScriptRequest, current_user: str = Depends(require_auth)): +@shared_limit(3) +async def run_admin_script(request: Request, req: AdminScriptRequest, current_user: str = Depends(require_auth)): """ 管理员专属:执行指定的 Python 脚本 🔒 P0安全修复:白名单 + 路径穿越防护 @@ -159,93 +144,24 @@ async def run_admin_script(req: AdminScriptRequest, current_user: str = Depends( # 原有功能:私信与聊天 # ========================================== @router.post("/api/messages/private") -@limiter.limit("30/minute") # 🔒 安全限流:防止私信骚扰/刷屏(聊天节奏快,阈值比发帖宽松) +@shared_limit(30) # 🔒 安全限流:防止私信骚扰/刷屏(聊天节奏快,阈值比发帖宽松) async def send_private_message(request: Request, msg: PrivateMessage, current_user: str = Depends(require_auth)): - msg.sender = current_user # 强制发送者身份,防止伪造 - - # 验证接收者存在 - users_db = db.load_data("users.json", default_data={}) - if msg.receiver not in users_db: - raise HTTPException(status_code=404, detail="接收者不存在") - - chats_db = db.load_data("chats.json", default_data={}) - conv_id = f"{min(msg.sender, msg.receiver)}_{max(msg.sender, msg.receiver)}" - if conv_id not in chats_db: chats_db[conv_id] = [] - - 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} - chats_db[conv_id].append(chat_msg) - db.save_data("chats.json", chats_db) - - # 🔔 私信未读提醒改由聊天入口(✉️红点)承载(读取 chats.json 每会话未读数), - # 不再向 messages.json 写入 private 铃铛通知,避免与聊天红点重复计数 + await asyncio.to_thread(chat_store.send_message, current_user, msg.receiver, msg.content) return {"status": "success"} @router.get("/api/chats/{account}") async def get_chat_list(account: str, current_user: str = Depends(require_auth)): if account != current_user: raise HTTPException(status_code=403, detail="无权访问他人私信") - chats_db = db.load_data("chats.json", default_data={}) - users_db = db.load_data("users.json", default_data={}) - - chat_list = [] - for conv_id, msgs in chats_db.items(): - if account in conv_id: - if not msgs: continue - # 🔧 P1修复:对方账户从消息的 sender/receiver 字段推导,而非对 conv_id 做 replace - # (replace 会替换所有子串,若账户名互为子串或含下划线会解析出错误账户) - other_account = None - for m in reversed(msgs): - if m.get("sender") != account: - other_account = m.get("sender") - break - if m.get("receiver") != account: - other_account = m.get("receiver") - break - if not other_account: continue - last_msg = msgs[-1] - unread_count = sum(1 for m in msgs if m["receiver"] == account and not m.get("is_read")) - other_user = users_db.get(other_account, {}) - chat_list.append({ - "account": other_account, - "name": other_user.get("name", other_account), - "avatar": other_user.get("avatarDataUrl", ""), - "last_message": last_msg["content"], - "last_time": last_msg["created_at"], - "unread_count": unread_count - }) - - chat_list.sort(key=lambda x: x["last_time"], reverse=True) - return {"status": "success", "data": chat_list} + data = await asyncio.to_thread(chat_store.chat_list, account) + return {"status": "success", "data": data} @router.get("/api/chats/{account}/{target_account}") async def get_chat_history(account: str, target_account: str, current_user: str = Depends(require_auth)): if account != current_user: raise HTTPException(status_code=403, detail="无权访问他人聊天记录") - chats_db = db.load_data("chats.json", default_data={}) - conv_id = f"{min(account, target_account)}_{max(account, target_account)}" - msgs = chats_db.get(conv_id, []) - - now = int(time.time()) - seven_days = 7 * 24 * 3600 - valid_msgs = [] - modified = False - - for m in msgs: - if not m.get("is_read") or (now - m.get("created_at", 0) < seven_days): - valid_msgs.append(m) - else: - modified = True - - # 本次访问即为已读 - if m["receiver"] == account and not m.get("is_read"): - m["is_read"] = True - modified = True - - if modified or len(valid_msgs) != len(msgs): - chats_db[conv_id] = valid_msgs - db.save_data("chats.json", chats_db) - - return {"status": "success", "data": valid_msgs} + data = await asyncio.to_thread(chat_store.chat_history, account, target_account) + return {"status": "success", "data": data} # ========================================== # 改造:获取通知列表 (加入系统公告懒加载注入) @@ -257,138 +173,16 @@ async def get_messages(account: str, count_only: bool = False, current_user: str # 🔒 P0安全加固:拦截越权读取他人消息(含 count_only 轮询) if account != current_user: raise HTTPException(status_code=403, detail="无权访问他人消息") - # 🔥 count_only 模式:轻量级轮询,只返回未读数,不标记已读 - if count_only: - messages_db = db.load_data("messages.json", default_data={}) - user_msgs = messages_db.get(account, []) - - now = int(time.time()) - seven_days = 7 * 24 * 3600 - - # 🔔 只统计未过期的未读消息;私信提醒已迁移至聊天入口(✉️红点)、 - # 插件更新提醒已迁移至卡片徽章,铃铛未读数不再计入这两类,避免同一提醒重复出现 - unread = sum(1 for m in user_msgs - if not m.get("is_read") - and m.get("type") not in ("private", "plugin_update") - and (now - m.get("created_at", 0) < seven_days)) - return {"status": "success", "unread_count": unread} - - # 公告是只读的,先加载 - announcements_db = db.load_data("announcements.json", default_data=[]) - - # 🔥 性能优化:先用只读方式检查是否有实际变更需要写入 - messages_db = db.load_data("messages.json", default_data={}) - user_msgs = messages_db.get(account, []) - - now = int(time.time()) - seven_days = 7 * 24 * 3600 - - # 检查三个条件判断是否需要写入 - # 1. 是否有新公告需要注入 - user_msg_ids = {m.get("id") for m in user_msgs} - has_new_announcements = any( - ann.get("id") not in user_msg_ids - for ann in announcements_db - ) - - # 2. 是否有未读消息需要标记已读 - has_unread = any(not m.get("is_read") for m in user_msgs) - - # 3. 是否有已读超过7天的消息需要清理 - has_expired = any( - m.get("is_read") and (now - m.get("created_at", 0) >= seven_days) - for m in user_msgs - ) - - needs_update = has_new_announcements or has_unread or has_expired - - # 🔥 修复:在标记已读之前先计算真实的未读数 - unread_before_mark = sum(1 for m in user_msgs - if not m.get("is_read") - and (now - m.get("created_at", 0) < seven_days)) - - if not needs_update: - # 无变更,直接返回只读数据,不触发写入和HF上传 - return { - "status": "success", - "data": user_msgs, - "unread_count": unread_before_mark # 🔥 修复:返回真实的未读数 - } - - # 有变更需要写入,使用 atomic_update 保证并发安全 - result_container = [None] - - def updater(data): - user_msgs = data.get(account, []) - - # --- 核心改造区:瞬间比对并注入全局公告 --- - user_msg_ids = {m.get("id") for m in user_msgs} - - injected = False - for ann in announcements_db: - if ann.get("id") not in user_msg_ids: - new_sys_msg = dict(ann) - new_sys_msg["is_read"] = False - new_sys_msg["receiver"] = account - user_msgs.append(new_sys_msg) - injected = True - - if injected: - # 重新按照时间倒序排列,让新公告置顶 - user_msgs.sort(key=lambda x: x.get("created_at", 0), reverse=True) - # ---------------------------------------- - - now = int(time.time()) - seven_days = 7 * 24 * 3600 - valid = [] - - # 如果注入了新公告,则判定为需要回写数据库保存 - modified = injected - - for m in user_msgs: - if not m.get("is_read") or (now - m.get("created_at", 0) < seven_days): - valid.append(m) - else: - modified = True - - # 本次访问即为已读 - 将所有未读消息标记为已读 - if not m.get("is_read"): - m["is_read"] = True - modified = True - # 原地修改 data,atomic_update 会自动保存 - data[account] = valid - - # 通过闭包返回结果 - result_container[0] = { - "status": "success", - "data": valid, - "unread_count": unread_before_mark # 🔥 修复:返回标记已读前的真实未读数 - } - - db.atomic_update("messages.json", updater, default_data={}) - - return result_container[0] + return await asyncio.to_thread(notification_store.get_messages, account, count_only) @router.post("/api/messages/{account}/read") -async def mark_messages_read(account: str, current_user: str = Depends(require_auth)): +@shared_limit(60) +async def mark_messages_read(request: Request, account: str, current_user: str = Depends(require_auth)): """ 标记消息为已读(原子操作,并发安全) """ # 🔒 P0安全加固:补全鉴权,防止任意用户篡改他人消息已读状态 if account != current_user: raise HTTPException(status_code=403, detail="无权操作他人消息") - def updater(data): - user_msgs = data.get(account, []) - modified = False - for m in user_msgs: - if not m.get("is_read"): - m["is_read"] = True - modified = True - - # 原地修改 data,atomic_update 会自动保存 - if modified: - data[account] = user_msgs - - db.atomic_update("messages.json", updater, default_data={}) - - return {"status": "success"} \ No newline at end of file + await asyncio.to_thread(notification_store.mark_messages_read, account) + return {"status": "success"} diff --git a/router_open_api.py b/router_open_api.py index 41026a7fba9f17b68866301016109506322dc41c..40395f7bc4f74ce2e0bb806210a7acf6bcb23a1b 100644 --- a/router_open_api.py +++ b/router_open_api.py @@ -1,740 +1,638 @@ -# router_open_api.py -# ========================================== -# 🌐 通用开放 API 模块(供第三方插件调用) -# ========================================== -# 作用:对外提供受 Plugin Key 保护的通用接口, -# 供 NodeCraft AI 等第三方插件复用 ComfyUI-Ranking 账号体系。 -# 关联文件: -# - 安全认证.py (verify_token_with_fallback, require_auth) -# - database_sql.py (get_db, SessionLocal) -# - models_sql.py (Wallet, Transaction) -# - 数据库连接.py (users.json 读写) -# 安全机制: -# - X-Plugin-Key Header 鉴权 -# - 基于 plugin_key 的内存限流(每分钟重置) -# - 所有扣款/退款操作记录到 Transaction 流水(含哈希链) -# ========================================== - -import os -import json -import time -import uuid -import datetime -import hashlib -import logging -import threading -from datetime import date -from typing import Optional - -from fastapi import APIRouter, Depends, Header, HTTPException, Query -from fastapi.responses import JSONResponse -from sqlalchemy.orm import Session -from pydantic import BaseModel, Field - -from database_sql import get_db, SessionLocal -from models_sql import Wallet, Transaction, RegisteredPlugin -from 安全认证 import verify_token_with_fallback -import 数据库连接 as json_db - -logger = logging.getLogger("ComfyUI-Ranking.OpenAPI") - -router = APIRouter(prefix="/api/open", tags=["open-api"]) - - -# ═══════════════ 插件注册表(从数据库动态加载) ═══════════════ -# 反向索引:plugin_key -> {id, key, name, rate_limit, permissions, max_deduct_per_tx, max_deduct_per_day} -_PLUGIN_KEY_INDEX: dict = {} - - -def load_plugins_from_db(): - """启动时从数据库加载所有启用的插件到内存索引""" - global _PLUGIN_KEY_INDEX - - db = SessionLocal() - try: - # 首次启动时 seed 默认插件 - if not db.query(RegisteredPlugin).first(): - default_key = os.environ.get("NCA_PLUGIN_KEY", "nca_2026_nodecraft_ai_plugin") - default_plugin = RegisteredPlugin( - plugin_id="nodecraft_ai", - plugin_key=default_key, - name="NodeCraft AI", - enabled=True, - rate_limit=100, - max_deduct_per_tx=100, - max_deduct_per_day=1000, - permissions='["verify","balance","deduct","refund"]', - created_by="system", - ) - db.add(default_plugin) - db.commit() - logger.info("✅ 已 seed 默认插件: NodeCraft AI") - - plugins = db.query(RegisteredPlugin).filter(RegisteredPlugin.enabled == True).all() - - # 构建内存索引 - new_index = {} - for p in plugins: - try: - perms = json.loads(p.permissions) if p.permissions else ["verify", "balance", "deduct", "refund"] - except Exception: - perms = ["verify", "balance", "deduct", "refund"] - new_index[p.plugin_key] = { - "id": p.plugin_id, - "key": p.plugin_key, - "name": p.name, - "rate_limit": p.rate_limit or 100, - "permissions": perms, - "max_deduct_per_tx": p.max_deduct_per_tx or 100, - "max_deduct_per_day": p.max_deduct_per_day or 1000, - } - - _PLUGIN_KEY_INDEX = new_index - logger.info(f"✅ 已加载 {len(new_index)} 个插件到注册表") - except Exception as e: - logger.error(f"❌ 加载插件注册表失败: {e}") - # 如果加载失败且索引为空,使用环境变量 fallback - if not _PLUGIN_KEY_INDEX: - fallback_key = os.environ.get("NCA_PLUGIN_KEY", "nca_2026_nodecraft_ai_plugin") - _PLUGIN_KEY_INDEX = { - fallback_key: { - "id": "nodecraft_ai", - "key": fallback_key, - "name": "NodeCraft AI", - "rate_limit": 100, - "permissions": ["verify", "balance", "deduct", "refund"], - "max_deduct_per_tx": 100, - "max_deduct_per_day": 1000, - } - } - logger.warning("⚠️ 使用 fallback 插件配置") - finally: - db.close() - - -def reload_plugin_registry(): - """热更新插件注册表(管理操作后调用)""" - load_plugins_from_db() - - -# ========================================== -# 💰 日扣款总额限制:内存计数器(每自然日重置) -# ========================================== -_daily_deduct_lock = threading.Lock() -_daily_deduct_counters: dict = {} # {plugin_id: {"date": "2026-06-17", "total": 500}} - - -def _peek_daily_used(plugin_id: str) -> int: - """读取插件当日已使用的扣款总额(不修改计数)。跨日自动归零。""" - today = str(date.today()) - with _daily_deduct_lock: - counter = _daily_deduct_counters.get(plugin_id) - if not counter or counter.get("date") != today: - return 0 - return int(counter.get("total", 0)) - - -def _commit_daily_used(plugin_id: str, amount: int) -> int: - """扣款成功后将本次金额累加到当日计数,返回累加后的总额。""" - today = str(date.today()) - with _daily_deduct_lock: - counter = _daily_deduct_counters.get(plugin_id) - if not counter or counter.get("date") != today: - counter = {"date": today, "total": 0} - counter["total"] = int(counter.get("total", 0)) + int(amount) - _daily_deduct_counters[plugin_id] = counter - return counter["total"] - - -def verify_plugin_key(plugin_key: str) -> dict: - """ - 校验 Plugin Key 并返回插件配置。 - - 参数: - plugin_key: 请求头 X-Plugin-Key 的值 - 返回: - 匹配到的插件配置字典(包含 id 字段) - 异常: - 401: 未提供 Plugin Key - 403: Plugin Key 无效或未注册 - """ - if not plugin_key: - raise HTTPException(status_code=401, detail="缺少 X-Plugin-Key Header") - info = _PLUGIN_KEY_INDEX.get(plugin_key) - if not info: - logger.warning(f"OPEN_API_INVALID_KEY | key_prefix={plugin_key[:8]}") - raise HTTPException(status_code=403, detail="Plugin Key 无效或未注册") - return info - - -# ========================================== -# 🚦 限流:基于 plugin_id 的内存计数器(每分钟自动重置) -# ========================================== -_rate_lock = threading.Lock() -_rate_buckets: dict = {} # {plugin_id: {"window_start": int, "count": int}} - - -def _check_rate_limit(plugin_info: dict, permission: str) -> None: - """ - 校验插件权限并执行限流。 - - - 权限不足 -> 403 - - 触发限流 -> 429 - """ - plugin_id = plugin_info["id"] - rate_limit = int(plugin_info.get("rate_limit", 60)) - perms = plugin_info.get("permissions", []) - if permission not in perms: - raise HTTPException( - status_code=403, - detail=f"插件 {plugin_id} 无权限调用 {permission} 接口", - ) - - now = int(time.time()) - window_start = now - (now % 60) - - with _rate_lock: - bucket = _rate_buckets.get(plugin_id) - if not bucket or bucket["window_start"] != window_start: - bucket = {"window_start": window_start, "count": 0} - _rate_buckets[plugin_id] = bucket - if bucket["count"] >= rate_limit: - raise HTTPException( - status_code=429, - detail=f"请求过于频繁,限流 {rate_limit}/分钟,请稍后再试", - ) - bucket["count"] += 1 - - -def _require_plugin(plugin_key: Optional[str], permission: str) -> dict: - """统一入口:校验 Plugin Key + 权限 + 限流""" - info = verify_plugin_key(plugin_key or "") - _check_rate_limit(info, permission) - return info - - -def _resolve_user_from_token(token: str) -> str: - """从 Token 中解析出账号;失败时抛 401""" - if not token: - raise HTTPException(status_code=401, detail="缺少用户 Token") - is_valid, account, error_msg = verify_token_with_fallback(token) - if not is_valid or not account: - raise HTTPException(status_code=401, detail=error_msg or "Token 无效") - return account - - -def _calc_tx_hash(tx_id: str, account: str, tx_type: str, amount: int, prev_hash: str) -> str: - """计算交易哈希(与 router_wallet.calculate_tx_hash 算法一致)""" - data = f"{tx_id}{account}{tx_type}{amount}{prev_hash}" - return hashlib.sha256(data.encode()).hexdigest() - - -# ========================================== -# 📦 请求模型 -# ========================================== -class VerifyTokenRequest(BaseModel): - token: str = Field(..., description="待验证的用户 JWT Token") - - -class DeductRequest(BaseModel): - token: str = Field(..., description="用户 JWT Token") - amount: int = Field(..., gt=0, description="扣款金额(正整数)") - reason: str = Field(..., description="业务标识/扣款原因") - reference_id: Optional[str] = Field(None, description="业务侧参考 ID") - - -class RefundRequest(BaseModel): - token: str = Field(..., description="用户 JWT Token") - transaction_id: str = Field(..., description="原扣款交易 ID") - amount: int = Field(..., gt=0, description="退款金额(正整数)") - - -# ========================================== -# 1️⃣ Token 验证:POST /api/open/verify-token -# ========================================== -@router.post("/verify-token") -async def open_verify_token( - req: VerifyTokenRequest, - x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"), -): - """ - 供第三方插件验证用户 Token 有效性。 - 成功返回账号与基础资料(昵称、头像)。 - """ - plugin_info = _require_plugin(x_plugin_key, "verify") - - is_valid, account, error_msg = verify_token_with_fallback(req.token) - if not is_valid or not account: - logger.info( - f"OPEN_VERIFY_TOKEN_FAIL | plugin={plugin_info['id']} | reason={error_msg}" - ) - return {"valid": False, "error": error_msg or "Token 无效"} - - users_db = json_db.load_data("users.json", default_data={}) - user = users_db.get(account, {}) or {} - - logger.info(f"OPEN_VERIFY_TOKEN | plugin={plugin_info['id']} | account={account}") - return { - "valid": True, - "account": account, - "name": user.get("name", account), - "avatar": user.get("avatarDataUrl", "") or user.get("avatar", ""), - } - - -# ========================================== -# 2️⃣ 余额查询:GET /api/open/balance -# ========================================== -@router.get("/balance") -async def open_get_balance( - authorization: Optional[str] = Header(None, alias="Authorization"), - x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"), - db: Session = Depends(get_db), -): - """ - 查询用户钱包余额。 - 认证:Authorization: Bearer + X-Plugin-Key - """ - plugin_info = _require_plugin(x_plugin_key, "balance") - - if not authorization: - raise HTTPException(status_code=401, detail="缺少 Authorization Header") - parts = authorization.split(" ", 1) - if len(parts) != 2 or parts[0].lower() != "bearer": - raise HTTPException(status_code=401, detail="Authorization 格式错误,请使用 Bearer Token") - - account = _resolve_user_from_token(parts[1]) - wallet = db.query(Wallet).filter(Wallet.account == account).first() - - if not wallet: - result = { - "account": account, - "balance": 0, - "earn_balance": 0, - "tip_balance": 0, - "task_balance": 0, - "frozen_balance": 0, - } - else: - result = { - "account": account, - "balance": int(wallet.balance or 0), - "earn_balance": int(wallet.earn_balance or 0), - "tip_balance": int(wallet.tip_balance or 0), - "task_balance": int(wallet.task_balance or 0), - "frozen_balance": int(wallet.frozen_balance or 0), - } - - logger.info( - f"OPEN_BALANCE | plugin={plugin_info['id']} | account={account} | balance={result['balance']}" - ) - return result - - -# ========================================== -# 3️⃣ 通用扣款:POST /api/open/deduct -# ========================================== -@router.post("/deduct") -async def open_deduct( - req: DeductRequest, - x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"), - db: Session = Depends(get_db), -): - """ - 通用扣款接口(从 wallet.balance 扣减)。 - 流程: - 1. 校验 Plugin Key + 权限 + 限流 - 2. 解析 Token -> account - 3. 悲观锁锁定钱包,检查余额 - 4. 扣款 + 写入 Transaction(含哈希链) - """ - plugin_info = _require_plugin(x_plugin_key, "deduct") - account = _resolve_user_from_token(req.token) - amount = int(req.amount) - - # 🛡️ 单笔扣款上限校验(在余额检查之前快速拒绝) - max_per_tx = int(plugin_info.get("max_deduct_per_tx", 100)) - if amount > max_per_tx: - logger.warning( - f"OPEN_DEDUCT_TX_LIMIT | plugin={plugin_info['id']} | account={account} | " - f"amount={amount} | max={max_per_tx}" - ) - return JSONResponse(status_code=403, content={ - "error": "AMOUNT_EXCEEDS_LIMIT", - "message": f"单笔扣款不能超过{max_per_tx}积分", - "max": max_per_tx, - }) - - # 🛡️ 日扣款总额校验(仅检查不累加,扣款成功后再累加) - max_daily = int(plugin_info.get("max_deduct_per_day", 1000)) - used_today = _peek_daily_used(plugin_info["id"]) - if used_today + amount > max_daily: - logger.warning( - f"OPEN_DEDUCT_DAILY_LIMIT | plugin={plugin_info['id']} | account={account} | " - f"amount={amount} | used={used_today} | limit={max_daily}" - ) - return JSONResponse(status_code=403, content={ - "error": "DAILY_LIMIT_EXCEEDED", - "message": "今日扣款已达上限", - "limit": max_daily, - "used": used_today, - }) - - try: - wallet = ( - db.query(Wallet) - .filter(Wallet.account == account) - .with_for_update() - .first() - ) - if not wallet or int(wallet.balance or 0) < amount: - raise HTTPException(status_code=402, detail="余额不足") - - wallet.balance = int(wallet.balance or 0) - amount - - tx_id = f"OPEN_DEDUCT_{int(time.time())}_{uuid.uuid4().hex[:8]}" - last_tx = ( - db.query(Transaction) - .filter(Transaction.account == account) - .order_by(Transaction.created_at.desc()) - .first() - ) - prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH" - tx_hash = _calc_tx_hash(tx_id, account, "OPEN_DEDUCT", -amount, prev_hash) - - new_tx = Transaction( - tx_id=tx_id, - account=account, - tx_type="OPEN_DEDUCT", - amount=-amount, - related_account=plugin_info["id"], - item_id=req.reference_id, - prev_hash=prev_hash, - tx_hash=tx_hash, - description=f"[{plugin_info['name']}] {req.reason}", - source_plugin=plugin_info["id"], # 🌐 来源插件标识,用于按工具结构化追踪 - ) - db.add(new_tx) - db.commit() - db.refresh(wallet) - except HTTPException: - db.rollback() - raise - except Exception as e: - db.rollback() - logger.error( - f"OPEN_DEDUCT_FAIL | plugin={plugin_info['id']} | account={account} | err={e}" - ) - raise HTTPException(status_code=500, detail=f"扣款失败: {e}") - - # ✅ 扣款成功后再累加日计数器,避免余额不足等失败场景占用额度 - daily_total = _commit_daily_used(plugin_info["id"], amount) - - logger.info( - f"OPEN_DEDUCT | plugin={plugin_info['id']} | account={account} | " - f"amount={amount} | tx={tx_id} | reason={req.reason} | ref={req.reference_id} | " - f"daily_used={daily_total}/{max_daily}" - ) - return { - "success": True, - "remaining_balance": int(wallet.balance or 0), - "transaction_id": tx_id, - } - - -# ========================================== -# 4️⃣ 退款:POST /api/open/refund -# ========================================== -@router.post("/refund") -async def open_refund( - req: RefundRequest, - x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"), - db: Session = Depends(get_db), -): - """ - 退款接口:将之前 OPEN_DEDUCT 的金额(部分或全额)退还到用户钱包。 - - 校验: - - 原交易必须存在且 tx_type == OPEN_DEDUCT - - 原交易归属当前 token 用户 - - 原交易由当前插件发起(related_account == plugin_id) - - 累计退款金额 ≤ 原扣款金额 - """ - plugin_info = _require_plugin(x_plugin_key, "refund") - account = _resolve_user_from_token(req.token) - refund_amount = int(req.amount) - - original_tx = ( - db.query(Transaction).filter(Transaction.tx_id == req.transaction_id).first() - ) - if not original_tx: - raise HTTPException(status_code=404, detail="原交易不存在") - if original_tx.account != account: - raise HTTPException(status_code=403, detail="无权对该交易执行退款") - if original_tx.tx_type != "OPEN_DEDUCT": - raise HTTPException(status_code=400, detail="该交易类型不支持开放退款") - if (original_tx.related_account or "") != plugin_info["id"]: - raise HTTPException(status_code=403, detail="不允许跨插件退款") - - original_amount = abs(int(original_tx.amount or 0)) - if refund_amount > original_amount: - raise HTTPException(status_code=400, detail="退款金额超过原扣款金额") - - # 累计已退金额校验(防止重复退款超额) - refunded_records = ( - db.query(Transaction) - .filter( - Transaction.tx_type == "OPEN_REFUND", - Transaction.related_account == plugin_info["id"], - Transaction.item_id == req.transaction_id, - ) - .all() - ) - already_refunded = sum(abs(int(t.amount or 0)) for t in refunded_records) - if already_refunded + refund_amount > original_amount: - raise HTTPException( - status_code=400, - detail=f"退款金额超过可退余额(已退 {already_refunded}/{original_amount})", - ) - - try: - wallet = ( - db.query(Wallet) - .filter(Wallet.account == account) - .with_for_update() - .first() - ) - if not wallet: - wallet = Wallet( - account=account, - balance=0, - earn_balance=0, - tip_balance=0, - frozen_balance=0, - ) - db.add(wallet) - db.flush() - wallet.balance = int(wallet.balance or 0) + refund_amount - - tx_id = f"OPEN_REFUND_{int(time.time())}_{uuid.uuid4().hex[:8]}" - last_tx = ( - db.query(Transaction) - .filter(Transaction.account == account) - .order_by(Transaction.created_at.desc()) - .first() - ) - prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH" - tx_hash = _calc_tx_hash(tx_id, account, "OPEN_REFUND", refund_amount, prev_hash) - - new_tx = Transaction( - tx_id=tx_id, - account=account, - tx_type="OPEN_REFUND", - amount=refund_amount, - related_account=plugin_info["id"], - item_id=req.transaction_id, # 关联原扣款交易 - prev_hash=prev_hash, - tx_hash=tx_hash, - description=f"[{plugin_info['name']}] 退款 {req.transaction_id}", - source_plugin=plugin_info["id"], # 🌐 来源插件标识 - ) - db.add(new_tx) - db.commit() - db.refresh(wallet) - except HTTPException: - db.rollback() - raise - except Exception as e: - db.rollback() - logger.error( - f"OPEN_REFUND_FAIL | plugin={plugin_info['id']} | account={account} | err={e}" - ) - raise HTTPException(status_code=500, detail=f"退款失败: {e}") - - logger.info( - f"OPEN_REFUND | plugin={plugin_info['id']} | account={account} | " - f"amount={refund_amount} | tx={tx_id} | original={req.transaction_id}" - ) - return { - "success": True, - "remaining_balance": int(wallet.balance or 0), - "transaction_id": tx_id, - } - - -# ========================================== -# 5️⃣ 消费记录查询:GET /api/open/transactions -# ========================================== -@router.get("/transactions") -async def open_list_transactions( - authorization: Optional[str] = Header(None, alias="Authorization"), - x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"), - page: int = Query(1, ge=1, description="页码(从 1 开始)"), - page_size: int = Query(20, ge=1, le=100, description="每页条数(1-100)"), - start_time: Optional[int] = Query(None, description="起始时间(unix 秒)"), - end_time: Optional[int] = Query(None, description="结束时间(unix 秒)"), - tx_type: Optional[str] = Query(None, description="交易类型,如 OPEN_DEDUCT / OPEN_REFUND"), - db: Session = Depends(get_db), -): - """ - 查询当前用户由当前插件产生的消费/退款记录。 - - 认证:Authorization: Bearer + X-Plugin-Key - 安全:仅返回 source_plugin == 当前 plugin_id 的记录,防止跨插件读取。 - """ - plugin_info = _require_plugin(x_plugin_key, "verify") - - if not authorization: - raise HTTPException(status_code=401, detail="缺少 Authorization Header") - parts = authorization.split(" ", 1) - if len(parts) != 2 or parts[0].lower() != "bearer": - raise HTTPException(status_code=401, detail="Authorization 格式错误,请使用 Bearer Token") - account = _resolve_user_from_token(parts[1]) - - plugin_id = plugin_info["id"] - - # 构造查询:按 source_plugin 过滤;兼容历史 NULL 数据走 related_account 兜底 - q = db.query(Transaction).filter( - Transaction.account == account, - ((Transaction.source_plugin == plugin_id) - | ((Transaction.source_plugin.is_(None)) & (Transaction.related_account == plugin_id))), - ) - - if tx_type: - q = q.filter(Transaction.tx_type == tx_type) - if start_time is not None: - q = q.filter(Transaction.created_at >= datetime.datetime.utcfromtimestamp(int(start_time))) - if end_time is not None: - q = q.filter(Transaction.created_at <= datetime.datetime.utcfromtimestamp(int(end_time))) - - total = q.count() - rows = ( - q.order_by(Transaction.created_at.desc()) - .offset((page - 1) * page_size) - .limit(page_size) - .all() - ) - - items = [] - for t in rows: - created_ts = int(t.created_at.timestamp()) if t.created_at else 0 - # reason 从 description 中解析(去掉 [插件名] 前缀),兼容旧记录 - reason = (t.description or "").replace(f"[{plugin_info['name']}] ", "").strip() or None - items.append({ - "id": t.tx_id, - "type": t.tx_type, - "amount": int(t.amount or 0), - "reason": reason, - "reference_id": t.item_id, - "created_at": created_ts, - "description": t.description or "", - }) - - logger.info( - f"OPEN_LIST_TX | plugin={plugin_id} | account={account} | " - f"page={page}/{page_size} | total={total}" - ) - return { - "success": True, - "transactions": items, - "total": total, - "page": page, - "page_size": page_size, - } - - -# ========================================== -# 6️⃣ 消费汇总:GET /api/open/usage-summary -# ========================================== -@router.get("/usage-summary") -async def open_usage_summary( - x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"), - account: Optional[str] = Query(None, description="指定用户账号,不传则统计所有用户"), - period: str = Query("daily", description="聚合粒度:daily / monthly / total"), - start_time: Optional[int] = Query(None, description="起始时间(unix 秒)"), - end_time: Optional[int] = Query(None, description="结束时间(unix 秒)"), - db: Session = Depends(get_db), -): - """ - 本插件维度的消费汇总统计。 - - 返回:总扣款 / 总退款 / 总笔数 / 净消费 + 按 period 分桶的明细。 - 安全:仅统计 source_plugin == 当前 plugin_id 的记录。 - """ - plugin_info = _require_plugin(x_plugin_key, "verify") - plugin_id = plugin_info["id"] - - period = (period or "daily").lower() - if period not in ("daily", "monthly", "total"): - raise HTTPException(status_code=400, detail="period 仅支持 daily / monthly / total") - - base = db.query(Transaction).filter( - ((Transaction.source_plugin == plugin_id) - | ((Transaction.source_plugin.is_(None)) & (Transaction.related_account == plugin_id))), - Transaction.tx_type.in_(["OPEN_DEDUCT", "OPEN_REFUND"]), - ) - if account: - base = base.filter(Transaction.account == account) - if start_time is not None: - base = base.filter(Transaction.created_at >= datetime.datetime.utcfromtimestamp(int(start_time))) - if end_time is not None: - base = base.filter(Transaction.created_at <= datetime.datetime.utcfromtimestamp(int(end_time))) - - rows = base.all() - - total_deduct = 0 - total_refund = 0 - total_count = len(rows) - bucket: dict = {} # key -> {"deduct":int,"refund":int,"count":int} - - for t in rows: - amt = int(t.amount or 0) - if t.tx_type == "OPEN_DEDUCT": - total_deduct += abs(amt) - elif t.tx_type == "OPEN_REFUND": - total_refund += abs(amt) - - if period == "total": - continue - if not t.created_at: - continue - if period == "daily": - key = t.created_at.strftime("%Y-%m-%d") - else: # monthly - key = t.created_at.strftime("%Y-%m") - - b = bucket.setdefault(key, {"deduct": 0, "refund": 0, "count": 0}) - if t.tx_type == "OPEN_DEDUCT": - b["deduct"] += abs(amt) - elif t.tx_type == "OPEN_REFUND": - b["refund"] += abs(amt) - b["count"] += 1 - - breakdown = [] - if period != "total": - for key in sorted(bucket.keys(), reverse=True): - v = bucket[key] - label = "date" if period == "daily" else "month" - breakdown.append({ - label: key, - "deduct": v["deduct"], - "refund": v["refund"], - "count": v["count"], - }) - - logger.info( - f"OPEN_USAGE_SUMMARY | plugin={plugin_id} | account={account or '*'} | " - f"period={period} | tx_count={total_count}" - ) - return { - "success": True, - "plugin_id": plugin_id, - "plugin_name": plugin_info.get("name", plugin_id), - "summary": { - "total_deduct": total_deduct, - "total_refund": total_refund, - "total_transactions": total_count, - "net_consumption": total_deduct - total_refund, - }, - "breakdown": breakdown, - } +import asyncio +# router_open_api.py +# ========================================== +# 🌐 通用开放 API 模块(供第三方插件调用) +# ========================================== +# 作用:对外提供受 Plugin Key 保护的通用接口, +# 供 NodeCraft AI 等第三方插件复用 ComfyUI-Ranking 账号体系。 +# 关联文件: +# - 安全认证.py (verify_token_with_fallback, require_auth) +# - database_sql.py (get_db, SessionLocal) +# - models_sql.py (Wallet, Transaction) +# - 数据库连接.py (users.json 读写) +# 安全机制: +# - X-Plugin-Key Header 鉴权 +# - 基于 plugin_id 的 SQL 持久化限流(每分钟窗口,多 worker 共享) +# - 所有扣款/退款操作记录到 Transaction 流水(含哈希链) +# ========================================== + +import os +import json +import time +import uuid +import datetime +import hashlib +import logging +from typing import Optional + +from fastapi import APIRouter, Depends, Header, HTTPException, Query +from fastapi.responses import JSONResponse +from sqlalchemy.orm import Session +from sqlalchemy import func, text +from pydantic import BaseModel, Field + +from database_sql import get_db, SessionLocal +from models_sql import Wallet, Transaction, RegisteredPlugin, OpenApiRateLimitBucket +from open_transaction import lock_open_wallet, positive_integer, validate_live_plugin, quota_total +from tip_store import run_tip_effect +from 安全认证 import verify_token_with_fallback +import 数据库连接 as json_db + +logger = logging.getLogger("ComfyUI-Ranking.OpenAPI") + +router = APIRouter(prefix="/api/open", tags=["open-api"]) + + +# ═══════════════ 插件注册表(从数据库动态加载) ═══════════════ +# 反向索引:plugin_key -> {id, key, name, rate_limit, permissions, max_deduct_per_tx, max_deduct_per_day} +_PLUGIN_KEY_INDEX: dict = {} +_INSECURE_LEGACY_PLUGIN_KEYS = frozenset({"nca_2026_nodecraft_ai_plugin"}) + + +def load_plugins_from_db(): + """启动时从数据库加载所有启用的插件到内存索引""" + global _PLUGIN_KEY_INDEX + + db = None + try: + db = SessionLocal() + # 首次启动时仅在显式配置高熵 Key 时 seed 默认插件,不再使用源码内固定凭证。 + if not db.query(RegisteredPlugin).first(): + default_key = os.environ.get("NCA_PLUGIN_KEY", "").strip() + if len(default_key) >= 32 and default_key not in _INSECURE_LEGACY_PLUGIN_KEYS: + default_plugin = RegisteredPlugin( + plugin_id="nodecraft_ai", + plugin_key=default_key, + name="NodeCraft AI", + enabled=True, + rate_limit=100, + max_deduct_per_tx=100, + max_deduct_per_day=1000, + permissions='["verify","balance","deduct","refund"]', + created_by="system", + ) + db.add(default_plugin) + db.commit() + logger.info("✅ 已 seed 默认插件: NodeCraft AI") + else: + logger.warning("NCA_PLUGIN_KEY 缺失或不符合安全要求,跳过默认插件 seed") + + plugins = db.query(RegisteredPlugin).filter(RegisteredPlugin.enabled == True).all() + + # 构建内存索引 + new_index = {} + for p in plugins: + # 固定旧 Key 已随源代码泄露,不能继续接受;管理员轮换成新 Key 后即可恢复。 + if p.plugin_key in _INSECURE_LEGACY_PLUGIN_KEYS: + logger.critical(f"拒绝加载已泄露的默认 Plugin Key: plugin_id={p.plugin_id}") + continue + try: + perms = json.loads(p.permissions) if p.permissions else ["verify", "balance", "deduct", "refund"] + except Exception: + perms = ["verify", "balance", "deduct", "refund"] + new_index[p.plugin_key] = { + "id": p.plugin_id, + "key": p.plugin_key, + "name": p.name, + "rate_limit": p.rate_limit or 100, + "permissions": perms, + "max_deduct_per_tx": p.max_deduct_per_tx or 100, + "max_deduct_per_day": p.max_deduct_per_day or 1000, + } + + _PLUGIN_KEY_INDEX = new_index + logger.info(f"✅ 已加载 {len(new_index)} 个插件到注册表") + except Exception as e: + # 数据源不可信/不可用时 fail closed:清空内存索引,撤销旧 Key 的继续访问能力。 + _PLUGIN_KEY_INDEX = {} + logger.error(f"❌ 加载插件注册表失败: error_type={type(e).__name__}; 插件 API 已 fail closed") + finally: + if db is not None: + try: + db.close() + except Exception as e: + _PLUGIN_KEY_INDEX = {} + logger.error( + f"关闭插件注册表会话失败: error_type={type(e).__name__}; 插件 API 已 fail closed" + ) + + +def reload_plugin_registry(): + """热更新插件注册表(管理操作后调用)""" + load_plugins_from_db() + + +# ========================================== +def verify_plugin_key(plugin_key: str) -> dict: + """ + 校验 Plugin Key 并返回插件配置。 + + 参数: + plugin_key: 请求头 X-Plugin-Key 的值 + 返回: + 匹配到的插件配置字典(包含 id 字段) + 异常: + 401: 未提供 Plugin Key + 403: Plugin Key 无效或未注册 + """ + if not plugin_key: + raise HTTPException(status_code=401, detail="缺少 X-Plugin-Key Header") + info = _PLUGIN_KEY_INDEX.get(plugin_key) + if not info: + logger.warning("OPEN_API_INVALID_KEY") + raise HTTPException(status_code=403, detail="Plugin Key 无效或未注册") + return info + + +# ========================================== +# 🚦 限流:SQL 分钟计数器,多 worker 共享 +# ========================================== +def _check_rate_limit(plugin_info: dict, permission: str, db: Session) -> None: + """ + 校验插件权限并执行限流。 + + - 权限不足 -> 403 + - 触发限流 -> 429 + """ + plugin_id = plugin_info["id"] + + now = int(time.time()) + window_start = now - (now % 60) + try: + # 对配置列执行 no-op UPDATE:PostgreSQL 获取行锁,SQLite 也会取得写事务锁; + # 再 SELECT FOR UPDATE,保证首次建桶和窗口递增串行,而不是只依赖进程锁。 + db.execute( + text("UPDATE registered_plugins SET rate_limit = rate_limit WHERE plugin_id = :plugin_id"), + {"plugin_id": plugin_id}, + ) + plugin_row = (db.query(RegisteredPlugin) + .filter(RegisteredPlugin.plugin_id == plugin_id) + .populate_existing() + .with_for_update() + .first()) + validate_live_plugin(plugin_row, plugin_info.get("key"), permission) + rate_limit = positive_integer(plugin_row.rate_limit) + bucket = (db.query(OpenApiRateLimitBucket) + .filter(OpenApiRateLimitBucket.plugin_id == plugin_id) + .with_for_update() + .first()) + if not bucket: + bucket = OpenApiRateLimitBucket( + plugin_id=plugin_id, window_start=window_start, request_count=0 + ) + db.add(bucket) + db.flush() + if bucket.window_start != window_start: + bucket.window_start = window_start + bucket.request_count = 0 + if bucket.request_count >= rate_limit: + db.rollback() + raise HTTPException( + status_code=429, + detail=f"请求过于频繁,限流 {rate_limit}/分钟,请稍后再试", + ) + bucket.request_count += 1 + db.commit() + except HTTPException: + db.rollback() + raise + except Exception as exc: + db.rollback() + logger.error(f"OPEN_API_RATE_LIMIT_DB_FAIL | plugin={plugin_id} | error_type={type(exc).__name__}") + raise HTTPException(status_code=503, detail="插件限流服务暂不可用") + + +def _require_plugin(plugin_key: Optional[str], permission: str, db: Session) -> dict: + """统一入口:校验 Plugin Key + 权限 + 限流""" + info = verify_plugin_key(plugin_key or "") + _check_rate_limit(info, permission, db) + return info + + +async def _resolve_user_from_token(token: str) -> str: + """从 Token 中解析出账号;失败时抛 401""" + if not token: + raise HTTPException(status_code=401, detail="缺少用户 Token") + is_valid, account, error_msg = await asyncio.to_thread(verify_token_with_fallback, token) + if not is_valid or not account: + raise HTTPException(status_code=401, detail=error_msg or "Token 无效") + return account + + +def _calc_tx_hash(tx_id: str, account: str, tx_type: str, amount: int, prev_hash: str) -> str: + """计算交易哈希(与 router_wallet.calculate_tx_hash 算法一致)""" + data = f"{tx_id}{account}{tx_type}{amount}{prev_hash}" + return hashlib.sha256(data.encode()).hexdigest() + + +# ========================================== +# 📦 请求模型 +# ========================================== +class VerifyTokenRequest(BaseModel): + token: str = Field(..., description="待验证的用户 JWT Token") + + +class DeductRequest(BaseModel): + token: str = Field(..., description="用户 JWT Token") + amount: int = Field(..., gt=0, strict=True, description="扣款金额(正整数)") + reason: str = Field(..., description="业务标识/扣款原因") + reference_id: Optional[str] = Field(None, description="业务侧参考 ID") + + +class RefundRequest(BaseModel): + token: str = Field(..., description="用户 JWT Token") + transaction_id: str = Field(..., description="原扣款交易 ID") + amount: int = Field(..., gt=0, strict=True, description="退款金额(正整数)") + + +# ========================================== +# 1️⃣ Token 验证:POST /api/open/verify-token +# ========================================== +@router.post("/verify-token") +async def open_verify_token( + req: VerifyTokenRequest, + x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"), + db: Session = Depends(get_db), +): + """ + 供第三方插件验证用户 Token 有效性。 + 成功返回账号与基础资料(昵称、头像)。 + """ + plugin_info = _require_plugin(x_plugin_key, "verify", db) + + is_valid, account, error_msg = await asyncio.to_thread(verify_token_with_fallback, req.token) + if not is_valid or not account: + logger.info(f"OPEN_VERIFY_TOKEN_FAIL | plugin={plugin_info['id']} | category=invalid_token") + return {"valid": False, "error": error_msg or "Token 无效"} + + users_db = json_db.load_data("users.json", default_data={}) + user = users_db.get(account, {}) or {} + + logger.info(f"OPEN_VERIFY_TOKEN | plugin={plugin_info['id']}") + return { + "valid": True, + "account": account, + "name": user.get("name", account), + "avatar": user.get("avatarDataUrl", "") or user.get("avatar", ""), + } + + +# ========================================== +# 2️⃣ 余额查询:GET /api/open/balance +# ========================================== +@router.get("/balance") +async def open_get_balance( + authorization: Optional[str] = Header(None, alias="Authorization"), + x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"), + db: Session = Depends(get_db), +): + """ + 查询用户钱包余额。 + 认证:Authorization: Bearer + X-Plugin-Key + """ + plugin_info = _require_plugin(x_plugin_key, "balance", db) + + if not authorization: + raise HTTPException(status_code=401, detail="缺少 Authorization Header") + parts = authorization.split(" ", 1) + if len(parts) != 2 or parts[0].lower() != "bearer": + raise HTTPException(status_code=401, detail="Authorization 格式错误,请使用 Bearer Token") + + account = await _resolve_user_from_token(parts[1]) + wallet = db.query(Wallet).filter(Wallet.account == account).first() + + if not wallet: + result = { + "account": account, + "balance": 0, + "earn_balance": 0, + "tip_balance": 0, + "task_balance": 0, + "frozen_balance": 0, + } + else: + result = { + "account": account, + "balance": int(wallet.balance or 0), + "earn_balance": int(wallet.earn_balance or 0), + "tip_balance": int(wallet.tip_balance or 0), + "task_balance": int(wallet.task_balance or 0), + "frozen_balance": int(wallet.frozen_balance or 0), + } + + logger.info(f"OPEN_BALANCE | plugin={plugin_info['id']}") + return result + + +# ========================================== +# 3️⃣ 通用扣款:POST /api/open/deduct +# ========================================== +@router.post("/deduct") +async def open_deduct( + req: DeductRequest, + x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"), + db: Session = Depends(get_db), +): + """ + 通用扣款接口(从 wallet.balance 扣减)。 + 流程: + 1. 校验 Plugin Key + 权限 + 限流 + 2. 解析 Token -> account + 3. 共用写锁锁定钱包与实时插件配置,检查额度/余额 + 4. 扣款 + 写入 Transaction(含哈希链) + """ + plugin_info = _require_plugin(x_plugin_key, "deduct", db) + account = await _resolve_user_from_token(req.token) + amount = req.amount + if isinstance(amount, bool) or not isinstance(amount, int) or amount <= 0: + raise HTTPException(status_code=422, detail="扣款金额必须为正整数") + try: + wallet, plugin_row = lock_open_wallet(db, account, plugin_info["id"], x_plugin_key, "deduct") + max_per_tx = positive_integer(plugin_row.max_deduct_per_tx) + max_daily = positive_integer(plugin_row.max_deduct_per_day) + if amount > max_per_tx: + db.rollback() + return JSONResponse(status_code=403, content={ + "error": "AMOUNT_EXCEEDS_LIMIT", "message": f"单笔扣款不能超过{max_per_tx}积分", "max": max_per_tx}) + now = datetime.datetime.utcnow() + day_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + used_today = db.query(func.coalesce(func.sum(func.abs(Transaction.amount)), 0)).filter( + Transaction.tx_type == "OPEN_DEDUCT", Transaction.source_plugin == plugin_info["id"], + Transaction.created_at >= day_start, + Transaction.created_at < day_start + datetime.timedelta(days=1)).scalar() + used_today = quota_total(used_today) + if used_today + amount > max_daily: + db.rollback() + return JSONResponse(status_code=403, content={ + "error": "DAILY_LIMIT_EXCEEDED", "message": "今日扣款已达上限", "limit": max_daily, "used": used_today}) + if wallet is None: + raise HTTPException(status_code=402, detail="余额不足") + if isinstance(wallet.balance, bool) or not isinstance(wallet.balance, int): + raise ValueError("invalid wallet balance") + if wallet.balance < amount: + raise HTTPException(status_code=402, detail="余额不足") + wallet.balance -= amount + tx_id = f"OPEN_DEDUCT_{uuid.uuid4().hex}" + last_tx = db.query(Transaction).filter(Transaction.account == account).order_by( + Transaction.created_at.desc(), Transaction.tx_id.desc()).with_for_update().first() + prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH" + db.add(Transaction(tx_id=tx_id, account=account, tx_type="OPEN_DEDUCT", amount=-amount, + related_account=plugin_info["id"], item_id=req.reference_id, prev_hash=prev_hash, + tx_hash=_calc_tx_hash(tx_id, account, "OPEN_DEDUCT", -amount, prev_hash), + description=f"[{plugin_row.name}] {req.reason}", source_plugin=plugin_info["id"])) + response = {"success": True, "remaining_balance": wallet.balance, "transaction_id": tx_id} + db.commit() + except HTTPException: + db.rollback() + raise + except Exception as error: + db.rollback() + logger.error("OPEN_DEDUCT_FAIL | error_type=%s", type(error).__name__) + raise HTTPException(status_code=500, detail="扣款失败,请稍后重试") + run_tip_effect(lambda: logger.info("OPEN_DEDUCT_COMPLETED")) + return response + + +# ========================================== +# 4️⃣ 退款:POST /api/open/refund +# ========================================== +@router.post("/refund") +async def open_refund( + req: RefundRequest, + x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"), + db: Session = Depends(get_db), +): + """ + 退款接口:将之前 OPEN_DEDUCT 的金额(部分或全额)退还到用户钱包。 + + 校验: + - 原交易必须存在且 tx_type == OPEN_DEDUCT + - 原交易归属当前 token 用户 + - 原交易由当前插件发起(related_account == plugin_id) + - 累计退款金额 ≤ 原扣款金额 + """ + plugin_info = _require_plugin(x_plugin_key, "refund", db) + account = await _resolve_user_from_token(req.token) + refund_amount = req.amount + if isinstance(refund_amount, bool) or not isinstance(refund_amount, int) or refund_amount <= 0: + raise HTTPException(status_code=422, detail="退款金额必须为正整数") + try: + wallet, plugin_row = lock_open_wallet(db, account, plugin_info["id"], x_plugin_key, "refund") + original_tx = db.query(Transaction).filter(Transaction.tx_id == req.transaction_id).populate_existing().with_for_update().first() + if original_tx is None: + raise HTTPException(status_code=404, detail="原交易不存在") + if original_tx.account != account: + raise HTTPException(status_code=403, detail="无权对该交易执行退款") + if original_tx.tx_type != "OPEN_DEDUCT": + raise HTTPException(status_code=400, detail="该交易类型不支持开放退款") + if original_tx.related_account != plugin_info["id"] or original_tx.source_plugin not in (None, plugin_info["id"]): + raise HTTPException(status_code=403, detail="不允许跨插件退款") + if isinstance(original_tx.amount, bool) or not isinstance(original_tx.amount, int) or original_tx.amount >= 0: + raise HTTPException(status_code=503, detail="原交易金额暂时无法核实") + original_amount = -original_tx.amount + # 校验全部关联退款,不能用 abs() 把损坏的负退款/跨账号记录视为合法。 + refunded = db.query(Transaction).filter( + Transaction.tx_type == "OPEN_REFUND", Transaction.item_id == req.transaction_id).all() + if any(row.account != account or row.related_account != plugin_info["id"] or + row.source_plugin not in (None, plugin_info["id"]) or isinstance(row.amount, bool) or + not isinstance(row.amount, int) or row.amount <= 0 for row in refunded): + raise HTTPException(status_code=503, detail="退款流水暂时无法核实") + already_refunded = sum(row.amount for row in refunded) + if already_refunded + refund_amount > original_amount: + raise HTTPException(status_code=400, detail="退款金额超过可退余额") + if wallet is None: + raise HTTPException(status_code=503, detail="钱包记录暂时无法核实") + if isinstance(wallet.balance, bool) or not isinstance(wallet.balance, int): + raise ValueError("invalid wallet balance") + wallet.balance += refund_amount + tx_id = f"OPEN_REFUND_{uuid.uuid4().hex}" + last_tx = db.query(Transaction).filter(Transaction.account == account).order_by( + Transaction.created_at.desc(), Transaction.tx_id.desc()).with_for_update().first() + prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH" + db.add(Transaction(tx_id=tx_id, account=account, tx_type="OPEN_REFUND", amount=refund_amount, + related_account=plugin_info["id"], item_id=req.transaction_id, prev_hash=prev_hash, + tx_hash=_calc_tx_hash(tx_id, account, "OPEN_REFUND", refund_amount, prev_hash), + description=f"[{plugin_row.name}] 退款 {req.transaction_id}", source_plugin=plugin_info["id"])) + response = {"success": True, "remaining_balance": wallet.balance, "transaction_id": tx_id} + db.commit() + except HTTPException: + db.rollback() + raise + except Exception as error: + db.rollback() + logger.error("OPEN_REFUND_FAIL | error_type=%s", type(error).__name__) + raise HTTPException(status_code=500, detail="退款失败,请稍后重试") + run_tip_effect(lambda: logger.info("OPEN_REFUND_COMPLETED")) + return response + + +# ========================================== +# 5️⃣ 消费记录查询:GET /api/open/transactions +# ========================================== +@router.get("/transactions") +async def open_list_transactions( + authorization: Optional[str] = Header(None, alias="Authorization"), + x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"), + page: int = Query(1, ge=1, description="页码(从 1 开始)"), + page_size: int = Query(20, ge=1, le=100, description="每页条数(1-100)"), + start_time: Optional[int] = Query(None, description="起始时间(unix 秒)"), + end_time: Optional[int] = Query(None, description="结束时间(unix 秒)"), + tx_type: Optional[str] = Query(None, description="交易类型,如 OPEN_DEDUCT / OPEN_REFUND"), + db: Session = Depends(get_db), +): + """ + 查询当前用户由当前插件产生的消费/退款记录。 + + 认证:Authorization: Bearer + X-Plugin-Key + 安全:仅返回 source_plugin == 当前 plugin_id 的记录,防止跨插件读取。 + """ + plugin_info = _require_plugin(x_plugin_key, "verify", db) + + if not authorization: + raise HTTPException(status_code=401, detail="缺少 Authorization Header") + parts = authorization.split(" ", 1) + if len(parts) != 2 or parts[0].lower() != "bearer": + raise HTTPException(status_code=401, detail="Authorization 格式错误,请使用 Bearer Token") + account = await _resolve_user_from_token(parts[1]) + + plugin_id = plugin_info["id"] + + # 构造查询:按 source_plugin 过滤;兼容历史 NULL 数据走 related_account 兜底 + q = db.query(Transaction).filter( + Transaction.account == account, + ((Transaction.source_plugin == plugin_id) + | ((Transaction.source_plugin.is_(None)) & (Transaction.related_account == plugin_id))), + ) + + if tx_type: + q = q.filter(Transaction.tx_type == tx_type) + if start_time is not None: + q = q.filter(Transaction.created_at >= datetime.datetime.utcfromtimestamp(int(start_time))) + if end_time is not None: + q = q.filter(Transaction.created_at <= datetime.datetime.utcfromtimestamp(int(end_time))) + + total = q.count() + rows = ( + q.order_by(Transaction.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + + items = [] + for t in rows: + created_ts = int(t.created_at.timestamp()) if t.created_at else 0 + # reason 从 description 中解析(去掉 [插件名] 前缀),兼容旧记录 + reason = (t.description or "").replace(f"[{plugin_info['name']}] ", "").strip() or None + items.append({ + "id": t.tx_id, + "type": t.tx_type, + "amount": int(t.amount or 0), + "reason": reason, + "reference_id": t.item_id, + "created_at": created_ts, + "description": t.description or "", + }) + + logger.info(f"OPEN_LIST_TX | plugin={plugin_id} | page={page}/{page_size} | total={total}") + return { + "success": True, + "transactions": items, + "total": total, + "page": page, + "page_size": page_size, + } + + +# ========================================== +# 6️⃣ 消费汇总:GET /api/open/usage-summary +# ========================================== +@router.get("/usage-summary") +async def open_usage_summary( + x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"), + account: Optional[str] = Query(None, description="指定用户账号,不传则统计所有用户"), + period: str = Query("daily", description="聚合粒度:daily / monthly / total"), + start_time: Optional[int] = Query(None, description="起始时间(unix 秒)"), + end_time: Optional[int] = Query(None, description="结束时间(unix 秒)"), + db: Session = Depends(get_db), +): + """ + 本插件维度的消费汇总统计。 + + 返回:总扣款 / 总退款 / 总笔数 / 净消费 + 按 period 分桶的明细。 + 安全:仅统计 source_plugin == 当前 plugin_id 的记录。 + """ + plugin_info = _require_plugin(x_plugin_key, "verify", db) + plugin_id = plugin_info["id"] + + period = (period or "daily").lower() + if period not in ("daily", "monthly", "total"): + raise HTTPException(status_code=400, detail="period 仅支持 daily / monthly / total") + + base = db.query(Transaction).filter( + ((Transaction.source_plugin == plugin_id) + | ((Transaction.source_plugin.is_(None)) & (Transaction.related_account == plugin_id))), + Transaction.tx_type.in_(["OPEN_DEDUCT", "OPEN_REFUND"]), + ) + if account: + base = base.filter(Transaction.account == account) + if start_time is not None: + base = base.filter(Transaction.created_at >= datetime.datetime.utcfromtimestamp(int(start_time))) + if end_time is not None: + base = base.filter(Transaction.created_at <= datetime.datetime.utcfromtimestamp(int(end_time))) + + rows = base.all() + + total_deduct = 0 + total_refund = 0 + total_count = len(rows) + bucket: dict = {} # key -> {"deduct":int,"refund":int,"count":int} + + for t in rows: + amt = int(t.amount or 0) + if t.tx_type == "OPEN_DEDUCT": + total_deduct += abs(amt) + elif t.tx_type == "OPEN_REFUND": + total_refund += abs(amt) + + if period == "total": + continue + if not t.created_at: + continue + if period == "daily": + key = t.created_at.strftime("%Y-%m-%d") + else: # monthly + key = t.created_at.strftime("%Y-%m") + + b = bucket.setdefault(key, {"deduct": 0, "refund": 0, "count": 0}) + if t.tx_type == "OPEN_DEDUCT": + b["deduct"] += abs(amt) + elif t.tx_type == "OPEN_REFUND": + b["refund"] += abs(amt) + b["count"] += 1 + + breakdown = [] + if period != "total": + for key in sorted(bucket.keys(), reverse=True): + v = bucket[key] + label = "date" if period == "daily" else "month" + breakdown.append({ + label: key, + "deduct": v["deduct"], + "refund": v["refund"], + "count": v["count"], + }) + + logger.info(f"OPEN_USAGE_SUMMARY | plugin={plugin_id} | period={period} | tx_count={total_count}") + return { + "success": True, + "plugin_id": plugin_id, + "plugin_name": plugin_info.get("name", plugin_id), + "summary": { + "total_deduct": total_deduct, + "total_refund": total_refund, + "total_transactions": total_count, + "net_consumption": total_deduct - total_refund, + }, + "breakdown": breakdown, + } diff --git a/router_posts.py b/router_posts.py index e4312da4ce86f2ea0be82076f1889efe3a81fc6e..99d56202206a72670078ab145753de7c9eeaf966 100644 --- a/router_posts.py +++ b/router_posts.py @@ -1,3 +1,4 @@ +from publication_identity import publication_identity # 云端Space代码/router_posts.py # ========================================== # 💬 讨论区API路由(小红书风格图文社区) @@ -9,24 +10,41 @@ from fastapi import APIRouter, HTTPException, Depends, Request from sqlalchemy.orm import Session from models import PostCreate, PostUpdate, RatingRequest import 数据库连接 as db +from comment_store import append_comment, increment_comment_count, attach_comment_counts from 安全认证 import require_auth from db_utils import record_view, sort_cache from database_sql import get_db from models_sql import Wallet, Transaction from notifications import add_notification +from tip_transaction import lock_tip_wallets +from tip_store import run_tip_effect import time +import asyncio +from post_store import publish_post, edit_post, delete_post as remove_post, PostStoreError import uuid import hashlib import datetime import logging # 🔒 频次限制(与 router_users_auth.py / router_wallet.py / router_prompts.py 同一写法) -from slowapi import Limiter -from slowapi.util import get_remote_address -limiter = Limiter(key_func=get_remote_address) +from rate_limit import shared_limit router = APIRouter() + +async def _post_mutation(operation, *args): + try: + return await asyncio.to_thread(operation, *args) + except PostStoreError as error: + raise HTTPException(status_code=error.status_code, detail=error.message) from None + + +def _invalidate_posts(): + try: + sort_cache.invalidate("posts:") + except Exception as error: + logging.getLogger(__name__).warning("帖子排序缓存失效失败: error_type=%s", type(error).__name__) + # ========================================== # 📝 帖子CRUD接口 # ========================================== @@ -87,6 +105,7 @@ async def get_posts(page: int = 1, limit: int = 20, sort: str = "latest"): post_data.pop("viewed_by", None) result.append(post_data) + attach_comment_counts(result) return { "status": "success", "data": result, @@ -115,6 +134,7 @@ async def get_my_posts(current_user: str = Depends(require_auth)): post_data.pop("viewed_by", None) result.append(post_data) + attach_comment_counts(result) return { "status": "success", "data": result @@ -141,6 +161,7 @@ async def get_post_detail(post_id: str): } # 过滤敏感字段 post_data.pop("viewed_by", None) + attach_comment_counts([post_data]) return { "status": "success", "data": post_data @@ -149,13 +170,16 @@ async def get_post_detail(post_id: str): raise HTTPException(status_code=404, detail="帖子不存在") @router.post("/api/posts") -@limiter.limit("5/minute") # 🔒 安全限流:防止刷帖洗版(一次发布含多图上传,但建帖只调一次) +@shared_limit(5) # 🔒 安全限流:防止刷帖洗版(一次发布含多图上传,但建帖只调一次) async def create_post(request: Request, post: PostCreate, current_user: str = Depends(require_auth)): """ 发布帖子 """ - posts_db = db.load_data("posts.json", default_data=[]) - + try: + stable_id = publication_identity('post', current_user, getattr(request, 'headers', {}).get('Idempotency-Key')) + except ValueError: + raise HTTPException(status_code=400, detail='发布请求身份无效') from None + # 限制图片数量 images = (post.images or [])[:9] 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 images = [] new_post = { - "id": f"post_{int(time.time())}_{uuid.uuid4().hex[:6]}", + "id": stable_id or f"post_{int(time.time())}_{uuid.uuid4().hex}", "title": post.title, "content": post.content, "cover_image": post.cover_image, @@ -193,91 +217,43 @@ async def create_post(request: Request, post: PostCreate, current_user: str = De "rated_by": {} } - posts_db.insert(0, new_post) - db.save_data("posts.json", posts_db) - # 🗂️ 清除排序缓存 - sort_cache.invalidate("posts:") + new_post = await _post_mutation(publish_post, db, new_post, stable_id is not None) + _invalidate_posts() return {"status": "success", "data": new_post} @router.put("/api/posts/{post_id}") -async def update_post(post_id: str, update_data: PostUpdate, current_user: str = Depends(require_auth)): +@shared_limit(10) +async def update_post(request: Request, post_id: str, update_data: PostUpdate, current_user: str = Depends(require_auth)): """ 更新帖子(仅作者可操作,原子操作,并发安全) """ - result_holder = {} - - def updater(data): - for post in data: - if post["id"] == post_id: - if post.get("author") != current_user: - result_holder["error"] = "forbidden" - return False - # 更新字段 - if update_data.title is not None: - post["title"] = update_data.title - if update_data.content is not None: - post["content"] = update_data.content - if update_data.cover_image is not None: - post["cover_image"] = update_data.cover_image - if update_data.images is not None: - post["images"] = update_data.images[:9] - if update_data.is_original is not None: - post["is_original"] = update_data.is_original # 🎨 更新原创作品标记 - if update_data.post_type is not None: - post["post_type"] = update_data.post_type - # 如果切换为视频帖,清空图片列表 - if update_data.post_type == "video": - post["images"] = [] - if update_data.video_url is not None: - post["video_url"] = update_data.video_url - result_holder["post"] = post - return True - result_holder["error"] = "not_found" - return False - - db.atomic_update("posts.json", updater, default_data=[]) - - if result_holder.get("error") == "forbidden": - raise HTTPException(status_code=403, detail="无权修改他人帖子") - if result_holder.get("error") == "not_found": - raise HTTPException(status_code=404, detail="帖子不存在") - - # 🗂️ 清除排序缓存 - sort_cache.invalidate("posts:") + changes = {key: getattr(update_data, key) for key in ( + "title", "content", "cover_image", "images", "is_original", "post_type", "video_url")} + await _post_mutation(edit_post, db, post_id, current_user, changes) + _invalidate_posts() return {"status": "success"} @router.delete("/api/posts/{post_id}") -async def delete_post(post_id: str, current_user: str = Depends(require_auth)): - """ - 删除帖子(仅作者可操作) - """ - posts_db = db.load_data("posts.json", default_data=[]) - - for i, post in enumerate(posts_db): - if post["id"] == post_id: - if post.get("author") != current_user: - raise HTTPException(status_code=403, detail="无权删除他人帖子") - - posts_db.pop(i) - db.save_data("posts.json", posts_db) - # 🗂️ 清除排序缓存 - sort_cache.invalidate("posts:") - return {"status": "success"} - - raise HTTPException(status_code=404, detail="帖子不存在") +@shared_limit(10) +async def delete_post(request: Request, post_id: str, current_user: str = Depends(require_auth)): + """删除帖子;锁内确认最新作者,只删除目标主记录。""" + await _post_mutation(remove_post, db, post_id, current_user) + _invalidate_posts() + return {"status": "success"} # ========================================== # ❤️ 互动接口(点赞/收藏) # ========================================== @router.post("/api/posts/{post_id}/rating") -async def rate_post(post_id: str, request: RatingRequest, current_user: str = Depends(require_auth)): +@shared_limit(20) +async def rate_post(request: Request, post_id: str, payload: RatingRequest, current_user: str = Depends(require_auth)): """ 为帖子评分(原子操作,并发安全) ⭐ score: 1-5 """ - score = request.score + score = payload.score if score < 1 or score > 5: raise HTTPException(status_code=400, detail="评分必须在1-5之间") @@ -346,7 +322,8 @@ async def rate_post(post_id: str, request: RatingRequest, current_user: str = De @router.post("/api/posts/{post_id}/like") -async def toggle_like(post_id: str, current_user: str = Depends(require_auth)): +@shared_limit(30) +async def toggle_like(request: Request, post_id: str, current_user: str = Depends(require_auth)): """ 点赞/取消点赞(原子操作,并发安全) """ @@ -380,7 +357,8 @@ async def toggle_like(post_id: str, current_user: str = Depends(require_auth)): return result_container[0] @router.post("/api/posts/{post_id}/favorite") -async def toggle_favorite(post_id: str, current_user: str = Depends(require_auth)): +@shared_limit(30) +async def toggle_favorite(request: Request, post_id: str, current_user: str = Depends(require_auth)): """ 收藏/取消收藏(原子操作,并发安全) """ @@ -425,7 +403,8 @@ def calculate_tx_hash(tx_id, account, tx_type, amount, prev_hash): return hashlib.sha256(data.encode()).hexdigest() @router.post("/api/posts/{post_id}/tip") -async def tip_post(post_id: str, amount: int, is_anon: bool = False, current_user: str = Depends(require_auth), db_session: Session = Depends(get_db)): +@shared_limit(10) +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)): """ 打赏帖子(原子操作,并发安全)- 使用 SQL Wallet 系统 """ @@ -451,6 +430,8 @@ async def tip_post(post_id: str, amount: int, is_anon: bool = False, current_use # 💳 使用 SQL Wallet 系统处理余额转账(先扣款) try: + # 🔒 并发安全:使用悲观锁获取双方钱包 + tipper_wallet, author_wallet = lock_tip_wallets(db_session, current_user, author) # 🔒 P1幂等性防护:检查最近5秒内是否存在相同交易 recent_cutoff = datetime.datetime.utcnow() - datetime.timedelta(seconds=5) 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 Transaction.amount == -amount, Transaction.related_account == author, Transaction.created_at >= recent_cutoff - ).first() + ).with_for_update().first() if duplicate_tx: + db_session.rollback() # 重复请求释放锁,不执行任何后续操作 return {"status": "success", "message": "打赏已处理(重复请求)"} - # 🔒 并发安全:使用悲观锁获取双方钱包 - tipper_wallet = db_session.query(Wallet).filter(Wallet.account == current_user).with_for_update().first() - author_wallet = db_session.query(Wallet).filter(Wallet.account == author).with_for_update().first() - if not tipper_wallet or tipper_wallet.balance < amount: raise HTTPException(status_code=400, detail="余额不足") - if not author_wallet: - # 如果作者钱包不存在,创建一个 - author_wallet = Wallet(account=author, balance=0, earn_balance=0, tip_balance=0, frozen_balance=0) - db_session.add(author_wallet) + # 执行转账 tipper_wallet.balance -= amount @@ -533,8 +508,17 @@ async def tip_post(post_id: str, amount: int, is_anon: bool = False, current_use db_session.commit() # 📝 审计日志 - logger.info(f"POST_TIP | from={current_user} | to={author} | amount={amount} | post={post_id} | anon={is_anon}") + logger.info(f"POST_TIP | post={post_id}") + except HTTPException: + db_session.rollback() + raise + except Exception as e: + db_session.rollback() + logger.error(f"POST_TIP_ERROR | post={post_id} | error_type={type(e).__name__}") + raise HTTPException(status_code=500, detail="打赏处理失败,请稍后重试") + + def notify_tip(): # 🔔 打赏通知(考虑匿名) if not is_anon: add_notification(author, { @@ -552,18 +536,10 @@ async def tip_post(post_id: str, amount: int, is_anon: bool = False, current_use "target_item_title": "", "content": f"您收到了一份 {amount} 积分的匿名帖子打赏" }) - - # 🗂️ 清除排序缓存(打赏可能影响排序) - sort_cache.invalidate("posts:") - - except HTTPException: - db_session.rollback() - raise - except Exception as e: - db_session.rollback() - logger.error(f"POST_TIP_ERROR | from={current_user} | post={post_id} | amount={amount} | error={str(e)}") - raise HTTPException(status_code=500, detail="打赏处理失败,请稍后重试") + run_tip_effect(notify_tip) + run_tip_effect(lambda: sort_cache.invalidate("posts:")) + # 🏆 扣款成功后再写打赏榜(写榜失败仅记录日志,不影响资金安全) def board_updater(data): for post in data: @@ -581,7 +557,7 @@ async def tip_post(post_id: str, amount: int, is_anon: bool = False, current_use try: db.atomic_update("posts.json", board_updater, default_data=[]) except Exception as e: - logger.error(f"POST_TIP_BOARD_ERROR | post={post_id} | error={str(e)}") + logger.error(f"POST_TIP_BOARD_ERROR | post={post_id} | error_type={type(e).__name__}") return {"status": "success", "message": f"成功打赏 {amount} 积分"} @@ -616,7 +592,8 @@ async def get_post_comments(post_id: str): return {"status": "success", "data": result} @router.post("/api/posts/{post_id}/comments") -async def add_post_comment(post_id: str, content: str, current_user: str = Depends(require_auth)): +@shared_limit(10) +async def add_post_comment(request: Request, post_id: str, content: str, current_user: str = Depends(require_auth)): """ 添加帖子评论 """ @@ -624,7 +601,6 @@ async def add_post_comment(post_id: str, content: str, current_user: str = Depen raise HTTPException(status_code=400, detail="评论内容不能为空") posts_db = db.load_data("posts.json", default_data=[]) - comments_db = db.load_data("comments.json", default_data={}) # 检查帖子是否存在 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 "created_at": int(time.time()) } - # comments_db 是 {item_id: [comments]} 格式 - post_comments = comments_db.get(post_id, []) - post_comments.insert(0, new_comment) - comments_db[post_id] = post_comments - db.save_data("comments.json", comments_db) - + append_comment(post_id, new_comment, prepend=True) + increment_comment_count("posts.json", post_id) + # 🗂️ 清除排序缓存(评论数变化可能影响排序) sort_cache.invalidate("posts:") - # 更新帖子评论数 - for post in posts_db: - if post["id"] == post_id: - post["comments"] = post.get("comments", 0) + 1 - break - db.save_data("posts.json", posts_db) - return {"status": "success", "data": new_comment} @router.post("/api/posts/{post_id}/view") -async def record_post_view(post_id: str, current_user: str = Depends(require_auth)): +@shared_limit(60) +async def record_post_view(request: Request, post_id: str, current_user: str = Depends(require_auth)): """ 记录帖子访问量 👁️ 需要用户认证,每个用户只计算一次总访问量,日访问量每次调用都增加 diff --git a/router_prompts.py b/router_prompts.py index 4fc1f484069a90fdc9af338d4d6b731c0fd59360..52f7d772acdc72cad8ea9dbad1673c67cc9ee63b 100644 --- a/router_prompts.py +++ b/router_prompts.py @@ -1,3 +1,4 @@ +from publication_identity import publication_identity # 云端Space代码/router_prompts.py # ========================================== # ✨ 提示词市场API路由(图像/视频提示词) @@ -14,23 +15,29 @@ from fastapi import APIRouter, HTTPException, Depends, Request from sqlalchemy.orm import Session -from models import PromptCreate, PromptUpdate, PromptCategoryAdd +from models import PromptCreate, PromptUpdate, PromptCategoryAdd, PromptPurchaseRequest +from typing import Optional import 数据库连接 as db +from comment_store import append_comment, increment_comment_count, attach_comment_counts from 安全认证 import require_auth, optional_auth, is_admin from db_utils import record_view, sort_cache from database_sql import get_db from models_sql import Wallet, Transaction, Ownership from notifications import add_notification +from tip_transaction import lock_tip_wallets +from purchase_transaction import effective_resource_price, lock_resource_purchase_wallets +from tip_store import run_tip_effect import time +import asyncio +from prompt_store import (PromptStoreError, publish_prompt, edit_prompt, + delete_prompt as remove_prompt, project_price, load_categories, modify_category, read_prompts) import uuid import hashlib import datetime import logging # 🔒 P0安全优化:API限流(与 router_wallet.py 保持一致) -from slowapi import Limiter -from slowapi.util import get_remote_address -limiter = Limiter(key_func=get_remote_address) +from rate_limit import shared_limit router = APIRouter() @@ -95,35 +102,18 @@ def _sanitize_tags(tags) -> list: return cleaned -def _load_categories() -> dict: - """读取分类数据,不存在时用内置默认值初始化并持久化""" - categories = db.load_data("prompt_categories.json", default_data=None) - if not isinstance(categories, dict) or "image" not in categories or "video" not in categories: - categories = { - "image": list(DEFAULT_CATEGORIES["image"]), - "video": list(DEFAULT_CATEGORIES["video"]) - } - db.save_data("prompt_categories.json", categories) - # 存量分类文件补全 music 分组(历史数据只有 image/video 键) - if "music" not in categories: - categories["music"] = list(DEFAULT_CATEGORIES["music"]) - db.save_data("prompt_categories.json", categories) - return categories - - -def _apply_effective_price(prompt: dict) -> None: - """检查 pending_price 是否已过生效时间,若是则更新 price 字段供前端显示(与 router_items.py 同构)""" - pending_price = prompt.get("pending_price") - pending_effective = prompt.get("pending_price_effective_at") - if pending_price is not None and pending_effective: - try: - effective_time = datetime.datetime.fromisoformat(pending_effective) - if datetime.datetime.now() >= effective_time: - prompt["price"] = pending_price - prompt["pending_price"] = None - prompt["pending_price_effective_at"] = None - except (ValueError, TypeError): - pass +async def _prompt_operation(operation, *args, **kwargs): + try: + return await asyncio.to_thread(operation, *args, **kwargs) + except PromptStoreError as error: + raise HTTPException(status_code=error.status_code, detail=error.message) from None + + +def _invalidate_prompts(): + try: + sort_cache.invalidate("prompts:") + except Exception as error: + logger.warning("提示词排序缓存失效失败: error_type=%s", type(error).__name__) 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): 获取提示词分类列表 - prompt_type: image / video / music,不传则返回全部 """ - categories = _load_categories() + categories = await _prompt_operation(load_categories, db, DEFAULT_CATEGORIES) if prompt_type in VALID_PROMPT_TYPES: return {"status": "success", "data": categories.get(prompt_type, [])} return {"status": "success", "data": categories} @router.post("/api/prompt-categories") -@limiter.limit("5/minute") # 🔒 限流:防止分类刷屏 +@shared_limit(5) # 🔒 限流:防止分类刷屏 async def add_prompt_category(request: Request, body: PromptCategoryAdd, current_user: str = Depends(require_auth)): """ 添加提示词分类(仅管理员,入口在云端管理界面,重名去重) @@ -184,38 +174,13 @@ async def add_prompt_category(request: Request, body: PromptCategoryAdd, current if len(name) > MAX_CATEGORY_LEN: raise HTTPException(status_code=400, detail=f"分类名称不能超过{MAX_CATEGORY_LEN}个字") - result_holder = {} - - def updater(data): - if not isinstance(data, dict): - data = { - "image": list(DEFAULT_CATEGORIES["image"]), - "video": list(DEFAULT_CATEGORIES["video"]) - } - cat_list = data.get(body.prompt_type, []) - if name in cat_list: - result_holder["error"] = "duplicate" - return - if len(cat_list) >= MAX_CATEGORY_PER_TYPE: - result_holder["error"] = "too_many" - return - cat_list.append(name) - data[body.prompt_type] = cat_list - result_holder["data"] = cat_list - - db.atomic_update("prompt_categories.json", updater, default_data={}) - - if result_holder.get("error") == "duplicate": - raise HTTPException(status_code=400, detail="该分类已存在") - if result_holder.get("error") == "too_many": - raise HTTPException(status_code=400, detail=f"分类数量已达上限({MAX_CATEGORY_PER_TYPE})") - - logger.info(f"PROMPT_CATEGORY_ADD | user={current_user} | type={body.prompt_type} | name={name}") - return {"status": "success", "data": result_holder["data"]} + values = await _prompt_operation(modify_category, db, DEFAULT_CATEGORIES, body.prompt_type, name, maximum=MAX_CATEGORY_PER_TYPE) + logger.info("PROMPT_CATEGORY_ADD | type=%s", body.prompt_type) + return {"status": "success", "data": values} @router.delete("/api/prompt-categories") -@limiter.limit("5/minute") # 🔒 限流:与添加分类一致 +@shared_limit(5) # 🔒 限流:与添加分类一致 async def delete_prompt_category(request: Request, prompt_type: str = None, name: str = None, current_user: str = Depends(require_auth)): """ @@ -233,27 +198,9 @@ async def delete_prompt_category(request: Request, prompt_type: str = None, name if not name: raise HTTPException(status_code=400, detail="分类名称不能为空") - result_holder = {} - - def updater(data): - if not isinstance(data, dict): - result_holder["error"] = "not_found" - return - cat_list = data.get(prompt_type, []) - if name not in cat_list: - result_holder["error"] = "not_found" - return - cat_list.remove(name) - data[prompt_type] = cat_list - result_holder["data"] = cat_list - - db.atomic_update("prompt_categories.json", updater, default_data={}) - - if result_holder.get("error") == "not_found": - raise HTTPException(status_code=404, detail="分类不存在") - - logger.info(f"PROMPT_CATEGORY_DELETE | user={current_user} | type={prompt_type} | name={name}") - return {"status": "success", "data": result_holder["data"]} + values = await _prompt_operation(modify_category, db, DEFAULT_CATEGORIES, prompt_type, name, delete=True) + logger.info("PROMPT_CATEGORY_DELETE | type=%s", prompt_type) + return {"status": "success", "data": values} # ========================================== @@ -285,7 +232,7 @@ async def get_prompts(prompt_type: str = None, category: str = None, - sort: latest / likes / favorites / views / daily_views / tips ⚠️ 列表接口永不下发 prompt_text(付费内容脱敏) """ - prompts_db = db.load_data("prompts.json", default_data=[]) + prompts_db = await _prompt_operation(read_prompts, db) users_db = db.load_data("users.json", default_data={}) user_map = users_db @@ -322,13 +269,11 @@ async def get_prompts(prompt_type: str = None, category: str = None, # 附加作者信息,并过滤敏感字段 result = [] - price_updated = False for prompt in paged_prompts: - # 💰 检查延迟价格是否已生效(与资源列表同构) - old_price = prompt.get("price", 0) - _apply_effective_price(prompt) - if prompt.get("price", 0) != old_price: - price_updated = True + try: + prompt = project_price(prompt) + except Exception: + raise HTTPException(status_code=503, detail="提示词暂时不可读取") from None author_info = user_map.get(prompt.get("author"), {}) prompt_data = { **prompt, @@ -340,9 +285,7 @@ async def get_prompts(prompt_type: str = None, category: str = None, prompt_data.pop("viewed_by", None) result.append(prompt_data) - if price_updated: - db.save_data("prompts.json", prompts_db) - + attach_comment_counts(result) return { "status": "success", "data": result, @@ -358,26 +301,22 @@ async def get_my_prompts(current_user: str = Depends(require_auth)): 获取我发布的提示词(个人中心用) 作者本人可见自己的 prompt_text """ - prompts_db = db.load_data("prompts.json", default_data=[]) + prompts_db = await _prompt_operation(read_prompts, db) my_prompts = [p for p in prompts_db if p.get("author") == current_user] my_prompts = sorted(my_prompts, key=lambda x: x.get("created_at", 0), reverse=True) result = [] - price_updated = False for prompt in my_prompts: - # 💰 检查延迟价格是否已生效 - old_price = prompt.get("price", 0) - _apply_effective_price(prompt) - if prompt.get("price", 0) != old_price: - price_updated = True + try: + prompt = project_price(prompt) + except Exception: + raise HTTPException(status_code=503, detail="提示词暂时不可读取") from None prompt_data = dict(prompt) prompt_data.pop("viewed_by", None) result.append(prompt_data) - if price_updated: - db.save_data("prompts.json", prompts_db) - + attach_comment_counts(result) return {"status": "success", "data": result} @@ -388,42 +327,54 @@ async def get_prompt_detail(prompt_id: str, current_user: str = Depends(optional 获取提示词详情 🔒 仅当 ①免费 ②作者本人 ③已购买 时下发 prompt_text """ - prompts_db = db.load_data("prompts.json", default_data=[]) + try: + prompts_db = db.load_data("prompts.json", default_data=[], strict=True, skip_cache=True) + if not isinstance(prompts_db, list): + raise ValueError("invalid prompt collection") + except Exception: + raise HTTPException(status_code=503, detail="提示词暂时不可读取") users_db = db.load_data("users.json", default_data={}) user_map = users_db for prompt in prompts_db: - if prompt["id"] == prompt_id: - # 💰 检查延迟价格是否已生效 - old_price = prompt.get("price", 0) - _apply_effective_price(prompt) - if prompt.get("price", 0) != old_price: - db.save_data("prompts.json", prompts_db) + if isinstance(prompt, dict) and prompt.get("id") == prompt_id: + try: + price = effective_resource_price(prompt) + except Exception: + raise HTTPException(status_code=503, detail="提示词暂时不可读取") author_info = user_map.get(prompt.get("author"), {}) prompt_data = { **prompt, + "price": price, "author_name": author_info.get("name", prompt.get("author")), "author_avatar": author_info.get("avatarDataUrl", "") } prompt_data.pop("viewed_by", None) - price = int(prompt.get("price", 0) or 0) is_author = current_user and current_user == prompt.get("author") can_view = price <= 0 or is_author or _user_owns_prompt(db_session, current_user, prompt_id) prompt_data["owned"] = bool(can_view) + prompt_data["price_confirmation_supported"] = True _mask_prompt_text(prompt_data, can_view) + attach_comment_counts([prompt_data]) return {"status": "success", "data": prompt_data} raise HTTPException(status_code=404, detail="提示词不存在") @router.post("/api/prompts") -async def create_prompt(prompt: PromptCreate, current_user: str = Depends(require_auth)): +@shared_limit(5) +async def create_prompt(request: Request, prompt: PromptCreate, current_user: str = Depends(require_auth)): """ 发布提示词 """ + try: + stable_id = publication_identity('prompt', current_user, getattr(request, 'headers', {}).get('Idempotency-Key')) + except ValueError: + raise HTTPException(status_code=400, detail='发布请求身份无效') from None + # 参数校验 if prompt.prompt_type not in VALID_PROMPT_TYPES: raise HTTPException(status_code=400, detail="提示词类型必须是 image、video 或 music") @@ -443,12 +394,12 @@ async def create_prompt(prompt: PromptCreate, current_user: str = Depends(requir raise HTTPException(status_code=400, detail="请选择分类") # 分类必须存在于分类库 - categories = _load_categories() + categories = await _prompt_operation(load_categories, db, DEFAULT_CATEGORIES) if category not in categories.get(prompt.prompt_type, []): raise HTTPException(status_code=400, detail="所选分类不存在") - price = int(prompt.price or 0) - if price < 0 or price > MAX_PRICE: + price = prompt.price + if type(price) is not int or not 0 <= price <= MAX_PRICE: raise HTTPException(status_code=400, detail=f"定价必须在0-{MAX_PRICE}之间") 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 audio_url = None new_prompt = { - "id": f"prompt_{int(time.time())}_{uuid.uuid4().hex[:6]}", + "id": stable_id or f"prompt_{int(time.time())}_{uuid.uuid4().hex}", "prompt_type": prompt.prompt_type, "category": category, "title": title, @@ -495,106 +446,18 @@ async def create_prompt(prompt: PromptCreate, current_user: str = Depends(requir "tags": _sanitize_tags(prompt.tags) } - prompts_db = db.load_data("prompts.json", default_data=[]) - prompts_db.insert(0, new_prompt) - db.save_data("prompts.json", prompts_db) - # 🗂️ 清除排序缓存 - sort_cache.invalidate("prompts:") + new_prompt = await _prompt_operation(publish_prompt, db, new_prompt, stable_id is not None) + _invalidate_prompts() return {"status": "success", "data": new_prompt} @router.put("/api/prompts/{prompt_id}") -async def update_prompt(prompt_id: str, update_data: PromptUpdate, current_user: str = Depends(require_auth)): +@shared_limit(10) +async def update_prompt(request: Request, prompt_id: str, update_data: PromptUpdate, current_user: str = Depends(require_auth)): """ 更新提示词(仅作者可操作,原子操作,并发安全) """ - result_holder = {} - - def updater(data): - for prompt in data: - if prompt["id"] == prompt_id: - if prompt.get("author") != current_user: - result_holder["error"] = "forbidden" - return False - # 更新字段 - if update_data.title is not None: - title = update_data.title.strip() - if not title or len(title) > MAX_TITLE_LEN: - result_holder["error"] = "invalid_title" - return False - prompt["title"] = title - if update_data.content is not None: - content = update_data.content.strip() - if len(content) > MAX_CONTENT_LEN: - result_holder["error"] = "invalid_content" - return False - prompt["content"] = content - if update_data.prompt_text is not None: - prompt_text = update_data.prompt_text.strip() - if not prompt_text or len(prompt_text) > MAX_PROMPT_TEXT_LEN: - result_holder["error"] = "invalid_prompt_text" - return False - prompt["prompt_text"] = prompt_text - if update_data.category is not None: - category = update_data.category.strip() - categories = _load_categories() - if category not in categories.get(prompt.get("prompt_type"), []): - result_holder["error"] = "invalid_category" - return False - prompt["category"] = category - if update_data.cover_image is not None: - prompt["cover_image"] = update_data.cover_image - if update_data.price is not None: - price = int(update_data.price) - if price < 0 or price > MAX_PRICE: - result_holder["error"] = "invalid_price" - return False - current_price = int(prompt.get("price", 0) or 0) - if current_price != price: - # 设置待生效价格,24小时后生效(与资源改价机制同构;提示词不支持退款,无需禁购配套) - effective_time = datetime.datetime.now() + datetime.timedelta(hours=24) - prompt["pending_price"] = price - prompt["pending_price_effective_at"] = effective_time.isoformat() - # 不立即修改 price,等待24小时后生效 - else: - # 价格未变,清除待生效价格 - prompt["pending_price"] = None - prompt["pending_price_effective_at"] = None - if update_data.media_type is not None: - if update_data.media_type not in ("image", "video"): - result_holder["error"] = "invalid_media_type" - return False - prompt["media_type"] = update_data.media_type - if update_data.media_type == "image": - # 图像类型不保留视频(与 create 逻辑一致) - prompt["video_url"] = None - if update_data.video_url is not None: - prompt["video_url"] = update_data.video_url - if update_data.video_url: - prompt["media_type"] = "video" - if update_data.audio_url is not None: - # 🎵 音频仅音乐提示词保留;传空字符串/null 表示清除 - audio_url = (update_data.audio_url or "").strip() or None - prompt["audio_url"] = audio_url if prompt.get("prompt_type") == "music" else None - # 类型变更后补正关联字段(pydantic 无法区分未传与 null,统一在此收尾) - if prompt.get("media_type") == "image": - prompt["video_url"] = None - else: - prompt["images"] = [] - if update_data.images is not None: - prompt["images"] = _sanitize_images(update_data.images) - if update_data.is_original is not None: - prompt["is_original"] = bool(update_data.is_original) - if update_data.tags is not None: - prompt["tags"] = _sanitize_tags(update_data.tags) - result_holder["prompt"] = prompt - return True - result_holder["error"] = "not_found" - return False - - db.atomic_update("prompts.json", updater, default_data=[]) - error_map = { "forbidden": (403, "无权修改他人提示词"), "not_found": (404, "提示词不存在"), @@ -605,35 +468,87 @@ async def update_prompt(prompt_id: str, update_data: PromptUpdate, current_user: "invalid_price": (400, f"定价必须在0-{MAX_PRICE}之间"), "invalid_media_type": (400, "媒体类型必须是 image 或 video") } - error = result_holder.get("error") - if error: - code, detail = error_map.get(error, (400, "参数错误")) - raise HTTPException(status_code=code, detail=detail) + categories = await _prompt_operation(load_categories, db, DEFAULT_CATEGORIES) if update_data.category is not None else None + + def apply(prompt): + # 更新字段 + if update_data.title is not None: + title = update_data.title.strip() + if not title or len(title) > MAX_TITLE_LEN: + raise PromptStoreError(*error_map["invalid_title"]) + prompt["title"] = title + if update_data.content is not None: + content = update_data.content.strip() + if len(content) > MAX_CONTENT_LEN: + raise PromptStoreError(*error_map["invalid_content"]) + prompt["content"] = content + if update_data.prompt_text is not None: + prompt_text = update_data.prompt_text.strip() + if not prompt_text or len(prompt_text) > MAX_PROMPT_TEXT_LEN: + raise PromptStoreError(*error_map["invalid_prompt_text"]) + prompt["prompt_text"] = prompt_text + if update_data.category is not None: + category = update_data.category.strip() + if category not in categories.get(prompt.get("prompt_type"), []): + raise PromptStoreError(*error_map["invalid_category"]) + prompt["category"] = category + if update_data.cover_image is not None: + prompt["cover_image"] = update_data.cover_image + if update_data.price is not None: + price = update_data.price + if type(price) is not int or not 0 <= price <= MAX_PRICE: + raise PromptStoreError(*error_map["invalid_price"]) + current_price = effective_resource_price(prompt) + if current_price != price: + # 设置待生效价格,24小时后生效(与资源改价机制同构;提示词不支持退款,无需禁购配套) + effective_time = datetime.datetime.now() + datetime.timedelta(hours=24) + prompt["pending_price"] = price + prompt["pending_price_effective_at"] = effective_time.isoformat() + # 不立即修改 price,等待24小时后生效 + else: + # 价格未变,清除待生效价格 + prompt["pending_price"] = None + prompt["pending_price_effective_at"] = None + if update_data.media_type is not None: + if update_data.media_type not in ("image", "video"): + raise PromptStoreError(*error_map["invalid_media_type"]) + prompt["media_type"] = update_data.media_type + if update_data.media_type == "image": + # 图像类型不保留视频(与 create 逻辑一致) + prompt["video_url"] = None + if update_data.video_url is not None: + prompt["video_url"] = update_data.video_url + if update_data.video_url: + prompt["media_type"] = "video" + if update_data.audio_url is not None or "audio_url" in getattr(update_data, "model_fields_set", set()): + # 🎵 音频仅音乐提示词保留;显式空字符串/null 清除,未传保持 + audio_url = (update_data.audio_url or "").strip() or None + prompt["audio_url"] = audio_url if prompt.get("prompt_type") == "music" else None + if update_data.images is not None: + prompt["images"] = _sanitize_images(update_data.images) + if update_data.is_original is not None: + prompt["is_original"] = bool(update_data.is_original) + if update_data.tags is not None: + prompt["tags"] = _sanitize_tags(update_data.tags) + if prompt.get("media_type") == "image": + prompt["video_url"] = None + else: + prompt["images"] = [] - # 🗂️ 清除排序缓存 - sort_cache.invalidate("prompts:") + await _prompt_operation(edit_prompt, db, prompt_id, current_user, apply) + _invalidate_prompts() return {"status": "success"} @router.delete("/api/prompts/{prompt_id}") -async def delete_prompt(prompt_id: str, current_user: str = Depends(require_auth)): +@shared_limit(10) +async def delete_prompt(request: Request, prompt_id: str, current_user: str = Depends(require_auth)): """ 删除提示词(仅作者可操作) """ - prompts_db = db.load_data("prompts.json", default_data=[]) - - for i, prompt in enumerate(prompts_db): - if prompt["id"] == prompt_id: - if prompt.get("author") != current_user: - raise HTTPException(status_code=403, detail="无权删除他人提示词") - - prompts_db.pop(i) - db.save_data("prompts.json", prompts_db) - # 🗂️ 清除排序缓存 - sort_cache.invalidate("prompts:") - return {"status": "success"} - - raise HTTPException(status_code=404, detail="提示词不存在") + await _prompt_operation(remove_prompt, db, prompt_id, current_user) + _invalidate_prompts() + return {"status": "success"} # ========================================== @@ -641,7 +556,8 @@ async def delete_prompt(prompt_id: str, current_user: str = Depends(require_auth # ========================================== @router.post("/api/prompts/{prompt_id}/like") -async def toggle_like(prompt_id: str, current_user: str = Depends(require_auth)): +@shared_limit(30) +async def toggle_like(request: Request, prompt_id: str, current_user: str = Depends(require_auth)): """ 点赞/取消点赞(原子操作,并发安全) """ @@ -676,7 +592,8 @@ async def toggle_like(prompt_id: str, current_user: str = Depends(require_auth)) @router.post("/api/prompts/{prompt_id}/favorite") -async def toggle_favorite(prompt_id: str, current_user: str = Depends(require_auth)): +@shared_limit(30) +async def toggle_favorite(request: Request, prompt_id: str, current_user: str = Depends(require_auth)): """ 收藏/取消收藏(原子操作,并发安全) """ @@ -715,7 +632,8 @@ async def toggle_favorite(prompt_id: str, current_user: str = Depends(require_au # ========================================== @router.post("/api/prompts/{prompt_id}/tip") -async def tip_prompt(prompt_id: str, amount: int, is_anon: bool = False, +@shared_limit(10) +async def tip_prompt(request: Request, prompt_id: str, amount: int, is_anon: bool = False, current_user: str = Depends(require_auth), db_session: Session = Depends(get_db)): """ 打赏提示词(原子操作,并发安全)- 使用 SQL Wallet 系统 @@ -742,6 +660,8 @@ async def tip_prompt(prompt_id: str, amount: int, is_anon: bool = False, # 💳 使用 SQL Wallet 系统处理余额转账(先扣款) try: + # 🔒 并发安全:使用悲观锁获取双方钱包 + tipper_wallet, author_wallet = lock_tip_wallets(db_session, current_user, author) # 🔒 P1幂等性防护:检查最近5秒内是否存在相同交易 recent_cutoff = datetime.datetime.utcnow() - datetime.timedelta(seconds=5) duplicate_tx = db_session.query(Transaction).filter( @@ -750,20 +670,13 @@ async def tip_prompt(prompt_id: str, amount: int, is_anon: bool = False, Transaction.amount == -amount, Transaction.related_account == author, Transaction.created_at >= recent_cutoff - ).first() + ).with_for_update().first() if duplicate_tx: + db_session.rollback() # 重复请求释放锁,不执行任何后续操作 return {"status": "success", "message": "打赏已处理(重复请求)"} - # 🔒 并发安全:使用悲观锁获取双方钱包 - tipper_wallet = db_session.query(Wallet).filter(Wallet.account == current_user).with_for_update().first() - author_wallet = db_session.query(Wallet).filter(Wallet.account == author).with_for_update().first() - if not tipper_wallet or tipper_wallet.balance < amount: raise HTTPException(status_code=400, detail="余额不足") - if not author_wallet: - # 如果作者钱包不存在,创建一个 - author_wallet = Wallet(account=author, balance=0, earn_balance=0, tip_balance=0, frozen_balance=0) - db_session.add(author_wallet) # 执行转账 tipper_wallet.balance -= amount @@ -824,8 +737,17 @@ async def tip_prompt(prompt_id: str, amount: int, is_anon: bool = False, db_session.commit() # 📝 审计日志 - logger.info(f"PROMPT_TIP | from={current_user} | to={author} | amount={amount} | prompt={prompt_id} | anon={is_anon}") + logger.info(f"PROMPT_TIP | prompt={prompt_id}") + + except HTTPException: + db_session.rollback() + raise + except Exception as e: + db_session.rollback() + logger.error(f"PROMPT_TIP_ERROR | prompt={prompt_id} | error_type={type(e).__name__}") + raise HTTPException(status_code=500, detail="打赏处理失败,请稍后重试") + def notify_tip(): # 🔔 打赏通知(考虑匿名) if not is_anon: add_notification(author, { @@ -844,16 +766,8 @@ async def tip_prompt(prompt_id: str, amount: int, is_anon: bool = False, "content": f"您收到了一份 {amount} 积分的匿名提示词打赏" }) - # 🗂️ 清除排序缓存(打赏可能影响排序) - sort_cache.invalidate("prompts:") - - except HTTPException: - db_session.rollback() - raise - except Exception as e: - db_session.rollback() - logger.error(f"PROMPT_TIP_ERROR | from={current_user} | prompt={prompt_id} | amount={amount} | error={str(e)}") - raise HTTPException(status_code=500, detail="打赏处理失败,请稍后重试") + run_tip_effect(notify_tip) + run_tip_effect(lambda: sort_cache.invalidate("prompts:")) # 🏆 扣款成功后再写打赏榜(写榜失败仅记录日志,不影响资金安全) def board_updater(data): @@ -872,7 +786,7 @@ async def tip_prompt(prompt_id: str, amount: int, is_anon: bool = False, try: db.atomic_update("prompts.json", board_updater, default_data=[]) except Exception as e: - logger.error(f"PROMPT_TIP_BOARD_ERROR | prompt={prompt_id} | error={str(e)}") + logger.error(f"PROMPT_TIP_BOARD_ERROR | prompt={prompt_id} | error_type={type(e).__name__}") return {"status": "success", "message": f"成功打赏 {amount} 积分"} @@ -882,164 +796,108 @@ async def tip_prompt(prompt_id: str, amount: int, is_anon: bool = False, # ========================================== @router.post("/api/prompts/{prompt_id}/purchase") -@limiter.limit("10/minute") # 🔒 P0安全优化:购买每分钟最多10次 +@shared_limit(10) # 🔒 P0安全优化:购买每分钟最多10次 async def purchase_prompt(request: Request, prompt_id: str, + purchase: Optional[PromptPurchaseRequest] = None, current_user: str = Depends(require_auth), db_session: Session = Depends(get_db)): - """ - 购买提示词解锁正文(与 /api/wallet/purchase 逻辑对齐) - - 扣费、所有权、PURCHASE/SALE 交易记录、通知一步到位 - - 成功后直接返回 prompt_text,前端立即展示 - """ - prompts_db = db.load_data("prompts.json", default_data=[]) - prompt = next((p for p in prompts_db if p["id"] == prompt_id), None) - - if not prompt: - raise HTTPException(status_code=404, detail="提示词不存在") - - # 💰 应用延迟生效价格(与 /api/wallet/purchase 同构),确保扣费价与前端显示价一致 - old_price = prompt.get("price", 0) - _apply_effective_price(prompt) - if prompt.get("price", 0) != old_price: - db.save_data("prompts.json", prompts_db) - - price = int(prompt.get("price", 0) or 0) - seller_account = prompt.get("author") - - if price <= 0 or current_user == seller_account: - # ☁️ 免费提示词或作者本人,确保 Ownership 记录存在 - existing_ownership = db_session.query(Ownership).filter( - Ownership.account == current_user, - Ownership.item_id == prompt_id, - Ownership.is_refunded == False - ).first() - if not existing_ownership: - new_ownership = Ownership( - account=current_user, - item_id=prompt_id, - price_paid=0 - ) - db_session.add(new_ownership) - db_session.commit() - return { - "status": "success", - "already_owned": True, - "prompt_text": prompt.get("prompt_text", "") - } - - # 检查是否已拥有(排除已退款的记录) - owned = db_session.query(Ownership).filter( - Ownership.account == current_user, - Ownership.item_id == prompt_id, - Ownership.is_refunded == False - ).first() - if owned: - return { - "status": "success", - "already_owned": True, - "prompt_text": prompt.get("prompt_text", "") - } + """资金与所有权原子提交;通知失败不改变已提交的购买结果。""" + try: + prompts_db = db.load_data("prompts.json", default_data=[], strict=True, skip_cache=True) + if not isinstance(prompts_db, list): + raise ValueError("invalid prompt collection") + prompt = next((p for p in prompts_db if isinstance(p, dict) and p.get("id") == prompt_id), None) + if prompt is None: + raise HTTPException(status_code=404, detail="提示词不存在") + price = effective_resource_price(prompt) + seller_account = prompt.get("author") + prompt_text = prompt.get("prompt_text") + if not isinstance(seller_account, str) or not seller_account.strip() or not isinstance(prompt_text, str): + raise ValueError("invalid prompt delivery") + response = {"status": "success", "already_owned": True, "prompt_text": prompt_text} + except HTTPException: + raise + except Exception as error: + logger.error("PROMPT_PURCHASE_SOURCE_ERROR | error_type=%s", type(error).__name__) + raise HTTPException(status_code=503, detail="提示词暂时不可购买") - # 🔒 P1并发安全:使用悲观锁+异常处理+事务回滚防止双花问题 + # 非资金展示资料在写锁外读取;失败不影响正文交付或交易。 try: - buyer_wallet = db_session.query(Wallet).filter(Wallet.account == current_user).with_for_update().first() + users_db = db.load_data("users.json", default_data={}) + seller_info = users_db.get(seller_account, {}) + buyer_info = users_db.get(current_user, {}) + seller_user_name = seller_info.get("name", seller_account) + buyer_user_name = buyer_info.get("name", current_user) + except Exception: + seller_user_name, buyer_user_name = seller_account, current_user - # 🔒 锁内二次检查:串行化后重查 Ownership,彻底消除并发重复购买竞态 - owned_locked = db_session.query(Ownership).filter( + free = price == 0 or current_user == seller_account + try: + # 必须是本次事务的第一项 SQL;SQLite 使用 BEGIN IMMEDIATE。 + buyer_wallet, seller_wallet = lock_resource_purchase_wallets( + db_session, current_user, seller_account, free) + owned = db_session.query(Ownership).filter( Ownership.account == current_user, Ownership.item_id == prompt_id, Ownership.is_refunded == False ).first() - if owned_locked: + if owned: db_session.rollback() - return { - "status": "success", - "already_owned": True, - "prompt_text": prompt.get("prompt_text", "") - } - - if not buyer_wallet or buyer_wallet.balance < price: + return response + if free: + db_session.add(Ownership(account=current_user, item_id=prompt_id, price_paid=0)) + db_session.commit() + return response + expected_price = getattr(purchase, "expected_price", None) + if expected_price is None: + raise HTTPException(status_code=428, detail="purchase_price_confirmation_required") + if isinstance(expected_price, bool) or not isinstance(expected_price, int) or expected_price < 0: + raise HTTPException(status_code=422, detail="purchase_price_invalid") + if expected_price != price: + raise HTTPException(status_code=409, detail="purchase_price_changed") + if buyer_wallet is None or buyer_wallet.balance < price: raise HTTPException(status_code=402, detail="余额不足,请先充值") - - seller_wallet = db_session.query(Wallet).filter(Wallet.account == seller_account).with_for_update().first() - if not seller_wallet: - seller_wallet = Wallet(account=seller_account, balance=0, earn_balance=0, tip_balance=0, frozen_balance=0) - db_session.add(seller_wallet) - - buyer_wallet.balance = int(buyer_wallet.balance or 0) - int(price) - seller_wallet.balance = int(seller_wallet.balance or 0) + int(price) # 实际收入进统一余额 - seller_wallet.earn_balance = int(seller_wallet.earn_balance or 0) + int(price) # 累计销售收益统计(只增不减) - - # 记录所有权与实付价格 - new_ownership = Ownership(account=current_user, item_id=prompt_id, price_paid=price) - db_session.add(new_ownership) - - tx_id = f"BUY_{int(time.time())}_{uuid.uuid4().hex[:6]}" - last_tx = db_session.query(Transaction).filter(Transaction.account == current_user).order_by(Transaction.created_at.desc()).first() - prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH" - tx_hash = calculate_tx_hash(tx_id, current_user, "PURCHASE", -price, prev_hash) - - # 获取卖家用户名 - users_db = db.load_data("users.json", default_data={}) - seller_info = users_db.get(seller_account, {}) - seller_user_name = seller_info.get("name", seller_account) - - # 创建交易记录 (字段名为 related_account,与 models_sql.py 中 Transaction 模型保持一致) - new_tx = Transaction( - tx_id=tx_id, account=current_user, tx_type="PURCHASE", amount=-price, - related_account=seller_account, item_id=prompt_id, prev_hash=prev_hash, tx_hash=tx_hash, - description=f"购买提示词 {prompt.get('title', '未知提示词')}", - item_title=prompt.get('title', ''), - item_type="prompt", - related_user_name=seller_user_name - ) - db_session.add(new_tx) - - # 为卖家生成销售收入记录 - buyer_info = users_db.get(current_user, {}) - buyer_user_name = buyer_info.get("name", current_user) - seller_tx_id = f"SALE_{int(time.time())}_{uuid.uuid4().hex[:6]}" - seller_tx = Transaction( - tx_id=seller_tx_id, - account=seller_account, - tx_type="SALE", - amount=price, - related_account=current_user, - item_id=prompt_id, - item_title=prompt.get('title', ''), - item_type="prompt", - related_user_name=buyer_user_name, - description=f"销售收入: {prompt.get('title', prompt_id)}" - ) - db_session.add(seller_tx) + for amount in (buyer_wallet.balance, seller_wallet.balance, seller_wallet.earn_balance): + if isinstance(amount, bool) or not isinstance(amount, int): + raise ValueError("invalid wallet amount") + buyer_wallet.balance -= price + seller_wallet.balance += price + seller_wallet.earn_balance += price + db_session.add(Ownership(account=current_user, item_id=prompt_id, price_paid=price)) + + for account, other, tx_type, amount, user_name, description in ( + (current_user, seller_account, "PURCHASE", -price, seller_user_name, + f"购买提示词 {prompt.get('title', '未知提示词')}"), + (seller_account, current_user, "SALE", price, buyer_user_name, + f"销售收入: {prompt.get('title', prompt_id)}") + ): + last_tx = db_session.query(Transaction).filter(Transaction.account == account).order_by( + Transaction.created_at.desc(), Transaction.tx_id.desc()).with_for_update().first() + prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH" + tx_id = f"{tx_type}_{uuid.uuid4().hex}" + db_session.add(Transaction( + tx_id=tx_id, account=account, tx_type=tx_type, amount=amount, + related_account=other, item_id=prompt_id, prev_hash=prev_hash, + tx_hash=calculate_tx_hash(tx_id, account, tx_type, amount, prev_hash), + description=description, item_title=prompt.get("title", ""), + item_type="prompt", related_user_name=user_name)) + response["already_owned"] = False db_session.commit() - - # 📝 P2优化:购买审计日志 - logger.info(f"PROMPT_PURCHASE | buyer={current_user} | seller={seller_account} | prompt={prompt_id} | amount={price} | tx={tx_id}") - - # 🔔 通知卖家提示词被购买 - if seller_account and seller_account != current_user: - add_notification(seller_account, { - "type": "purchase", - "from_user": current_user, - "target_item_id": prompt_id, - "target_item_title": prompt.get("title", ""), - "content": f"您的提示词《{prompt.get('title', '')}》已被购买" - }) - - # 购买成功后直接返回提示词正文 - return { - "status": "success", - "already_owned": False, - "prompt_text": prompt.get("prompt_text", "") - } except HTTPException: db_session.rollback() raise - except Exception as e: + except Exception as error: db_session.rollback() - logger.error(f"PROMPT_PURCHASE_ERROR | buyer={current_user} | prompt={prompt_id} | error={str(e)}") + logger.error("PROMPT_PURCHASE_ERROR | error_type=%s", type(error).__name__) raise HTTPException(status_code=500, detail="购买处理失败,请稍后重试") + run_tip_effect(lambda: logger.info("PROMPT_PURCHASE_COMPLETED")) + run_tip_effect(lambda: add_notification(seller_account, { + "type": "purchase", "from_user": current_user, + "target_item_id": prompt_id, "target_item_title": prompt.get("title", ""), + "content": f"您的提示词《{prompt.get('title', '')}》已被购买" + })) + return response + # ========================================== # 💬 评论接口(复用通用评论存储 comments.json) @@ -1071,7 +929,8 @@ async def get_prompt_comments(prompt_id: str): @router.post("/api/prompts/{prompt_id}/comments") -async def add_prompt_comment(prompt_id: str, content: str, current_user: str = Depends(require_auth)): +@shared_limit(10) +async def add_prompt_comment(request: Request, prompt_id: str, content: str, current_user: str = Depends(require_auth)): """ 添加提示词评论 """ @@ -1079,7 +938,6 @@ async def add_prompt_comment(prompt_id: str, content: str, current_user: str = D raise HTTPException(status_code=400, detail="评论内容不能为空") prompts_db = db.load_data("prompts.json", default_data=[]) - comments_db = db.load_data("comments.json", default_data={}) # 检查提示词是否存在 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 "created_at": int(time.time()) } - # comments_db 是 {item_id: [comments]} 格式 - prompt_comments = comments_db.get(prompt_id, []) - prompt_comments.insert(0, new_comment) - comments_db[prompt_id] = prompt_comments - db.save_data("comments.json", comments_db) + append_comment(prompt_id, new_comment, prepend=True) + increment_comment_count("prompts.json", prompt_id) # 🗂️ 清除排序缓存(评论数变化可能影响排序) sort_cache.invalidate("prompts:") - # 更新提示词评论数 - for prompt in prompts_db: - if prompt["id"] == prompt_id: - prompt["comments"] = prompt.get("comments", 0) + 1 - break - db.save_data("prompts.json", prompts_db) - return {"status": "success", "data": new_comment} @router.post("/api/prompts/{prompt_id}/view") -async def record_prompt_view(prompt_id: str, current_user: str = Depends(require_auth)): +@shared_limit(60) +async def record_prompt_view(request: Request, prompt_id: str, current_user: str = Depends(require_auth)): """ 记录提示词访问量 👁️ 需要用户认证,每个用户只计算一次总访问量,日访问量每次调用都增加 diff --git a/router_proxy.py b/router_proxy.py index 2cc105414c7e5a64aa4e10b9b3be4be09f112f54..898319b5810dd8e8c256664179413da4b4cfd815 100644 --- a/router_proxy.py +++ b/router_proxy.py @@ -1,22 +1,102 @@ # router_proxy.py -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request +from rate_limit import shared_limit from fastapi.responses import StreamingResponse, JSONResponse, Response from sqlalchemy.orm import Session from pydantic import BaseModel, Field, field_validator, HttpUrl from typing import Optional -from urllib.parse import urlparse +from urllib.parse import urlparse, urljoin import ipaddress import httpx import os import re +import asyncio +import socket import urllib.request import urllib.error import 数据库连接 as json_db from database_sql import get_db from models_sql import Ownership +from workflow_source import authorized_workflow_path, workflow_credential_target, WorkflowSourceError +from proxy_access import authorized_proxy_item, ProxyAccessError +from github_source import repository_parts, select_github_token +from github_archive import ARCHIVE_MAX_BYTES from 安全认证 import require_auth +from safe_http_transport import ValidatedAsyncHTTPTransport +from workflow_download_service import prepare_workflow_download +from zip_stream import ZIP_DOWNLOAD_MAX_BYTES +from github_zip_service import prepare_github_zip router = APIRouter() +MAX_PROXY_DOWNLOAD_BYTES = int(os.environ.get("MAX_PROXY_DOWNLOAD_BYTES", str(256 * 1024 * 1024))) + + +async def _validate_public_http_target(url: str) -> str: + """Validate every proxy hop and reject local/private destinations.""" + parsed = urlparse(url) + if parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username or parsed.password: + raise HTTPException(status_code=400, detail="下载地址无效") + try: + port = parsed.port + except ValueError: + raise HTTPException(status_code=400, detail="下载地址端口无效") + if port not in (None, 80, 443): + raise HTTPException(status_code=400, detail="不允许访问自定义端口") + + host = parsed.hostname.rstrip(".").lower() + try: + addresses = [ipaddress.ip_address(host)] + except ValueError: + try: + infos = await asyncio.to_thread( + socket.getaddrinfo, host, port or (443 if parsed.scheme == "https" else 80), + type=socket.SOCK_STREAM, + ) + except OSError: + raise HTTPException(status_code=400, detail="下载地址无法解析") + addresses = [ipaddress.ip_address(info[4][0].split("%", 1)[0]) for info in infos] + if not addresses or any(not address.is_global for address in addresses): + raise HTTPException(status_code=400, detail="下载地址解析到非公网地址") + return host + + +async def _send_with_validated_redirects(client, method: str, url: str, headers=None, timeout=None, + max_redirects: int = 5): + """逐跳校验 URL/DNS 后请求;不让 HTTPX 自动跟随未经检查的重定向。""" + current_url = url + original_host = await _validate_public_http_target(current_url) + original_parsed = urlparse(current_url) + original_origin = ( + original_parsed.scheme.lower(), + original_host, + original_parsed.port or (443 if original_parsed.scheme.lower() == "https" else 80), + ) + request_headers = dict(headers or {}) + for redirect_count in range(max_redirects + 1): + current_host = await _validate_public_http_target(current_url) + current_parsed = urlparse(current_url) + current_origin = ( + current_parsed.scheme.lower(), + current_host, + current_parsed.port or (443 if current_parsed.scheme.lower() == "https" else 80), + ) + hop_headers = dict(request_headers) + if current_origin != original_origin: + # 凭据只发给初始 HTTPS origin,绝不随跨 origin 重定向传递。 + hop_headers.pop("Authorization", None) + response = await client.send( + client.build_request(method, current_url, headers=hop_headers, timeout=timeout), + stream=True, + follow_redirects=False, + ) + if response.status_code not in (301, 302, 303, 307, 308): + return response + location = response.headers.get("location") + await response.aclose() + if not location or redirect_count >= max_redirects: + raise HTTPException(status_code=502, detail="下载源重定向无效或次数过多") + current_url = urljoin(current_url, location) + raise HTTPException(status_code=502, detail="下载源重定向失败") class ProxyGithubZipRequest(BaseModel): @@ -35,172 +115,27 @@ class ProxyGithubZipRequest(BaseModel): return v @router.post("/api/proxy_github_zip") -async def proxy_github_zip(req_data: ProxyGithubZipRequest, account: str = Depends(require_auth), db: Session = Depends(get_db)): +@shared_limit(5) +async def proxy_github_zip(request: Request, req_data: ProxyGithubZipRequest, account: str = Depends(require_auth), db: Session = Depends(get_db)): """云端代理:校验所有权后,拉取 GitHub 私有库 ZIP 流透传给本地""" - items_db = json_db.load_data("items.json", default_data=[]) - item = next((i for i in items_db if i["id"] == req_data.item_id), None) - if not item: return JSONResponse(content={"error": "资源不存在"}, status_code=404) - - # 1. 核心鉴权:验证用户是否购买过该工具(account 来自 Token 验证,不可伪造) - try: - price = int(float(item.get("price", 0) or 0)) - except (ValueError, TypeError): - price = 0 - if price > 0 and account != item.get("author"): - owned = db.query(Ownership).filter(Ownership.account == account, Ownership.item_id == req_data.item_id).first() - if not owned: - return JSONResponse(content={"error": "🚨 拒绝访问:未找到购买记录!"}, status_code=403) - - # ===== ZIP 缓存优先机制(鉴权后、GitHub请求前) ===== - try: - versions_db = json_db.load_data("versions.json", default_data={}) - item_version = versions_db.get(req_data.item_id) - - # 兼容新格式:{"item_id": {"hash": "...", "cached_at": "...", "zip_size": ...}} - if isinstance(item_version, dict) and item_version.get("cached_at"): - cache_filename = f"zips/{req_data.item_id}_{item_version['hash']}.zip" - cache_url = f"https://huggingface.co/datasets/ZHIWEI666/ComfyUI-Ranking/resolve/main/{cache_filename}" - - cache_client = httpx.AsyncClient(follow_redirects=True) - head_resp = None - try: - head_resp = await cache_client.head(cache_url, timeout=10, follow_redirects=True) - except Exception as head_err: - print(f"[ZIP缓存] HEAD验证失败: {head_err}") - await cache_client.aclose() - raise - - if head_resp.status_code == 200: - print(f"[ZIP缓存] 命中: {req_data.item_id}, 大小: {item_version.get('zip_size', 'unknown')}") - - content_length = head_resp.headers.get('content-length', '') or str(item_version.get('zip_size', '')) - - try: - stream_resp = await cache_client.send( - cache_client.build_request("GET", cache_url), - stream=True - ) - except Exception as stream_err: - print(f"[ZIP缓存] 流请求失败: {stream_err}") - await cache_client.aclose() - raise - - cache_headers = {} - if content_length: - cache_headers["Content-Length"] = content_length - # 🔖 回传本次实际下发的版本戳(缓存命中即 item_version['hash']), - # 供本地私有安装写入 LocalVer 驱动卡片更新徽章(前端 installed_version 优先) - if item_version.get('hash'): - cache_headers["X-Installed-Version"] = item_version['hash'] - - async def cache_stream_generator(): - try: - if stream_resp.status_code != 200: - yield b"CACHE_DOWNLOAD_FAILED" - return - async for chunk in stream_resp.aiter_bytes(): - yield chunk - except Exception: - yield b"CACHE_DOWNLOAD_FAILED" - finally: - await stream_resp.aclose() - await cache_client.aclose() - - return StreamingResponse(cache_stream_generator(), media_type="application/zip", headers=cache_headers) - - # 缓存未命中:关闭 client 后继续原有逻辑 - await cache_client.aclose() - except Exception as e: - print(f"[ZIP缓存] 查询失败,fallback到GitHub直连: {e}") - - # 2. 解析 GitHub 仓库信息 - repo_url = item.get("link", "").rstrip("/") - if not repo_url.startswith("https://github.com/"): - return JSONResponse(content={"error": "无效的仓库地址,目前仅支持 GitHub 私有库代理"}, status_code=400) - - repo_parts = repo_url.split("/") - if len(repo_parts) < 2: return JSONResponse(content={"error": "无效的仓库地址格式"}, status_code=400) - owner = repo_parts[-2] - repo = repo_parts[-1].replace(".git", "") - - # GitHub 官方提供的打包下载 API - github_zip_api = f"https://api.github.com/repos/{owner}/{repo}/zipball" - - # 优先读取该资源在数据库中绑定的专属创作者 Token - creator_token = item.get("github_token") - # 如果没填,尝试使用官方全局兜底的 PAT - fallback_token = os.environ.get("GITHUB_PAT") - - active_token = creator_token if creator_token else fallback_token - - headers = { - "Accept": "application/vnd.github.v3+json", - "User-Agent": "ComfyUI-Ranking-SaaS" - } - if active_token: - headers["Authorization"] = f"Bearer {active_token}" - - # 3. 异步请求 GitHub API 并以流形式透传回客户端 (防内存打爆) - client = httpx.AsyncClient(follow_redirects=True) try: - try: - response = await client.send( - client.build_request("GET", github_zip_api, headers=headers), - stream=True - ) - except Exception as send_err: - await client.aclose() - return JSONResponse(content={"error": f"代理下载时发生网络异常:{str(send_err)}"}, status_code=500) - - # 非 200 直接返回错误,避免 Content-Length 不匹配导致 h11 协议错误 - if response.status_code != 200: - error_body = "" - try: - error_body = (await response.aread()).decode('utf-8', errors='ignore')[:200] - except Exception: - pass - await response.aclose() - await client.aclose() - status_map = {404: "仓库不存在或无权访问", 401: "Token 无效或已过期", 403: "访问被拒绝(可能触发速率限制)"} - err_msg = status_map.get(response.status_code, f"GitHub API 返回 HTTP {response.status_code}") - print(f"[proxy_github_zip] ❌ GitHub API 失败: {response.status_code}, repo: {github_zip_api}, body: {error_body[:100]}") - return JSONResponse(content={"error": f"GITHUB_DOWNLOAD_FAILED: {err_msg}"}, status_code=502) + item = await authorized_proxy_item(json_db, db, req_data.item_id, account) + except ProxyAccessError as error: + return JSONResponse(content={"error": error.message}, status_code=error.status_code) - # 成功时才透传 Content-Length 和流式响应 - content_length = response.headers.get('content-length', '') - resp_headers = {} - if content_length: - resp_headers["Content-Length"] = content_length + async def resolve_commit(repo_url, token): + # 缓存命中不加载版本引擎;保留原按需导入时序。 + from 云端_定时版本检测引擎 import fetch_github_commit + return await fetch_github_commit(repo_url, token) - # 🔖 缓存未命中(含 versions.json 尚未填充)时,现取仓库最新 commit 短 hash 回传, - # 补齐私有工具"安装瞬间无版本戳"的边界,使卡片更新徽章可正常触发。 - # 解析失败不影响 ZIP 下发(前端会兜底 latest_version) - try: - from 云端_定时版本检测引擎 import fetch_latest_github_hash - live_hash = await fetch_latest_github_hash(repo_url, active_token) - if live_hash: - resp_headers["X-Installed-Version"] = live_hash - except Exception as ver_err: - print(f"[proxy_github_zip] ⚠️ 版本戳解析失败(不影响下发): {ver_err}") + return await prepare_github_zip(item, req_data.item_id, store=json_db, + token_selector=select_github_token, resolve_commit=resolve_commit, + send=_send_with_validated_redirects, client_factory=httpx.AsyncClient, + transport_factory=ValidatedAsyncHTTPTransport, error_response=JSONResponse, + cache_max_bytes=ARCHIVE_MAX_BYTES, direct_max_bytes=ZIP_DOWNLOAD_MAX_BYTES) - async def stream_generator(): - try: - async for chunk in response.aiter_bytes(): - yield chunk - except Exception: - yield b"GITHUB_DOWNLOAD_FAILED" - finally: - await response.aclose() - await client.aclose() - return StreamingResponse(stream_generator(), media_type="application/zip", headers=resp_headers) - except Exception as e: - await client.aclose() - return JSONResponse(content={"error": f"代理下载时发生网络异常:{str(e)}"}, status_code=500) -# ========================================== -# 新增:工作流/应用 (App) JSON 代理下载接口 -# ========================================== class ProxyDownloadRequest(BaseModel): """工作流/应用 JSON 代理下载请求模型""" url: str = Field(..., min_length=1, description="下载URL") @@ -240,68 +175,17 @@ class ProxyDownloadRequest(BaseModel): return v @router.post("/api/proxy_download") -async def proxy_download(req_data: ProxyDownloadRequest, account: str = Depends(require_auth), db: Session = Depends(get_db)): +@shared_limit(10) +async def proxy_download(request: Request, req_data: ProxyDownloadRequest, account: str = Depends(require_auth), db: Session = Depends(get_db)): """云端代理:校验所有权后,拉取真实的工作流 JSON 文件并透传给本地""" - items_db = json_db.load_data("items.json", default_data=[]) - item = next((i for i in items_db if i["id"] == req_data.item_id), None) - - if not item: - return JSONResponse(content={"error": "云端记录中找不到该资源"}, status_code=404) - - # 1. 核心鉴权:验证用户是否购买/获取过该工作流(account 来自 Token 验证,不可伪造) - try: - price = int(float(item.get("price", 0) or 0)) - except (ValueError, TypeError): - price = 0 - if price > 0 and account != item.get("author"): - owned = db.query(Ownership).filter(Ownership.account == account, Ownership.item_id == req_data.item_id).first() - if not owned: - return JSONResponse(content={"error": "您尚未获取该工作流,请先在社区页面点击获取"}, status_code=403) - - target_url = req_data.url - - # 🚀 核心修复:从环境变量提取 Hugging Face Token,并组装 Authorization 请求头 - hf_token = os.environ.get("HF_TOKEN") - - headers = {"User-Agent": "ComfyUI-Ranking-SaaS"} - if hf_token and "huggingface.co" in target_url: - headers["Authorization"] = f"Bearer {hf_token}" - - # 🚀 核心修复:使用异步 httpx 替代同步 urllib,避免阻塞事件循环 - # ⚠️ 安全警告:仅在网络环境导致证书验证失败时,通过环境变量临时关闭 - verify_ssl = os.environ.get("DISABLE_SSL_VERIFY", "").lower() not in ("1", "true") - if not verify_ssl: - print("⚠️ SSL证书验证已关闭,请仅在调试环境使用") - try: - async with httpx.AsyncClient(follow_redirects=True, verify=verify_ssl, timeout=120.0) as client: - print(f"🔍 开始下载资源 [{req_data.item_id}]") - print(f"🔗 目标地址:{target_url[:80]}...") - - response = await client.get(target_url, headers=headers) - - if response.status_code != 200: - print(f"❌ 源文件拉取失败 [HTTP {response.status_code}]") - return JSONResponse( - content={"error": f"源文件拉取失败,HTTP 状态码:{response.status_code}"}, - status_code=response.status_code - ) - - content = response.content - print(f"✅ 成功下载资源 [{req_data.item_id}], 大小:{len(content)} bytes") - - return Response( - content=content, - media_type="application/json", - status_code=200, - headers={"Content-Disposition": f"attachment; filename={req_data.item_id}.json"} - ) - - except httpx.TimeoutException as e: - print(f"❌ 下载超时:{str(e)}") - return JSONResponse(content={"error": "下载超时,请稍后重试"}, status_code=504) - except Exception as e: - import traceback - print(f"❌ 代理下载异常:{str(e)}") - print(traceback.format_exc()) - return JSONResponse(content={"error": f"代理下载时发生网络异常:{str(e)}"}, status_code=500) \ No newline at end of file + item = await authorized_proxy_item(json_db, db, req_data.item_id, account) + except ProxyAccessError as error: + return JSONResponse(content={"error": error.message}, status_code=error.status_code) + + return await prepare_workflow_download(item, req_data.item_id, store=json_db, + source_authorizer=authorized_workflow_path, source_error=WorkflowSourceError, + credential_target=workflow_credential_target, validate_target=_validate_public_http_target, + client_factory=httpx.AsyncClient, transport_factory=ValidatedAsyncHTTPTransport, + timeout_error=httpx.TimeoutException, http_error=HTTPException, + error_response=JSONResponse, max_bytes=MAX_PROXY_DOWNLOAD_BYTES) diff --git a/router_tasks.py b/router_tasks.py index 8c9d5eee425bd4d802698d1ece228ddceba73bc9..a968918b61c400c02fc9e2c1b0884fd06163261f 100644 --- a/router_tasks.py +++ b/router_tasks.py @@ -11,12 +11,26 @@ # - 支付通知推送 # ========================================== -from fastapi import APIRouter, HTTPException, Depends, Query +from fastapi import APIRouter, HTTPException, Depends, Query, Request +from rate_limit import shared_limit from sqlalchemy.orm import Session from models import TaskCreate, TaskUpdate, TaskSubmit, TaskDispute, TaskDisputeResponse # 🔧 P2修复:提交/申诉/回应端点改为 body 模型,匹配前端 JSON 提交 import 数据库连接 as db -from 安全认证 import require_auth, is_admin +from comment_store import append_comment, increment_comment_count, attach_comment_counts +from 安全认证 import require_auth, optional_auth, is_admin from notifications import add_notification +from task_publication import freeze_task, project_publications, InsufficientTaskBalance +from task_terms import edit_task, project_revisions +from task_assignment import assign_open_task, project_assignments +from task_application import change_application, ApplicationConflict, ApplicationStateConflict +from task_settlement import settle_task, project_settlements, change_delivery, closed_transactions +from task_resolution import resolve_task, project_resolutions, ResolutionConflict +from task_dispute import respond_to_dispute, DisputePermissionError, create_task_dispute, project_disputes +from task_dispute_read import read_dispute_state, read_admin_disputes, DisputeReadDenied, DisputeReadMissing +from task_refund_transaction import refund_open_task +from task_read import read_tasks +from tip_transaction import lock_tip_wallets, lock_wallet_accounts +from tip_store import run_tip_effect from database_sql import get_db from models_sql import Wallet, Transaction from db_utils import record_view, sort_cache @@ -79,7 +93,7 @@ def create_task_transaction(db_session: Session, account: str, tx_type: str, amo ) db_session.add(new_tx) - logger.info(f"TASK_TX | type={tx_type} | account_hash={hash(account)} | amount={amount} | task={task_id} | tx={tx_id}") + logger.info(f"TASK_TX | type={tx_type} | task={task_id} | tx={tx_id}") return tx_id # ========================================== @@ -105,155 +119,86 @@ TASK_STATUS = { import datetime import threading -# 🔒 P1修复(CAS):过期退款串行锁——防止并发请求各持旧 tasks.json 快照, -# 把同一任务重复判定为过期导致双重退款 -_expired_task_lock = threading.Lock() - -def check_and_update_expired_tasks(tasks_db, db_session=None): - """ - 检查并更新过期任务状态 - - open 状态且超过截止日期:自动取消,退还冻结金额 - - in_progress 状态且超过截止日期:标记为过期(不自动取消,需双方处理) - 💳 P6支付增强:过期时自动退款 - 💳 P0修复:两阶段提交,确保SQL事务与JSON修改原子性 - """ - today = datetime.date.today().isoformat() # "2026-03-30" - updated = False - expired_task_indices = [] # 记录过期任务的索引 - - # ========== 第一阶段:预检查(只读,收集需要退款的任务列表) ========== - for idx, task in enumerate(tasks_db): - deadline = task.get("deadline", "") - status = task.get("status", "") - - if not deadline: - continue - - # 检查是否过期 - if deadline < today: - if status == "open": - # 开放接单状态且过期:记录需要处理 - frozen_amount = task.get("frozen_amount", task.get("total_price", 0)) - expired_task_indices.append({ - "index": idx, - "task_id": task["id"], - "publisher": task.get("publisher"), - "amount": frozen_amount, - "title": task.get("title", "") - }) - updated = True - - elif status == "in_progress": - # 进行中且过期:直接标记(不涉及资金) - task["is_overdue"] = True - updated = True - - # 如果没有过期任务,直接返回 - if not expired_task_indices: - return updated - - # 如果没有 db_session,只标记状态,不处理退款 - if not db_session: - for item in expired_task_indices: - task = tasks_db[item["index"]] - task["status"] = "expired" - task["expired_at"] = int(time.time()) - # 不标记退款,等待下次带 db_session 的调用 - return updated - - # ========== 第二阶段:执行SQL事务(钱包退款+交易记录) ========== - refund_results = [] # 记录退款成功的任务 - sql_committed = False +def _read_refund_task(task_id): + tasks = db.load_data("tasks.json", default_data=[], strict=True, skip_cache=True) + if not isinstance(tasks, list): + raise ValueError("invalid task source") + matches = [task for task in tasks if isinstance(task, dict) and task.get("id") == task_id] + if not matches: + raise HTTPException(status_code=404, detail="任务不存在") + if len(matches) != 1: + raise ValueError("ambiguous task source") + return matches[0] - # 🔒 P1修复(CAS):整个"复核→退款→写状态"流程串行化,并以磁盘最新数据复核任务状态 - # (仅退仍为 open 且未退过款的任务),防止并发旧快照导致同一任务重复退款 - with _expired_task_lock: - try: - fresh_tasks = {t["id"]: t for t in db.load_data("tasks.json", default_data=[])} - except Exception: - fresh_tasks = {} # 复核失败时宁可不退款,等待下次扫描 - def _still_refundable(item): - fresh = fresh_tasks.get(item["task_id"]) - return fresh is not None and fresh.get("status") == "open" and not fresh.get("refunded") +def _project_task_refund(task_id, publisher, result): + def update(tasks): + if not isinstance(tasks, list): + raise ValueError("invalid task source") + matches = [task for task in tasks if isinstance(task, dict) and task.get("id") == task_id] + if len(matches) != 1 or matches[0].get("publisher") != publisher: + raise ValueError("task owner changed") + task = matches[0] + if task.get("status") not in {"open", result["status"]}: + raise ValueError("task state changed") + task["status"] = result["status"] + task["expired_at" if result["status"] == "expired" else "cancelled_at"] = int(time.time()) + task["refunded"] = True + task["refund_amount"] = result["amount"] + db.atomic_update("tasks.json", update, default_data=[]) + sort_cache.invalidate("tasks:") - expired_task_indices = [item for item in expired_task_indices if _still_refundable(item)] - if not expired_task_indices: - return updated +def check_and_update_expired_tasks(tasks_db, db_session=None): + """有资金Session才处理开放任务过期;流水防重,投影失败后可重试修复。""" + today = datetime.date.today().isoformat() + changed = False + for snapshot in tasks_db: + if not isinstance(snapshot, dict): + continue + deadline = snapshot.get("deadline") try: - for item in expired_task_indices: - if item["amount"] > 0: - wallet = db_session.query(Wallet).filter(Wallet.account == item["publisher"]).with_for_update().first() - if wallet: - wallet.frozen_balance = max(0, wallet.frozen_balance - item["amount"]) - wallet.balance += item["amount"] - - # 记录退款交易 - create_task_transaction( - db_session, item["publisher"], "TASK_REFUND", - item["amount"], task_id=item["task_id"], - task_title=item["title"] - ) - - refund_results.append(item) - - # 💳 P0修复:先 commit SQL 事务 - db_session.commit() - sql_committed = True - logger.info(f"TASK_EXPIRED_SQL_COMMIT | refunded_tasks={len(refund_results)}") - - except Exception as e: - # 💳 P0修复:SQL事务失败,回滚所有操作,不修改JSON - db_session.rollback() - logger.error(f"TASK_EXPIRED_SQL_ROLLBACK | error={str(e)}") - # 不修改 JSON,下次再试 - return False - - # ========== 第三阶段:SQL成功后修改JSON任务状态 ========== - if sql_committed: + if not isinstance(deadline, str) or datetime.date.fromisoformat(deadline).isoformat() >= today: + continue + except ValueError: + continue + if snapshot.get("status") == "in_progress" and not snapshot.get("is_overdue"): + def overdue(tasks): + for task in tasks: + if isinstance(task, dict) and task.get("id") == snapshot.get("id") and task.get("status") == "in_progress" and task.get("deadline") == deadline: + task["is_overdue"] = True + try: + db.atomic_update("tasks.json", overdue, default_data=[]) + changed = True + except Exception as error: + run_tip_effect(lambda: logger.warning(f"TASK_OVERDUE_ERROR | error_type={type(error).__name__}")) + elif snapshot.get("status") == "open" and db_session is not None: try: - # 🔒 P1修复:按 id 定位任务(而非索引),避免并发修改后索引错位 - task_map = {t["id"]: t for t in tasks_db} - for item in expired_task_indices: - task = task_map.get(item["task_id"]) - if not task: - continue - task["status"] = "expired" - task["expired_at"] = int(time.time()) - if item["amount"] > 0: - task["refunded"] = True - task["refund_amount"] = item["amount"] + fresh = _read_refund_task(snapshot.get("id")) + if fresh.get("status") != "open" or fresh.get("deadline") != deadline: + continue + result = refund_open_task(db_session, fresh["id"], fresh.get("publisher"), _read_refund_task, "expired") + run_tip_effect(lambda: _project_task_refund(fresh["id"], fresh["publisher"], result)) + changed = True + if result["credited"]: + run_tip_effect(lambda: add_notification(fresh["publisher"], { + "type": "task_refund", "from_user": "system", "target_item_id": fresh["id"], + "target_item_title": fresh.get("title", ""), + "content": f"任务已过期,{result['amount']}积分已退还"})) + except Exception as error: + db_session.rollback() + run_tip_effect(lambda: logger.error(f"TASK_EXPIRED_ERROR | error_type={type(error).__name__}")) + return changed - # 💳 P0修复:保存JSON数据 - db.save_data("tasks.json", tasks_db) - logger.info(f"TASK_EXPIRED_JSON_SAVED | tasks={len(expired_task_indices)}") +def _read_task_pages(db_session, task_id=None): + try: + return read_tasks(db_session, db, task_id) + except Exception as error: + run_tip_effect(lambda: logger.error("TASK_READ_FAILED | error_type=%s", type(error).__name__)) + raise HTTPException(status_code=503, detail="任务状态暂时无法核实,请稍后核对") + finally: + db_session.rollback() - except Exception as e: - # 💳 P0修复:JSON保存失败,记录告警但不回滚SQL(资金已转移,可后续手动恢复) - import traceback - traceback.print_exc() - task_ids = ",".join(item["task_id"] for item in expired_task_indices) - print(f"[CRITICAL-DATA-INCONSISTENCY] task_id={task_ids}, SQL已退款但JSON状态未更新. Error: {e}") - logger.critical(f"TASK_EXPIRED_JSON_SAVE_FAILED | [CRITICAL-DATA-INCONSISTENCY] task_ids={task_ids} | SQL已退款但JSON状态未更新 | error={str(e)}") - # 不抛出异常 - 资金安全退回是更高优先级 - # 定时任务会在下次扫描时修正状态不一致 - - # 发送退款通知(在事务外执行,失败不影响主流程) - for item in refund_results: - try: - add_notification(item["publisher"], { - "type": "task_refund", - "from_user": "system", - "target_item_id": item["task_id"], - "target_item_title": item["title"], - "content": f"💰 任务《{item['title']}》已过期自动取消,{item['amount']}积分已退还" - }) - logger.info(f"TASK_REFUND | task={item['task_id']} | amount={item['amount']}") - except Exception as e: - logger.warning(f"TASK_REFUND_NOTIFY_ERROR | task={item['task_id']} | error={str(e)}") - - return updated @router.get("/api/tasks") async def get_tasks( @@ -268,12 +213,12 @@ async def get_tasks( - status: 筛选状态(open/in_progress/completed/expired等) - sort: latest(最新)/price(价格高)/deadline(截止日期近) """ - tasks_db = db.load_data("tasks.json", default_data=[]) + tasks_db = _read_task_pages(db_session) users_db = db.load_data("users.json", default_data={}) # 自动检查并更新过期任务(带自动退款) if check_and_update_expired_tasks(tasks_db, db_session): - db.save_data("tasks.json", tasks_db) + tasks_db = _read_task_pages(db_session) # users_db 已经是 {account: user_info} 格式,直接使用 user_map = users_db @@ -286,8 +231,7 @@ async def get_tasks( # 默认不显示已取消和过期的任务 filtered = [t for t in filtered if t.get("status") not in ["cancelled", "expired"]] - # 🗂️ 使用排序缓存优化排序性能 - cache_key = f"tasks:{status or 'all'}:{sort}" + # 按已合并的最新SQL/JSON字段排序,避免沿用旧价格或状态的排序位置。 def sort_fn(data): if sort == "price": @@ -305,7 +249,9 @@ async def get_tasks( else: # latest data.sort(key=lambda x: x.get("created_at", 0), reverse=True) - filtered = sort_cache.get_sorted(cache_key, filtered, sort_fn) + # SQL状态可在JSON尚未同步时变化,按当前合并字段排序。 + filtered = list(filtered) + sort_fn(filtered) # 分页 start = (page - 1) * limit @@ -318,6 +264,8 @@ async def get_tasks( publisher_info = user_map.get(task.get("publisher"), {}) # 👁️ 过滤敏感字段(列表接口过滤 liked_by, favorited_by, viewed_by) task_data = {k: v for k, v in task.items() if k not in ["viewed_by", "liked_by", "favorited_by"]} + task_data["applicant_count"] = len(task.get("applicants", [])) + task_data["applicants"] = [] result.append({ **task_data, "publisher_name": publisher_info.get("name", task.get("publisher")), @@ -325,6 +273,7 @@ async def get_tasks( "status_text": TASK_STATUS.get(task.get("status"), "未知") }) + attach_comment_counts(result) return { "status": "success", "data": result, @@ -334,11 +283,11 @@ async def get_tasks( } @router.get("/api/tasks/{task_id}") -async def get_task_detail(task_id: str, current_user: str = None): +async def get_task_detail(task_id: str, db_session: Session = Depends(get_db), current_user: str = Depends(optional_auth)): """ 获取任务详情 """ - tasks_db = db.load_data("tasks.json", default_data=[]) + tasks_db = _read_task_pages(db_session, task_id) users_db = db.load_data("users.json", default_data={}) # users_db 已经是 {account: user_info} 格式,直接使用 @@ -371,6 +320,7 @@ async def get_task_detail(task_id: str, current_user: str = None): # 👁️ 过滤敏感字段(详情接口保留 liked_by 和 favorited_by,过滤 viewed_by) task_data = {k: v for k, v in task.items() if k != "viewed_by"} + attach_comment_counts([task_data]) return { "status": "success", "data": { @@ -381,6 +331,7 @@ async def get_task_detail(task_id: str, current_user: str = None): "assignee_avatar": assignee_info.get("avatarDataUrl") if assignee_info else None, "applicants": applicants_result, "applicant_count": applicant_count, + "has_applied": bool(current_user and any(app.get("account") == current_user for app in task.get("applicants", []))), "status_text": TASK_STATUS.get(task.get("status"), "未知") } } @@ -388,40 +339,23 @@ async def get_task_detail(task_id: str, current_user: str = None): raise HTTPException(status_code=404, detail="任务不存在") @router.post("/api/tasks") -async def create_task(task: TaskCreate, current_user: str = Depends(require_auth), db_session: Session = Depends(get_db)): +@shared_limit(5) +async def create_task(request: Request, task: TaskCreate, current_user: str = Depends(require_auth), db_session: Session = Depends(get_db)): """ 发布新任务 💳 P6支付增强:发布时冻结全额,避免支付时余额不足 """ - tasks_db = db.load_data("tasks.json", default_data=[]) - - # 验证订金比例 - if task.depositRatio not in [10, 20, 30, 50]: - raise HTTPException(status_code=400, detail="订金比例必须是 10/20/30/50 之一") - - # 验证价格 - if task.totalPrice < 0: - raise HTTPException(status_code=400, detail="任务价格不能低于0积分") - - # 💳 使用SQL钱包检查余额并冻结 - wallet = db_session.query(Wallet).filter(Wallet.account == current_user).with_for_update().first() - if not wallet: - wallet = Wallet(account=current_user, balance=0) - db_session.add(wallet) - db_session.flush() - - if wallet.balance < task.totalPrice: - raise HTTPException(status_code=400, detail=f"余额不足,需要{task.totalPrice}积分,当前{wallet.balance}积分") - - # 💳 冻结全额(从余额转移到冻结余额) - wallet.balance -= task.totalPrice - wallet.frozen_balance += task.totalPrice - - # 计算订金金额 - deposit_amount = int(task.totalPrice * task.depositRatio / 100) - - task_id = f"task_{int(time.time())}_{uuid.uuid4().hex[:6]}" - + if type(task.depositRatio) is not int or task.depositRatio not in [10, 20, 30, 50]: + raise HTTPException(status_code=422, detail="订金比例必须是 10/20/30/50 之一") + if type(task.totalPrice) is not int or not 0 <= task.totalPrice <= 2147483647: + raise HTTPException(status_code=422, detail="任务价格无效") + try: + deadline = datetime.date.fromisoformat(task.deadline).isoformat() + except (ValueError, TypeError): + raise HTTPException(status_code=422, detail="截止日期无效") + deposit_amount = task.totalPrice * task.depositRatio // 100 + task_id = f"task_{uuid.uuid4().hex}" + new_task = { "id": task_id, "title": escape(task.title), @@ -431,7 +365,7 @@ async def create_task(task: TaskCreate, current_user: str = Depends(require_auth "total_price": task.totalPrice, "deposit_ratio": task.depositRatio, "deposit_amount": deposit_amount, - "deadline": task.deadline, + "deadline": deadline, "publisher": current_user, "status": "open", "created_at": int(time.time()), @@ -463,490 +397,203 @@ async def create_task(task: TaskCreate, current_user: str = Depends(require_auth "daily_views_date": "" } - tasks_db.insert(0, new_task) - db.save_data("tasks.json", tasks_db) - # 🗂️ 清除排序缓存 - sort_cache.invalidate("tasks:") - - # 💳 记录冻结交易 - create_task_transaction( - db_session, current_user, "TASK_FREEZE", - -task.totalPrice, task_id=task_id, - task_title=task.title - ) - db_session.commit() - - logger.info(f"TASK_CREATE | task={task_id} | price={task.totalPrice} | frozen={task.totalPrice}") - - return {"status": "success", "data": new_task, "frozen_amount": task.totalPrice} + try: + committed_task = freeze_task(db_session, new_task) + except InsufficientTaskBalance: + raise HTTPException(status_code=400, detail="余额不足,请先充值") + except Exception as error: + db_session.rollback() + run_tip_effect(lambda: logger.error(f"TASK_CREATE_ERROR | error_type={type(error).__name__}")) + raise HTTPException(status_code=503, detail="任务发布未确认,请先查询任务和钱包") + run_tip_effect(lambda: project_publications(db_session.get_bind(), db, task_id=committed_task["id"])) + run_tip_effect(lambda: sort_cache.invalidate("tasks:")) + run_tip_effect(lambda: logger.info("TASK_CREATE")) + return {"status": "success", "data": committed_task, "frozen_amount": task.totalPrice} @router.put("/api/tasks/{task_id}") -async def update_task(task_id: str, update_data: TaskUpdate, current_user: str = Depends(require_auth)): - """ - 更新任务(仅发布者可操作) - - open状态:可修改所有字段 - - in_progress/submitted状态:只能修改标题/描述/参考图/链接,不能改价格,并通知接单人 - """ - tasks_db = db.load_data("tasks.json", default_data=[]) - - for task in tasks_db: - if task["id"] == task_id: - if task.get("publisher") != current_user: - raise HTTPException(status_code=403, detail="无权修改他人任务") - - status = task.get("status") - if status not in ["open", "in_progress", "submitted"]: - raise HTTPException(status_code=400, detail="当前状态不允许修改") - - # 记录修改内容(用于通知) - changes = [] - - if update_data.title is not None and update_data.title != task.get("title"): - task["title"] = update_data.title - changes.append("标题") - if update_data.description is not None and update_data.description != task.get("description"): - task["description"] = update_data.description - changes.append("描述") - if update_data.referenceImages is not None: - task["reference_images"] = update_data.referenceImages[:6] - changes.append("参考图") - if update_data.referenceLink is not None and update_data.referenceLink != task.get("reference_link"): - task["reference_link"] = update_data.referenceLink - changes.append("参考链接") - - # 价格和订金只能在open状态修改 - if status == "open": - if update_data.totalPrice is not None: - # 验证价格 - if update_data.totalPrice < 0: - raise HTTPException(status_code=400, detail="任务价格不能低于0积分") - task["total_price"] = update_data.totalPrice - task["deposit_amount"] = int(update_data.totalPrice * task["deposit_ratio"] / 100) - changes.append("价格") - if update_data.depositRatio is not None and update_data.depositRatio in [10, 20, 30, 50]: - task["deposit_ratio"] = update_data.depositRatio - task["deposit_amount"] = int(task["total_price"] * update_data.depositRatio / 100) - changes.append("订金比例") - if update_data.deadline is not None: - task["deadline"] = update_data.deadline - changes.append("截止日期") - - db.save_data("tasks.json", tasks_db) - # 🗂️ 清除排序缓存 - sort_cache.invalidate("tasks:") - - # 📢 如果有接单人,发送通知 - if task.get("assignee") and changes: - add_notification(task.get("assignee"), { - "type": "task_updated", - "from_user": current_user, - "target_item_id": task_id, - "target_item_title": task.get("title", ""), - "content": f"任务《{task.get('title', '')}》已更新:{'、'.join(changes)}" - }) - - return {"status": "success"} - - raise HTTPException(status_code=404, detail="任务不存在") +@shared_limit(10) +async def update_task(request: Request, task_id: str, update_data: TaskUpdate, current_user: str = Depends(require_auth), db_session: Session = Depends(get_db)): + task = _read_refund_task(task_id) + if task.get("publisher") != current_user: + raise HTTPException(status_code=403, detail="无权修改他人任务") + if task.get("status") not in {"open", "in_progress", "submitted"}: + raise HTTPException(status_code=400, detail="当前状态不允许修改") + changes = {} + mapping = {"title":"title", "description":"description", "referenceImages":"reference_images", + "referenceLink":"reference_link", "totalPrice":"total_price", "depositRatio":"deposit_ratio", "deadline":"deadline"} + for source, target in mapping.items(): + value = getattr(update_data, source, None) + if value is not None: + changes[target] = value[:6] if source == "referenceImages" else value + try: + updated = edit_task(db_session, task_id, current_user, changes, _read_refund_task) + except PermissionError: + raise HTTPException(status_code=403, detail="无权修改他人任务") + except Exception as error: + db_session.rollback() + run_tip_effect(lambda: logger.error(f"TASK_UPDATE_ERROR | error_type={type(error).__name__}")) + raise HTTPException(status_code=503, detail="任务更新未确认,请先查询任务和钱包") + run_tip_effect(lambda: project_revisions(db_session.get_bind(), db, task_id=task_id)) + run_tip_effect(lambda: sort_cache.invalidate("tasks:")) + if updated.get("assignee") and changes: + run_tip_effect(lambda: add_notification(updated["assignee"], { + "type":"task_updated", "from_user":current_user, "target_item_id":task_id, + "target_item_title":updated.get("title", ""), "content":"任务内容已更新,请重新查看任务详情"})) + return {"status":"success"} @router.delete("/api/tasks/{task_id}") -async def cancel_task(task_id: str, current_user: str = Depends(require_auth), db_session: Session = Depends(get_db)): - """ - 取消任务(仅发布者可操作,且仅在 open 状态时可取消) - 💳 P0修复:取消时退还冻结资金 - """ - # 🔒 P0安全修复:防御性断言,确保 current_user 不为 None +@shared_limit(10) +async def cancel_task(request: Request, task_id: str, current_user: str = Depends(require_auth), db_session: Session = Depends(get_db)): if not current_user: raise HTTPException(status_code=401, detail="未认证") - - tasks_db = db.load_data("tasks.json", default_data=[]) - - for task in tasks_db: - if task["id"] == task_id: - if task.get("publisher") != current_user: - raise HTTPException(status_code=403, detail="无权取消他人任务") - - if task.get("status") != "open": - raise HTTPException(status_code=400, detail="只能取消开放状态的任务") - - # 💳 P0修复:退还冻结资金 - frozen_amount = task.get("frozen_amount", task.get("total_price", 0)) - - # 🔒 P0安全修复:冻结金额负数防护 - if frozen_amount < 0: - raise HTTPException(status_code=400, detail="冻结金额无效,无法退款") - - refund_success = False - - if frozen_amount > 0: - try: - wallet = db_session.query(Wallet).filter(Wallet.account == current_user).with_for_update().first() - if wallet: - wallet.frozen_balance = max(0, wallet.frozen_balance - frozen_amount) - wallet.balance += frozen_amount - - # 记录退款交易 - create_task_transaction( - db_session, current_user, "TASK_CANCEL_REFUND", - frozen_amount, task_id=task_id, - task_title=task.get("title") - ) - db_session.commit() - refund_success = True - logger.info(f"TASK_CANCEL_REFUND | task={task_id} | amount={frozen_amount}") - except Exception as e: - db_session.rollback() - logger.error(f"TASK_CANCEL_REFUND_ERROR | task={task_id} | error={str(e)}") - raise HTTPException(status_code=500, detail="退款失败,请稍后重试") - else: - refund_success = True # 无需退款 - - # 只有退款成功才更新状态 - if refund_success: - task["status"] = "cancelled" - task["cancelled_at"] = int(time.time()) - task["refunded"] = frozen_amount > 0 - task["refund_amount"] = frozen_amount - db.save_data("tasks.json", tasks_db) - # 🗂️ 清除排序缓存 - sort_cache.invalidate("tasks:") - - return {"status": "success", "refunded_amount": frozen_amount} - - raise HTTPException(status_code=404, detail="任务不存在") + try: + task = _read_refund_task(task_id) + if task.get("publisher") != current_user: + raise HTTPException(status_code=403, detail="无权取消他人任务") + result = refund_open_task(db_session, task_id, current_user, _read_refund_task, "cancelled") + except HTTPException: + raise + except Exception as error: + db_session.rollback() + run_tip_effect(lambda: logger.error(f"TASK_CANCEL_REFUND_ERROR | error_type={type(error).__name__}")) + raise HTTPException(status_code=503, detail="任务资金状态无法核实,请稍后查询确认") + # 资金已提交,状态/排序投影故障不把成功伪报失败;下次可从流水恢复。 + run_tip_effect(lambda: _project_task_refund(task_id, current_user, result)) + run_tip_effect(lambda: logger.info("TASK_CANCEL_REFUND")) + return {"status": "success", "refunded_amount": result["amount"]} # ========================================== # 🙋 申请接单 # ========================================== @router.post("/api/tasks/{task_id}/apply") -async def apply_task(task_id: str, message: str = None, current_user: str = Depends(require_auth)): - """ - 申请接单 - """ - tasks_db = db.load_data("tasks.json", default_data=[]) - - for task in tasks_db: - if task["id"] == task_id: - if task.get("status") != "open": - raise HTTPException(status_code=400, detail="该任务不在开放接单状态") - - if task.get("publisher") == current_user: - raise HTTPException(status_code=400, detail="不能申请自己发布的任务") - - # 检查是否已申请 - applicants = task.get("applicants", []) - if any(a["account"] == current_user for a in applicants): - raise HTTPException(status_code=400, detail="您已申请过该任务") - - # 添加申请 - applicants.append({ - "account": current_user, - "message": message or "", - "applied_at": int(time.time()) - }) - task["applicants"] = applicants - - db.save_data("tasks.json", tasks_db) - # 🗂️ 清除排序缓存 - sort_cache.invalidate("tasks:") - - # 🔔 通知发布者:有人申请接单 - add_notification(task.get("publisher"), { - "type": "task_apply", - "from_user": current_user, - "target_item_id": task_id, - "target_item_title": task.get("title", ""), - "content": f"申请了您的任务《{task.get('title', '')}》" - }) - - return {"status": "success", "message": "申请成功,等待发布者选择"} - - raise HTTPException(status_code=404, detail="任务不存在") +@shared_limit(10) +async def apply_task(request: Request, task_id: str, message: str = None, current_user: str = Depends(require_auth), db_session: Session = Depends(get_db)): + try: + task = change_application(db_session, task_id, current_user, _read_refund_task, db, message=message) + except HTTPException: + db_session.rollback() + raise + except ApplicationConflict as error: + db_session.rollback() + raise HTTPException(status_code=400, detail=str(error)) + except ApplicationStateConflict as error: + db_session.rollback() + raise HTTPException(status_code=409, detail=str(error)) + except Exception as error: + db_session.rollback() + logger.error("TASK_APPLY_FAILED | error_type=%s", type(error).__name__) + raise HTTPException(status_code=503, detail="报名状态暂时无法核实,请稍后核对") + run_tip_effect(lambda: sort_cache.invalidate("tasks:")) + run_tip_effect(lambda: add_notification(task.get("publisher"), { + "type": "task_apply", "from_user": current_user, "target_item_id": task_id, + "target_item_title": task.get("title", ""), "content": f"申请了您的任务《{task.get('title', '')}》"})) + return {"status": "success", "message": "申请成功,等待发布者选择"} @router.delete("/api/tasks/{task_id}/apply") -async def cancel_apply(task_id: str, current_user: str = Depends(require_auth)): - """ - 取消申请接单 - """ - tasks_db = db.load_data("tasks.json", default_data=[]) - - for task in tasks_db: - if task["id"] == task_id: - if task.get("status") != "open": - raise HTTPException(status_code=400, detail="任务已开始,无法取消申请") - - applicants = task.get("applicants", []) - new_applicants = [a for a in applicants if a["account"] != current_user] - - if len(new_applicants) == len(applicants): - raise HTTPException(status_code=400, detail="您未申请该任务") - - task["applicants"] = new_applicants - db.save_data("tasks.json", tasks_db) - # 🗂️ 清除排序缓存 - sort_cache.invalidate("tasks:") - return {"status": "success"} - - raise HTTPException(status_code=404, detail="任务不存在") +@shared_limit(10) +async def cancel_apply(request: Request, task_id: str, current_user: str = Depends(require_auth), db_session: Session = Depends(get_db)): + try: + change_application(db_session, task_id, current_user, _read_refund_task, db, withdraw=True) + except HTTPException: + db_session.rollback() + raise + except ApplicationConflict as error: + db_session.rollback() + raise HTTPException(status_code=400, detail=str(error)) + except ApplicationStateConflict as error: + db_session.rollback() + raise HTTPException(status_code=409, detail=str(error)) + except Exception as error: + db_session.rollback() + logger.error("TASK_CANCEL_APPLY_FAILED | error_type=%s", type(error).__name__) + raise HTTPException(status_code=503, detail="报名状态暂时无法核实,请稍后核对") + run_tip_effect(lambda: sort_cache.invalidate("tasks:")) + return {"status": "success"} # ========================================== # 🎯 指派接单者 # ========================================== @router.post("/api/tasks/{task_id}/assign") -async def assign_task(task_id: str, assignee: str, current_user: str = Depends(require_auth), db_session: Session = Depends(get_db)): - """ - 发布者选择接单者 - 💳 P6支付增强:从冻结金额中扣除订金,记录交易 - """ - tasks_db = db.load_data("tasks.json", default_data=[]) - - for task in tasks_db: - if task["id"] == task_id: - if task.get("publisher") != current_user: - raise HTTPException(status_code=403, detail="只有发布者可以指派接单者") - - if task.get("status") != "open": - raise HTTPException(status_code=400, detail="该任务不在开放状态") - - # 验证接单者是否在申请列表中 - applicants = task.get("applicants", []) - if not any(a["account"] == assignee for a in applicants): - raise HTTPException(status_code=400, detail="该用户未申请此任务") - - deposit = task.get("deposit_amount", 0) - - # 🔒 P0安全修复:订金金额负数防护 - if deposit < 0: - raise HTTPException(status_code=400, detail="订金金额无效") - - # 💳 从冻结金额中扣除订金(订金暂时不给接单者,待任务完成后一起给) - wallet = db_session.query(Wallet).filter(Wallet.account == current_user).with_for_update().first() - if not wallet or wallet.frozen_balance < deposit: - raise HTTPException(status_code=400, detail="冻结余额异常") - - # 订金从冻结金额中扣除(但还不给接单者,待任务完成后一起支付) - wallet.frozen_balance -= deposit - - # 获取接单者用户名 - users_db = db.load_data("users.json", default_data={}) - assignee_info = users_db.get(assignee, {}) - assignee_name = assignee_info.get("name", assignee) - - # 💳 记录订金支付交易 - create_task_transaction( - db_session, current_user, "TASK_DEPOSIT", - -deposit, related_account=assignee, task_id=task_id, - task_title=task.get("title"), - related_user_name=assignee_name - ) - - # 更新任务状态 - task["assignee"] = assignee - task["assigned_at"] = int(time.time()) - task["status"] = "in_progress" - task["deposit_paid"] = deposit # 💳 记录已支付订金 - - db.save_data("tasks.json", tasks_db) - db_session.commit() - # 🗂️ 清除排序缓存 - sort_cache.invalidate("tasks:") - - # 🔔 通知接单者:被指派接单 - add_notification(assignee, { - "type": "task_assigned", - "from_user": current_user, - "target_item_id": task_id, - "target_item_title": task.get("title", ""), - "content": f"您已被选为任务《{task.get('title', '')}》的接单者,订金{deposit}积分已缓冲" - }) - - logger.info(f"TASK_ASSIGN | task={task_id} | deposit={deposit}") - - return {"status": "success", "message": f"已指派 {assignee} 接单,订金 {deposit} 积分已开始缓冲"} - - raise HTTPException(status_code=404, detail="任务不存在") +@shared_limit(10) +async def assign_task(request: Request, task_id: str, assignee: str, current_user: str = Depends(require_auth), db_session: Session = Depends(get_db)): + task = _read_refund_task(task_id) + if task.get("publisher") != current_user: + raise HTTPException(status_code=403, detail="只有发布者可以指派接单者") + try: + result = assign_open_task(db_session, task_id, current_user, assignee, _read_refund_task) + except Exception as error: + db_session.rollback() + run_tip_effect(lambda: logger.error(f"TASK_ASSIGN_ERROR | error_type={type(error).__name__}")) + raise HTTPException(status_code=503, detail="任务指派未确认,请先查询任务和钱包") + run_tip_effect(lambda: project_assignments(db_session.get_bind(), db, task_id=task_id)) + run_tip_effect(lambda: sort_cache.invalidate("tasks:")) + if result["credited"]: + run_tip_effect(lambda: add_notification(assignee, { + "type":"task_assigned", "from_user":current_user, "target_item_id":task_id, + "target_item_title":result["task"].get("title", ""), "content":"您已被选为任务的接单者,订金已缓冲"})) + return {"status":"success", "message":f"已指派 {assignee} 接单,订金 {result['deposit']} 积分已开始缓冲"} # ========================================== # 📤 提交成果 # ========================================== @router.post("/api/tasks/{task_id}/submit") -async def submit_task(task_id: str, payload: TaskSubmit, current_user: str = Depends(require_auth)): - """ - 接单者提交成果 - """ - # 🔧 P2修复:前端以 JSON body 提交,原裸标量参数会被解析为 query 导致 422 - deliverables = payload.deliverables - note = payload.note - tasks_db = db.load_data("tasks.json", default_data=[]) - - for task in tasks_db: - if task["id"] == task_id: - if task.get("assignee") != current_user: - raise HTTPException(status_code=403, detail="只有接单者可以提交成果") - - if task.get("status") != "in_progress": - raise HTTPException(status_code=400, detail="任务状态不允许提交") - - if not deliverables: - raise HTTPException(status_code=400, detail="请上传交付成果") - - task["deliverables"] = deliverables - task["submit_note"] = note - task["submitted_at"] = int(time.time()) - task["status"] = "submitted" - - db.save_data("tasks.json", tasks_db) - # 🗂️ 清除排序缓存 - sort_cache.invalidate("tasks:") - - # 🔔 通知发布者:接单者已提交成果 - add_notification(task.get("publisher"), { - "type": "task_submitted", - "from_user": current_user, - "target_item_id": task_id, - "target_item_title": task.get("title", ""), - "content": f"已提交任务《{task.get('title', '')}》的成果,请验收" - }) - - return {"status": "success", "message": "成果已提交,等待发布者验收"} - - raise HTTPException(status_code=404, detail="任务不存在") +@shared_limit(10) +async def submit_task(request: Request, task_id: str, payload: TaskSubmit, current_user: str = Depends(require_auth), db_session: Session = Depends(get_db)): + task = _read_refund_task(task_id) + if task.get("assignee") != current_user: + raise HTTPException(status_code=403, detail="只有接单者可以提交成果") + if not payload.deliverables: + raise HTTPException(status_code=400, detail="请上传交付成果") + try: + task = change_delivery(db_session, task_id, current_user, _read_refund_task, db, + deliverables=payload.deliverables, note=payload.note) + except Exception as error: + run_tip_effect(lambda: logger.error(f"TASK_SUBMIT_ERROR | error_type={type(error).__name__}")) + raise HTTPException(status_code=503, detail="任务提交未确认,请重新查询任务") + run_tip_effect(lambda: sort_cache.invalidate("tasks:")) + if task.get("_delivery_changed", True): + run_tip_effect(lambda: add_notification(task["publisher"], { + "type":"task_submitted", "from_user":current_user, "target_item_id":task_id, + "target_item_title":task.get("title", ""), "content":"接单者已提交成果,请验收"})) + return {"status":"success", "message":"成果已提交,等待发布者验收"} -# ========================================== -# ✅ 验收成果 -# ========================================== @router.post("/api/tasks/{task_id}/accept") -async def accept_task(task_id: str, is_accepted: bool, feedback: str = None, current_user: str = Depends(require_auth), db_session: Session = Depends(get_db)): - """ - 发布者验收成果 - - is_accepted=True: 验收通过,支付尾款给接单者 - - is_accepted=False: 验收不通过,可以要求修改或发起申诉 - 💳 P6支付增强:使用SQL钱包支付,记录交易流水,发送支付通知 - 💳 P0修复:事务完整性保护,失败时回滚 - """ - tasks_db = db.load_data("tasks.json", default_data=[]) - - for task in tasks_db: - if task["id"] == task_id: - if task.get("publisher") != current_user: - raise HTTPException(status_code=403, detail="只有发布者可以验收") - - if task.get("status") != "submitted": - raise HTTPException(status_code=400, detail="任务状态不允许验收") - - task["feedback"] = feedback - assignee_account = task.get("assignee") - total_price = task.get("total_price", 0) - deposit = task.get("deposit_paid", task.get("deposit_amount", 0)) # 优先使用已记录的支付订金 - remaining = total_price - deposit # 尾款 - - # 🔒 P0安全修复:金额有效性校验 - if total_price < 0 or deposit < 0 or remaining < 0: - raise HTTPException(status_code=400, detail="任务金额数据异常,无法完成验收") - - if is_accepted: - # 💳 验收通过:支付尾款给接单者 - # 💳 P0修复:使用事务保护,失败则回滚 - try: - publisher_wallet = db_session.query(Wallet).filter(Wallet.account == current_user).with_for_update().first() - if not publisher_wallet or publisher_wallet.frozen_balance < remaining: - raise HTTPException(status_code=400, detail="冻结余额不足支付尾款") - - # 扣除发布者尾款(从冻结余额) - publisher_wallet.frozen_balance -= remaining - - # 给接单者全款(订金+尾款) - assignee_wallet = db_session.query(Wallet).filter(Wallet.account == assignee_account).with_for_update().first() - if not assignee_wallet: - assignee_wallet = Wallet(account=assignee_account, balance=0) - db_session.add(assignee_wallet) - db_session.flush() - - assignee_wallet.balance += total_price # 全款进入可用余额 - assignee_wallet.task_balance += total_price # 累计任务收益统计(只增不减) - - # 获取双方用户名 - users_db = db.load_data("users.json", default_data={}) - publisher_info = users_db.get(current_user, {}) - assignee_info = users_db.get(assignee_account, {}) - publisher_name = publisher_info.get("name", current_user) - assignee_name = assignee_info.get("name", assignee_account) - - # 💳 记录交易流水 - # 1. 发布者支付尾款 - create_task_transaction( - db_session, current_user, "TASK_PAYMENT", - -remaining, related_account=assignee_account, task_id=task_id, - task_title=task.get("title"), - related_user_name=assignee_name - ) - # 2. 接单者收入 - create_task_transaction( - db_session, assignee_account, "TASK_INCOME", - total_price, related_account=current_user, task_id=task_id, - task_title=task.get("title"), - related_user_name=publisher_name - ) - - task["status"] = "completed" - task["completed_at"] = int(time.time()) - - # 💳 P0修复:先commit SQL事务(资金为真),再最佳努力写入JSON - db_session.commit() - - # 再最佳努力写入JSON - try: - db.save_data("tasks.json", tasks_db) - except Exception as e: - logger.warning(f"TASK_COMPLETE_JSON_SAVE_FAILED | 资金已结算但任务状态未更新,需手动修复: {str(e)}") - - logger.info(f"TASK_COMPLETE | task={task_id} | total={total_price}") - # 🗂️ 清除排序缓存(任务状态变化) - sort_cache.invalidate("tasks:") - message = f"验收通过,已支付 {total_price} 积分给接单者" - - # 🔔 支付通知:接单者收到款项 - add_notification(assignee_account, { - "type": "task_payment", - "from_user": current_user, - "target_item_id": task_id, - "target_item_title": task.get("title", ""), - "content": f"💰 任务《{task.get('title', '')}》验收通过,{total_price}积分已到账" - }) - - except HTTPException: - raise # 重新抛出 HTTP 异常 - except Exception as e: - db_session.rollback() - logger.error(f"TASK_ACCEPT_ERROR | task={task_id} | error={str(e)}") - raise HTTPException(status_code=500, detail="验收处理失败,请稍后重试") - else: - # 验收不通过:回到进行中状态,允许修改后重新提交 - task["status"] = "in_progress" - task["deliverables"] = [] - task["submitted_at"] = None - message = "验收不通过,接单者可以修改后重新提交" - - db.save_data("tasks.json", tasks_db) - # 🗂️ 清除排序缓存(任务状态变化) - sort_cache.invalidate("tasks:") - - # 🔔 通知接单者:验收未通过 - add_notification(assignee_account, { - "type": "task_rejected", - "from_user": current_user, - "target_item_id": task_id, - "target_item_title": task.get("title", ""), - "content": f"任务《{task.get('title', '')}》验收未通过,请修改后重新提交" - }) - - return {"status": "success", "message": message} - - raise HTTPException(status_code=404, detail="任务不存在") +@shared_limit(10) +async def accept_task(request: Request, task_id: str, is_accepted: bool, feedback: str = None, current_user: str = Depends(require_auth), db_session: Session = Depends(get_db)): + task = _read_refund_task(task_id) + if task.get("publisher") != current_user: + raise HTTPException(status_code=403, detail="只有发布者可以验收") + if type(is_accepted) is not bool: + raise HTTPException(status_code=422, detail="验收参数无效") + try: + if is_accepted: + result = settle_task(db_session, task_id, current_user, _read_refund_task, feedback) + else: + task = change_delivery(db_session, task_id, current_user, _read_refund_task, db, feedback=feedback) + except Exception as error: + db_session.rollback() + run_tip_effect(lambda: logger.error(f"TASK_ACCEPT_ERROR | error_type={type(error).__name__}")) + raise HTTPException(status_code=503, detail="验收结果未确认,请先查询任务和钱包") + if is_accepted: + run_tip_effect(lambda: project_settlements(db_session.get_bind(), db, task_id=task_id)) + if result["paid"]: + run_tip_effect(lambda: add_notification(result["assignee"], { + "type":"task_payment", "from_user":current_user, "target_item_id":task_id, + "target_item_title":result["task"].get("title", ""), "content":"任务验收通过,款项已到账"})) + message = f"验收通过,已支付 {result['amount']} 积分给接单者" + else: + if task.get("_delivery_changed", True): + run_tip_effect(lambda: add_notification(task["assignee"], { + "type":"task_rejected", "from_user":current_user, "target_item_id":task_id, + "target_item_title":task.get("title", ""), "content":"验收未通过,请修改后重新提交"})) + message = "验收不通过,接单者可以修改后重新提交" + run_tip_effect(lambda: sort_cache.invalidate("tasks:")) + run_tip_effect(lambda: logger.info("TASK_ACCEPT")) + return {"status":"success", "message":message} # ========================================== # ⚖️ 申诉仲裁系统 @@ -954,448 +601,182 @@ async def accept_task(task_id: str, is_accepted: bool, feedback: str = None, cur @router.post("/api/tasks/{task_id}/dispute") -async def create_dispute(task_id: str, payload: TaskDispute, current_user: str = Depends(require_auth)): - """ - 发起申诉(发布者或接单者均可) - - evidence: 证据图片URL列表(可选) - """ - # 🔧 P2修复:前端以 JSON body 提交,原裸标量参数会被解析为 query 导致 422 - reason = payload.reason - evidence = payload.evidence - tasks_db = db.load_data("tasks.json", default_data=[]) - disputes_db = db.load_data("disputes.json", default_data=[]) - - for task in tasks_db: - if task["id"] == task_id: - # 验证权限 - if current_user not in [task.get("publisher"), task.get("assignee")]: - raise HTTPException(status_code=403, detail="只有发布者或接单者可以发起申诉") - - # 验证状态 - if task.get("status") not in ["in_progress", "submitted"]: - raise HTTPException(status_code=400, detail="当前状态不允许申诉") - - # 检查是否已有申诉 - if task.get("dispute_id"): - raise HTTPException(status_code=400, detail="该任务已有进行中的申诉") - - # 确定角色 - is_publisher = current_user == task.get("publisher") - role = "publisher" if is_publisher else "assignee" - - # 创建申诉 - dispute = { - "id": f"dispute_{int(time.time())}_{uuid.uuid4().hex[:6]}", - "task_id": task_id, - "task_title": task.get("title", ""), - "publisher": task.get("publisher"), - "assignee": task.get("assignee"), - "initiator": current_user, - "initiator_role": role, - "reason": reason, - "evidence": (evidence or [])[:6], - "status": "pending", # pending/responded/resolved - "created_at": int(time.time()), - # 被申诉方回应 - "response": None, - "response_evidence": [], - "responded_at": None, - # 仲裁结果 - "resolution": None, # favor_initiator/favor_respondent/split - "resolution_ratio": None, # 分成比例(如果是split) - "resolution_note": None, - "resolved_by": None, - "resolved_at": None - } - - disputes_db.append(dispute) - task["status"] = "disputed" - task["dispute_id"] = dispute["id"] - - db.save_data("tasks.json", tasks_db) - db.save_data("disputes.json", disputes_db) - # 🗂️ 清除排序缓存(任务状态变为争议中) - sort_cache.invalidate("tasks:") - - # 🔔 通知对方:已发起申诉 - other_party = task.get("assignee") if is_publisher else task.get("publisher") - add_notification(other_party, { - "type": "task_disputed", - "from_user": current_user, - "target_item_id": task_id, - "target_item_title": task.get("title", ""), - "content": f"对任务《{task.get('title', '')}》发起了申诉,请回应" - }) - - return {"status": "success", "data": dispute, "message": "申诉已提交,等待对方回应"} - - raise HTTPException(status_code=404, detail="任务不存在") +@shared_limit(5) +async def create_dispute(request: Request, task_id: str, payload: TaskDispute, current_user: str = Depends(require_auth), db_session: Session = Depends(get_db)): + try: + result = create_task_dispute(db_session, task_id, current_user, payload.reason, + payload.evidence or [], _read_refund_task) + except HTTPException: + db_session.rollback() + raise + except DisputePermissionError: + db_session.rollback() + raise HTTPException(status_code=403, detail="只有发布者或接单者可以发起申诉") + except ResolutionConflict: + db_session.rollback() + raise HTTPException(status_code=409, detail="任务或申诉状态已改变,请刷新核对") + except Exception as exc: + db_session.rollback() + logger.error("DISPUTE_CREATE_FAILED | error_type=%s", type(exc).__name__) + raise HTTPException(status_code=503, detail="申诉状态暂时无法核实,请稍后核对") + dispute = result["dispute"] + run_tip_effect(lambda: project_disputes(db_session.get_bind(), db, task_id=task_id)) + def refresh(): + latest = _read_dispute(dispute["id"]) + if any(latest.get(key) != dispute.get(key) for key in ("task_id", "publisher", "assignee", "initiator", "initiator_role")): + raise ValueError("dispute identity changed") + dispute.update(latest) + run_tip_effect(refresh) + run_tip_effect(lambda: sort_cache.invalidate("tasks:")) + if result["created"]: + other_party = dispute["assignee"] if dispute["initiator_role"] == "publisher" else dispute["publisher"] + run_tip_effect(lambda: add_notification(other_party, { + "type": "task_disputed", "from_user": current_user, "target_item_id": task_id, + "target_item_title": dispute.get("task_title", ""), + "content": f"对任务《{dispute.get('task_title', '')}》发起了申诉,请回应"})) + return {"status": "success", "data": dispute, "message": "申诉已提交,等待对方回应"} @router.get("/api/disputes/{dispute_id}") -async def get_dispute_detail(dispute_id: str): - """ - 获取申诉详情 - """ - disputes_db = db.load_data("disputes.json", default_data=[]) - users_db = db.load_data("users.json", default_data={}) - - # users_db 已经是 {account: user_info} 格式,直接使用 - user_map = users_db - - for dispute in disputes_db: - if dispute["id"] == dispute_id: - publisher_info = user_map.get(dispute.get("publisher"), {}) - assignee_info = user_map.get(dispute.get("assignee"), {}) - - return { - "status": "success", - "data": { - **dispute, - "publisher_name": publisher_info.get("name", dispute.get("publisher")), - "publisher_avatar": publisher_info.get("avatarDataUrl", ""), - "assignee_name": assignee_info.get("name", dispute.get("assignee")), - "assignee_avatar": assignee_info.get("avatarDataUrl", "") - } - } - - raise HTTPException(status_code=404, detail="申诉不存在") +async def get_dispute_detail(dispute_id: str, db_session: Session = Depends(get_db), current_user: str = Depends(require_auth)): + try: + dispute = read_dispute_state(db_session, db, dispute_id, current_user, is_admin(current_user)) + users = db.load_data("users.json", default_data={}, strict=True, skip_cache=True) + if not isinstance(users, dict): + raise ValueError("invalid user source") + publisher_info = users.get(dispute.get("publisher"), {}) + assignee_info = users.get(dispute.get("assignee"), {}) + if not isinstance(publisher_info, dict) or not isinstance(assignee_info, dict): + raise ValueError("invalid participant profile") + return {"status": "success", "data": { + **dispute, "publisher_name": publisher_info.get("name", dispute.get("publisher")), + "publisher_avatar": publisher_info.get("avatarDataUrl", ""), + "assignee_name": assignee_info.get("name", dispute.get("assignee")), + "assignee_avatar": assignee_info.get("avatarDataUrl", "")}} + except DisputeReadDenied: + raise HTTPException(status_code=403, detail="无权查看此申诉") + except DisputeReadMissing: + raise HTTPException(status_code=404, detail="申诉不存在") + except Exception as error: + try: + logger.error("DISPUTE_READ_FAILED | error_type=%s", type(error).__name__) + except Exception: + pass + raise HTTPException(status_code=503, detail="申诉状态暂时无法核实,请稍后核对") + finally: + db_session.rollback() @router.post("/api/disputes/{dispute_id}/respond") -async def respond_dispute(dispute_id: str, payload: TaskDisputeResponse, current_user: str = Depends(require_auth)): - """ - 被申诉方回应申诉 - """ - # 🔧 P2修复:前端以 JSON body 提交,原裸标量参数会被解析为 query 导致 422 - response = payload.response - evidence = payload.evidence - disputes_db = db.load_data("disputes.json", default_data=[]) - - for dispute in disputes_db: - if dispute["id"] == dispute_id: - # 验证权限:必须是被申诉方 - is_publisher = dispute.get("initiator_role") == "publisher" - respondent = dispute.get("assignee") if is_publisher else dispute.get("publisher") - - if current_user != respondent: - raise HTTPException(status_code=403, detail="只有被申诉方可以回应") - - if dispute.get("status") != "pending": - raise HTTPException(status_code=400, detail="该申诉已回应或已解决") - - dispute["response"] = response - dispute["response_evidence"] = (evidence or [])[:6] - dispute["responded_at"] = int(time.time()) - dispute["status"] = "responded" - - db.save_data("disputes.json", disputes_db) - # 🗂️ 清除排序缓存(争议状态变化可能影响排序) - sort_cache.invalidate("tasks:") - - # 🔔 通知申诉方:对方已回应 - add_notification(dispute.get("initiator"), { - "type": "dispute_responded", - "from_user": current_user, - "target_item_id": dispute.get("task_id"), - "target_item_title": dispute.get("task_title", ""), - "content": f"对方已回应您关于任务《{dispute.get('task_title', '')}》的申诉" - }) - - return {"status": "success", "message": "回应已提交,等待管理员仲裁"} - - raise HTTPException(status_code=404, detail="申诉不存在") +@shared_limit(5) +async def respond_dispute(request: Request, dispute_id: str, payload: TaskDisputeResponse, current_user: str = Depends(require_auth), db_session: Session = Depends(get_db)): + try: + dispute = respond_to_dispute(db_session, dispute_id, current_user, payload.response, + payload.evidence or [], _read_dispute, _read_refund_task, db) + except HTTPException: + db_session.rollback() + raise + except DisputePermissionError: + db_session.rollback() + raise HTTPException(status_code=403, detail="只有被申诉方可以回应") + except ResolutionConflict: + db_session.rollback() + raise HTTPException(status_code=409, detail="该申诉已回应或资金已处理,请刷新核对") + except Exception as exc: + db_session.rollback() + logger.error("DISPUTE_RESPONSE_FAILED | error_type=%s", type(exc).__name__) + raise HTTPException(status_code=503, detail="申诉状态暂时无法核实,请稍后核对") + run_tip_effect(lambda: sort_cache.invalidate("tasks:")) + if dispute.get("_response_changed", True): + run_tip_effect(lambda: add_notification(dispute.get("initiator"), { + "type": "dispute_responded", "from_user": current_user, + "target_item_id": dispute.get("task_id"), "target_item_title": dispute.get("task_title", ""), + "content": f"对方已回应您关于任务《{dispute.get('task_title', '')}》的申诉"})) + return {"status": "success", "message": "回应已提交,等待管理员仲裁"} @router.get("/api/admin/disputes") -async def get_admin_disputes(status: str = None, current_user: str = Depends(require_auth)): - """ - 管理员获取申诉列表 - - status: pending/responded/resolved - """ +async def get_admin_disputes(status: str = None, db_session: Session = Depends(get_db), current_user: str = Depends(require_auth)): if not is_admin(current_user): raise HTTPException(status_code=403, detail="需要管理员权限") - - disputes_db = db.load_data("disputes.json", default_data=[]) - users_db = db.load_data("users.json", default_data={}) - - # users_db 已经是 {account: user_info} 格式,直接使用 - user_map = users_db - - # 筛选 - filtered = disputes_db - if status: - filtered = [d for d in filtered if d.get("status") == status] - - # 按时间倒序 - filtered = sorted(filtered, key=lambda x: x.get("created_at", 0), reverse=True) - - # 附加用户信息 - result = [] - for dispute in filtered: - publisher_info = user_map.get(dispute.get("publisher"), {}) - assignee_info = user_map.get(dispute.get("assignee"), {}) - result.append({ - **dispute, - "publisher_name": publisher_info.get("name", dispute.get("publisher")), - "assignee_name": assignee_info.get("name", dispute.get("assignee")) - }) - - return {"status": "success", "data": result} + try: + disputes = read_admin_disputes(db_session, db) + users = db.load_data("users.json", default_data={}, strict=True, skip_cache=True) + if not isinstance(users, dict): + raise ValueError("invalid user source") + if status: + disputes = [d for d in disputes if d.get("status") == status] + result = [] + for dispute in sorted(disputes, key=lambda d: d.get("created_at", 0) or 0, reverse=True): + publisher_info = users.get(dispute.get("publisher"), {}) + assignee_info = users.get(dispute.get("assignee"), {}) + if not isinstance(publisher_info, dict) or not isinstance(assignee_info, dict): + raise ValueError("invalid participant profile") + result.append({**dispute, "publisher_name": publisher_info.get("name", dispute.get("publisher")), + "assignee_name": assignee_info.get("name", dispute.get("assignee"))}) + return {"status": "success", "data": result} + except Exception as error: + try: + logger.error("ADMIN_DISPUTE_READ_FAILED | error_type=%s", type(error).__name__) + except Exception: + pass + raise HTTPException(status_code=503, detail="申诉状态暂时无法核实,请稍后核对") + finally: + db_session.rollback() + +def _read_dispute(dispute_id): + disputes = db.load_data("disputes.json", default_data=[], strict=True, skip_cache=True) + if not isinstance(disputes, list): + raise ValueError("invalid dispute source") + matches = [entry for entry in disputes if isinstance(entry, dict) and entry.get("id") == dispute_id] + if not matches: + raise HTTPException(status_code=404, detail="申诉不存在") + if len(matches) != 1: + raise ValueError("ambiguous dispute") + return matches[0] + @router.post("/api/admin/disputes/{dispute_id}/resolve") -async def resolve_dispute(dispute_id: str, resolution: str, ratio: int = None, note: str = None, current_user: str = Depends(require_auth), db_session: Session = Depends(get_db)): - """ - 管理员裁决申诉 - - resolution: favor_initiator(支持申诉方) / favor_respondent(支持被申诉方) / split(按比例分成) - - ratio: 分成比例(0-100),表示申诉方获得的比例。仅split时有效 - - note: 裁决说明 - 💳 P0改造:使用 SQL Wallet 系统处理资金,替代 JSON balance 操作 - """ +@shared_limit(10) +async def resolve_dispute(request: Request, dispute_id: str, resolution: str, ratio: int = None, note: str = None, current_user: str = Depends(require_auth), db_session: Session = Depends(get_db)): if not is_admin(current_user): raise HTTPException(status_code=403, detail="需要管理员权限") - - if resolution not in ["favor_initiator", "favor_respondent", "split"]: + if resolution not in {"favor_initiator", "favor_respondent", "split"}: raise HTTPException(status_code=400, detail="无效的裁决结果") - - if resolution == "split" and (ratio is None or ratio < 0 or ratio > 100): + if resolution == "split" and (type(ratio) is not int or not 0 <= ratio <= 100): raise HTTPException(status_code=400, detail="分成比例必须在0-100之间") - - disputes_db = db.load_data("disputes.json", default_data=[]) - tasks_db = db.load_data("tasks.json", default_data=[]) - users_db = db.load_data("users.json", default_data={}) - - for dispute in disputes_db: - if dispute["id"] == dispute_id: - if dispute.get("status") == "resolved": - raise HTTPException(status_code=400, detail="该申诉已裁决") - - # 查找关联任务 - task = next((t for t in tasks_db if t["id"] == dispute.get("task_id")), None) - if not task: - raise HTTPException(status_code=404, detail="关联任务不存在") - - total_price = task.get("total_price", 0) - deposit = task.get("deposit_amount", 0) - - publisher_account = dispute.get("publisher") - assignee_account = dispute.get("assignee") - - is_publisher_initiator = dispute.get("initiator_role") == "publisher" - - # 💳 P0改造:使用 SQL Wallet 处理资金 - try: - # 获取双方钱包(带悲观锁) - publisher_wallet = db_session.query(Wallet).filter(Wallet.account == publisher_account).with_for_update().first() - assignee_wallet = db_session.query(Wallet).filter(Wallet.account == assignee_account).with_for_update().first() - - # 如果钱包不存在,创建新钱包 - if not publisher_wallet: - publisher_wallet = Wallet(account=publisher_account, balance=0) - db_session.add(publisher_wallet) - db_session.flush() - if not assignee_wallet: - assignee_wallet = Wallet(account=assignee_account, balance=0) - db_session.add(assignee_wallet) - db_session.flush() - - # 获取双方用户名 - publisher_info = users_db.get(publisher_account, {}) - assignee_info = users_db.get(assignee_account, {}) - publisher_name = publisher_info.get("name", publisher_account) - assignee_name = assignee_info.get("name", assignee_account) - - # 计算资金分配 - if resolution == "favor_initiator": - # 支持申诉方 - if is_publisher_initiator: - # 发布者胜诉:退还订金(从冻结余额释放到可用余额) - publisher_wallet.frozen_balance = max(0, publisher_wallet.frozen_balance - deposit) - publisher_wallet.balance += deposit - - # 记录交易 - create_task_transaction( - db_session, publisher_account, "TASK_REFUND", - deposit, task_id=task.get("id"), - task_title=task.get("title"), - related_user_name=assignee_name - ) - initiator_amount = deposit - respondent_amount = 0 - else: - # 接单者胜诉:获得全款 - remaining = total_price - deposit - # 发布者支付尾款(从冻结余额扣除) - publisher_wallet.frozen_balance = max(0, publisher_wallet.frozen_balance - remaining) - # 接单者获得全款 - assignee_wallet.balance += total_price - assignee_wallet.task_balance += total_price - - # 记录交易 - create_task_transaction( - db_session, publisher_account, "TASK_PAYMENT", - -remaining, related_account=assignee_account, task_id=task.get("id"), - task_title=task.get("title"), - related_user_name=assignee_name - ) - create_task_transaction( - db_session, assignee_account, "TASK_INCOME", - total_price, related_account=publisher_account, task_id=task.get("id"), - task_title=task.get("title"), - related_user_name=publisher_name - ) - initiator_amount = total_price - respondent_amount = 0 - - elif resolution == "favor_respondent": - # 支持被申诉方 - if is_publisher_initiator: - # 接单者胜诉:获得全款 - remaining = total_price - deposit - # 发布者支付尾款(从冻结余额扣除) - publisher_wallet.frozen_balance = max(0, publisher_wallet.frozen_balance - remaining) - # 接单者获得全款 - assignee_wallet.balance += total_price - assignee_wallet.task_balance += total_price - - # 记录交易 - create_task_transaction( - db_session, publisher_account, "TASK_PAYMENT", - -remaining, related_account=assignee_account, task_id=task.get("id"), - task_title=task.get("title"), - related_user_name=assignee_name - ) - create_task_transaction( - db_session, assignee_account, "TASK_INCOME", - total_price, related_account=publisher_account, task_id=task.get("id"), - task_title=task.get("title"), - related_user_name=publisher_name - ) - initiator_amount = 0 - respondent_amount = total_price - else: - # 发布者胜诉:退还订金(从冻结余额释放到可用余额) - publisher_wallet.frozen_balance = max(0, publisher_wallet.frozen_balance - deposit) - publisher_wallet.balance += deposit - - # 记录交易 - create_task_transaction( - db_session, publisher_account, "TASK_REFUND", - deposit, task_id=task.get("id"), - task_title=task.get("title"), - related_user_name=assignee_name - ) - initiator_amount = 0 - respondent_amount = deposit - - else: - # 按比例分成 - initiator_share = int(total_price * ratio / 100) - respondent_share = total_price - initiator_share - remaining = total_price - deposit - - # 发布者支付尾款(从冻结余额扣除) - publisher_wallet.frozen_balance = max(0, publisher_wallet.frozen_balance - remaining) - - # 发布者获得退款部分,接单者获得报酬部分 - if is_publisher_initiator: - # 申诉方是发布者 - pub_refund = initiator_share - assignee_earn = respondent_share - else: - # 申诉方是接单者 - assignee_earn = initiator_share - pub_refund = respondent_share - - # 分配资金 - publisher_wallet.balance += pub_refund - assignee_wallet.balance += assignee_earn - assignee_wallet.task_balance += assignee_earn - - # 记录交易 - create_task_transaction( - db_session, publisher_account, "TASK_PAYMENT", - -remaining, related_account=assignee_account, task_id=task.get("id"), - task_title=task.get("title"), - related_user_name=assignee_name - ) - create_task_transaction( - db_session, assignee_account, "TASK_INCOME", - assignee_earn, related_account=publisher_account, task_id=task.get("id"), - task_title=task.get("title"), - related_user_name=publisher_name - ) - - initiator_amount = initiator_share - respondent_amount = respondent_share - - # 提交 SQL 事务 - db_session.commit() - - except Exception as e: - db_session.rollback() - logger.error(f"DISPUTE_RESOLVE_ERROR | dispute={dispute_id} | error={str(e)}") - raise HTTPException(status_code=500, detail="仲裁资金处理失败,请稍后重试") - - # 更新申诉状态 - dispute["resolution"] = resolution - dispute["resolution_ratio"] = ratio - dispute["resolution_note"] = note - dispute["resolved_by"] = current_user - dispute["resolved_at"] = int(time.time()) - dispute["status"] = "resolved" - - # 更新任务状态 - task["status"] = "completed" - task["completed_at"] = int(time.time()) - task["feedback"] = f"申诉裁决:{note or '无'}(结果:{resolution})" - - db.save_data("disputes.json", disputes_db) - db.save_data("tasks.json", tasks_db) - # 🗂️ 清除排序缓存(任务状态变为已完成) - sort_cache.invalidate("tasks:") - - # 🔔 通知双方:仲裁结果 - resolution_text = { - "favor_initiator": "支持申诉方", - "favor_respondent": "支持被申诉方", - "split": f"双方协商分成({ratio}%:{100-ratio}%)" - }.get(resolution, "已裁决") - - # 通知申诉方 - add_notification(dispute.get("initiator"), { - "type": "dispute_resolved", - "from_user": current_user, - "target_item_id": dispute.get("task_id"), - "target_item_title": dispute.get("task_title", ""), - "content": f"您的申诉已裁决:{resolution_text},您获得{initiator_amount}积分" - }) - - # 通知被申诉方 - respondent_account = dispute.get("assignee") if is_publisher_initiator else dispute.get("publisher") - add_notification(respondent_account, { - "type": "dispute_resolved", - "from_user": current_user, - "target_item_id": dispute.get("task_id"), - "target_item_title": dispute.get("task_title", ""), - "content": f"任务申诉已裁决:{resolution_text},您获得{respondent_amount}积分" - }) - - logger.info(f"DISPUTE_RESOLVED | dispute={dispute_id} | resolution={resolution} | initiator_amount={initiator_amount} | respondent_amount={respondent_amount}") - - return {"status": "success", "message": f"裁决完成:{resolution_text}"} - - raise HTTPException(status_code=404, detail="申诉不存在") + try: + result = resolve_task(db_session, dispute_id, resolution, ratio, note, current_user, _read_dispute, _read_refund_task) + except HTTPException: + db_session.rollback() + raise + except ResolutionConflict: + db_session.rollback() + raise HTTPException(status_code=409, detail="任务资金已处理或裁决已固定,请先核对任务与流水") + except Exception as error: + db_session.rollback() + run_tip_effect(lambda: logger.error(f"DISPUTE_RESOLVE_ERROR | error_type={type(error).__name__}")) + raise HTTPException(status_code=503, detail="裁决结果未确认,请先查询任务和钱包") + run_tip_effect(lambda: project_resolutions(db_session.get_bind(), db, task_id=result["task"]["id"])) + run_tip_effect(lambda: sort_cache.invalidate("tasks:")) + labels = {"favor_initiator":"支持申诉方", "favor_respondent":"支持被申诉方", "split":f"双方协商分成({result['ratio']}%)"} + if result["paid"]: + for account,amount in ((result["publisher"],result["publisher_refund"]),(result["assignee"],result["assignee_income"])): + run_tip_effect(lambda account=account, amount=amount: add_notification(account, { + "type":"dispute_resolved", "from_user":current_user, "target_item_id":result["task"]["id"], + "target_item_title":result["task"].get("title", ""), "content":f"任务申诉已裁决,您获得{amount}积分"})) + run_tip_effect(lambda: logger.info("DISPUTE_RESOLVED")) + return {"status":"success", "message":f"裁决完成:{labels[result['resolution']]}"} # ========================================== # 📊 我的任务 # ========================================== @router.get("/api/my-tasks") -async def get_my_tasks(role: str = "publisher", current_user: str = Depends(require_auth)): +async def get_my_tasks(role: str = "publisher", db_session: Session = Depends(get_db), current_user: str = Depends(require_auth)): """ 获取我的任务 - role=publisher: 我发布的任务 - role=assignee: 我接的任务 """ - tasks_db = db.load_data("tasks.json", default_data=[]) + tasks_db = _read_task_pages(db_session) if role == "publisher": my_tasks = [t for t in tasks_db if t.get("publisher") == current_user] @@ -1409,13 +790,19 @@ async def get_my_tasks(role: str = "publisher", current_user: str = Depends(requ result = [] for task in my_tasks: task_data = {k: v for k, v in task.items() if k not in ["viewed_by", "liked_by", "favorited_by"]} + task_data["applicant_count"] = len(task.get("applicants", [])) + task_data["has_applied"] = any(app.get("account") == current_user for app in task.get("applicants", [])) + if task.get("publisher") != current_user: + task_data["applicants"] = [] result.append(task_data) + attach_comment_counts(result) return {"status": "success", "data": result} @router.post("/api/tasks/{task_id}/view") -async def record_task_view(task_id: str, current_user: str = Depends(require_auth)): +@shared_limit(60) +async def record_task_view(request: Request, task_id: str, current_user: str = Depends(require_auth)): """ 记录任务访问量 👁️ 需要用户认证,每个用户只计算一次总访问量,日访问量每次调用都增加 @@ -1435,7 +822,8 @@ async def record_task_view(task_id: str, current_user: str = Depends(require_aut # ========================================== @router.post("/api/tasks/{task_id}/like") -async def toggle_like(task_id: str, current_user: str = Depends(require_auth)): +@shared_limit(30) +async def toggle_like(request: Request, task_id: str, current_user: str = Depends(require_auth)): """ 点赞/取消点赞(原子操作,并发安全) """ @@ -1469,7 +857,8 @@ async def toggle_like(task_id: str, current_user: str = Depends(require_auth)): return result_container[0] @router.post("/api/tasks/{task_id}/favorite") -async def toggle_favorite(task_id: str, current_user: str = Depends(require_auth)): +@shared_limit(30) +async def toggle_favorite(request: Request, task_id: str, current_user: str = Depends(require_auth)): """ 收藏/取消收藏(原子操作,并发安全) """ @@ -1507,7 +896,8 @@ async def toggle_favorite(task_id: str, current_user: str = Depends(require_auth # ========================================== @router.post("/api/tasks/{task_id}/tip") -async def tip_task(task_id: str, amount: int, is_anon: bool = False, current_user: str = Depends(require_auth), db_session: Session = Depends(get_db)): +@shared_limit(10) +async def tip_task(request: Request, task_id: str, amount: int, is_anon: bool = False, current_user: str = Depends(require_auth), db_session: Session = Depends(get_db)): """ 打赏任务(原子操作,并发安全)- 使用 SQL Wallet 系统 """ @@ -1533,6 +923,8 @@ async def tip_task(task_id: str, amount: int, is_anon: bool = False, current_use # 💳 使用 SQL Wallet 系统处理余额转账(先扣款) try: + # 🔒 并发安全:使用悲观锁获取双方钱包 + tipper_wallet, author_wallet = lock_tip_wallets(db_session, current_user, publisher) # 🔒 P1幂等性防护:检查最近5秒内是否存在相同交易 recent_cutoff = datetime.datetime.utcnow() - datetime.timedelta(seconds=5) duplicate_tx = db_session.query(Transaction).filter( @@ -1541,20 +933,14 @@ async def tip_task(task_id: str, amount: int, is_anon: bool = False, current_use Transaction.amount == -amount, Transaction.related_account == publisher, Transaction.created_at >= recent_cutoff - ).first() + ).with_for_update().first() if duplicate_tx: + db_session.rollback() # 重复请求释放锁,不执行任何后续操作 return {"status": "success", "message": "打赏已处理(重复请求)"} - # 🔒 并发安全:使用悲观锁获取双方钱包 - tipper_wallet = db_session.query(Wallet).filter(Wallet.account == current_user).with_for_update().first() - author_wallet = db_session.query(Wallet).filter(Wallet.account == publisher).with_for_update().first() - if not tipper_wallet or tipper_wallet.balance < amount: raise HTTPException(status_code=400, detail="余额不足") - if not author_wallet: - # 如果作者钱包不存在,创建一个 - author_wallet = Wallet(account=publisher, balance=0, earn_balance=0, tip_balance=0, frozen_balance=0) - db_session.add(author_wallet) + # 执行转账 tipper_wallet.balance -= amount @@ -1615,8 +1001,17 @@ async def tip_task(task_id: str, amount: int, is_anon: bool = False, current_use db_session.commit() # 📝 审计日志 - logger.info(f"TASK_TIP | task={task_id} | amount={amount} | anon={is_anon}") + logger.info(f"TASK_TIP | task={task_id}") + except HTTPException: + db_session.rollback() + raise + except Exception as e: + db_session.rollback() + logger.error(f"TASK_TIP_ERROR | task={task_id} | error_type={type(e).__name__}") + raise HTTPException(status_code=500, detail="打赏处理失败,请稍后重试") + + def notify_tip(): # 🔔 打赏通知(考虑匿名) if not is_anon: add_notification(publisher, { @@ -1634,18 +1029,10 @@ async def tip_task(task_id: str, amount: int, is_anon: bool = False, current_use "target_item_title": "", "content": f"您收到了一份 {amount} 积分的匿名任务打赏" }) - - # 🗂️ 清除排序缓存(打赏可能影响排序) - sort_cache.invalidate("tasks:") - - except HTTPException: - db_session.rollback() - raise - except Exception as e: - db_session.rollback() - logger.error(f"TASK_TIP_ERROR | task={task_id} | amount={amount} | error={str(e)}") - raise HTTPException(status_code=500, detail="打赏处理失败,请稍后重试") + run_tip_effect(notify_tip) + run_tip_effect(lambda: sort_cache.invalidate("tasks:")) + # 🏆 扣款成功后再写打赏榜(写榜失败仅记录日志,不影响资金安全) def board_updater(data): for task in data: @@ -1663,7 +1050,7 @@ async def tip_task(task_id: str, amount: int, is_anon: bool = False, current_use try: db.atomic_update("tasks.json", board_updater, default_data=[]) except Exception as e: - logger.error(f"TASK_TIP_BOARD_ERROR | task={task_id} | error={str(e)}") + logger.error(f"TASK_TIP_BOARD_ERROR | task={task_id} | error_type={type(e).__name__}") return {"status": "success", "message": f"成功打赏 {amount} 积分"} @@ -1698,7 +1085,8 @@ async def get_task_comments(task_id: str): return {"status": "success", "data": result} @router.post("/api/tasks/{task_id}/comments") -async def add_task_comment(task_id: str, content: str, current_user: str = Depends(require_auth)): +@shared_limit(10) +async def add_task_comment(request: Request, task_id: str, content: str, current_user: str = Depends(require_auth)): """ 添加任务评论 """ @@ -1706,7 +1094,6 @@ async def add_task_comment(task_id: str, content: str, current_user: str = Depen raise HTTPException(status_code=400, detail="评论内容不能为空") tasks_db = db.load_data("tasks.json", default_data=[]) - comments_db = db.load_data("comments.json", default_data={}) # 检查任务是否存在 task_exists = any(t["id"] == task_id for t in tasks_db) @@ -1720,20 +1107,10 @@ async def add_task_comment(task_id: str, content: str, current_user: str = Depen "created_at": int(time.time()) } - # comments_db 是 {item_id: [comments]} 格式 - task_comments = comments_db.get(task_id, []) - task_comments.insert(0, new_comment) - comments_db[task_id] = task_comments - db.save_data("comments.json", comments_db) - + append_comment(task_id, new_comment, prepend=True) + increment_comment_count("tasks.json", task_id) + # 🗂️ 清除排序缓存(评论数变化可能影响排序) sort_cache.invalidate("tasks:") - # 更新任务评论数 - for task in tasks_db: - if task["id"] == task_id: - task["comments"] = task.get("comments", 0) + 1 - break - db.save_data("tasks.json", tasks_db) - return {"status": "success", "data": new_comment} diff --git a/router_users_auth.py b/router_users_auth.py index 8abc0ee17026550a107cf5aeecd74295f7182597..fca911711f8d3eab35fb7114516c3b794795dfed 100644 --- a/router_users_auth.py +++ b/router_users_auth.py @@ -11,25 +11,26 @@ # ========================================== from fastapi import APIRouter, HTTPException, BackgroundTasks, Request +from rate_limit import shared_limit, store_verification_code, consume_verification_code import asyncio import time import re import random import json +import logging import 数据库连接 as db from models import UserRegister, UserLogin, SendCodeRequest -from verify_code_engine import VERIFY_CODES, send_email_code, send_sms_code, cleanup_expired_codes, check_send_cooldown +from verify_code_engine import send_email_code, send_sms_code, cleanup_expired_codes, check_send_cooldown # 🔒 P0安全增强:导入密码哈希和 JWT 工具 from 安全认证 import hash_password, verify_password, create_token, require_password_match -# 🔒 验证码并发保护锁 -_verify_code_lock = asyncio.Lock() +from user_store import read_user, check_registration, create_user, finish_login, reset_user_password, get_password_version + +logger = logging.getLogger("ComfyUI-Ranking.UsersAuth") + # 🚀 P2优化:速率限制 -from slowapi import Limiter -from slowapi.util import get_remote_address -limiter = Limiter(key_func=get_remote_address) # 创建子路由实例 router = APIRouter() @@ -42,7 +43,7 @@ router = APIRouter() # 关联:verify_code_engine.py 的 send_email_code / send_sms_code # 前端调用:注册表单组件.js、重置密码表单组件.js @router.post("/api/users/send-code") -@limiter.limit("5/minute") # 🚀 P2优化:每分钟最多5次 +@shared_limit(5) # 🚀 P2优化:每分钟最多5次 async def send_verify_code(request: Request, req: SendCodeRequest, bg_tasks: BackgroundTasks): """ 发送验证码接口(异步版本) @@ -92,11 +93,8 @@ async def send_verify_code(request: Request, req: SendCodeRequest, bg_tasks: Bac # 🔒 P0安全加固:构建缓存键(联系方式:动作类型),使用冒号分隔符避免与邮箱中的下划线混淆 cache_key = f"{req.contact}:{req.action_type}" - # 将验证码存入内存缓存,有效期5分钟 - VERIFY_CODES[cache_key] = { - "code": code, - "expires_at": int(time.time()) + 300 # 当前时间 + 300秒(5分钟) - } + # 将验证码安全摘要写入 SQL,有效期5分钟 + await asyncio.to_thread(store_verification_code, cache_key, code, int(time.time()) + 300) # 根据联系方式类型,添加后台发送任务 if req.contact_type == "email": @@ -115,7 +113,7 @@ async def send_verify_code(request: Request, req: SendCodeRequest, bg_tasks: Bac # 作用:同步方式发送验证码,部分场景需要立即知道发送结果 # 关联:verify_code_engine.py 的 send_email_code @router.post("/api/users/send_code") -@limiter.limit("5/minute") # 🔒 安全限流:与异步版对齐,每分钟最多5次 +@shared_limit(5) # 🔒 安全限流:与异步版对齐,每分钟最多5次 async def send_code_api(request: Request, req: SendCodeRequest): """发送验证码接口(同步版本,直接等待发送结果)""" # 🔎 找回账号:凭绑定邮箱/手机反查,无需账号 @@ -138,10 +136,7 @@ async def send_code_api(request: Request, req: SendCodeRequest): key = f"{req.contact}:{req.action_type}" # 存入缓存,有效期5分钟 - VERIFY_CODES[key] = { - "code": code, - "expires_at": time.time() + 300 # 300秒(5分钟) - } + await asyncio.to_thread(store_verification_code, key, code, int(time.time()) + 300) # 同步发送(会阻塞等待结果) # ⚡ 性能修复:send_email_code 内部走 SMTP 同步连接(最长可阻塞十秒级), @@ -153,8 +148,7 @@ async def send_code_api(request: Request, req: SendCodeRequest): return {"status": "success", "message": "验证码已成功发送至邮箱"} except Exception as e: # 🔒 P0安全修复:仅服务端记录详细错误,不暴露给前端 - import traceback - traceback.print_exc() + logger.error(f"验证码邮件发送失败: action={req.action_type}, error_type={type(e).__name__}") raise HTTPException(status_code=500, detail="邮件发送失败,请稍后重试") elif req.contact_type == "phone": @@ -169,11 +163,11 @@ async def send_code_api(request: Request, req: SendCodeRequest): # ========================================== # 作用:用户同时忘记账号和密码时,凭绑定邮箱收到的验证码找回账号 # 关联: -# - verify_code_engine.py 的 VERIFY_CODES (校验 recover 验证码) +# - verify_code_engine.py 的 SQL 验证码表 (校验 recover 验证码) # - 数据库连接.py (遍历 users.json 按邮箱反查账号) # - 前端 找回账号表单组件.js @router.post("/api/users/recover_account") -@limiter.limit("3/minute") # 🔒 安全限流:每分钟最多3次 +@shared_limit(3) # 🔒 安全限流:每分钟最多3次 async def recover_account(request: Request): """ 找回账号接口 @@ -201,12 +195,8 @@ async def recover_account(request: Request): # ========== 第二步:验证码校验(原子性一次性消费,并发安全) ========== cache_key = f"{email}:recover" - async with _verify_code_lock: - cached = VERIFY_CODES.pop(cache_key, None) - expire_time = cached.get("expires_at", cached.get("expires", 0)) if cached else 0 - # 🛡️ 防枚举:验证码错误与邮箱未注册统一报错,不暴露账号注册状态 - if not cached or cached["code"] != code or time.time() > expire_time: + if not await asyncio.to_thread(consume_verification_code, cache_key, code): raise HTTPException(status_code=400, detail="验证码不正确或已过期") # ========== 第三步:按邮箱反查账号 ========== @@ -234,11 +224,11 @@ async def recover_account(request: Request): # ========================================== # 作用:新用户注册,需要验证邮箱/手机验证码 # 关联: -# - verify_code_engine.py 的 VERIFY_CODES (校验验证码) +# - verify_code_engine.py 的 SQL 验证码表 (校验验证码) # - 数据库连接.py (保存新用户到 users.json) # - 前端 注册表单组件.js @router.post("/api/users/register") -@limiter.limit("3/minute") # 🔒 P0安全优化:注册每分钟最多3次 +@shared_limit(3) # 🔒 P0安全优化:注册每分钟最多3次 async def register_user(request: Request, user: UserRegister): """ 用户注册接口 @@ -252,37 +242,17 @@ async def register_user(request: Request, user: UserRegister): - code: 验证码 - intro: 个人介绍(可选,最多100字) """ - users_db = db.load_data("users.json", default_data={}) - - # ========== 第一步:查重检查 ========== - # 检查账号是否已存在 - if user.account in users_db: - raise HTTPException(status_code=400, detail="该账号已被注册,请更换一个") - - # 🔒 禁止保留账号名 + await asyncio.to_thread(check_registration, user.account, user.email, user.phone) RESERVED_ACCOUNTS = {"admin", "system", "root", "test", "anonymous", "administrator"} if user.account.lower() in RESERVED_ACCOUNTS: raise HTTPException(status_code=400, detail="该账号名不可用") - - # 检查邮箱和手机号是否已被其他用户绑定 - for existing_user in users_db.values(): - if user.email and existing_user.get("email") == user.email: - raise HTTPException(status_code=400, detail="此邮箱已注册,请直接登录或找回密码") - if user.phone and existing_user.get("phone") == user.phone: - raise HTTPException(status_code=400, detail="该手机号已被绑定") - + # ========== 第二步:验证码校验(并发安全) ========== # 根据注册方式构建缓存键 cache_key = f"{user.email}:register" if user.email else f"{user.phone}:register" # 🔒 P0安全修复:验证码一次性使用,并发安全键防止并发重用 - async with _verify_code_lock: - cached = VERIFY_CODES.pop(cache_key, None) - - # 兼容新老缓存格式(expires_at 或 expires) - expire_time = cached.get("expires_at", cached.get("expires", 0)) if cached else 0 - # 校验验证码是否正确且未过期 - if not cached or cached["code"] != user.code or time.time() > expire_time: + if not await asyncio.to_thread(consume_verification_code, cache_key, user.code): raise HTTPException(status_code=400, detail="验证码不正确或已过期") # ========== 第三步:格式校验 ========== @@ -299,7 +269,8 @@ async def register_user(request: Request, user: UserRegister): # ========== 第四步:保存新用户 ========== # 构建用户数据对象 - new_user = user.dict() + dump = getattr(user, "model_dump", None) or user.dict + new_user = dump() new_user.pop("code", None) # 移除验证码字段,不存入数据库 # 🔒 安全防护:拒绝 base64 头像数据 @@ -307,7 +278,7 @@ async def register_user(request: Request, user: UserRegister): new_user["avatarDataUrl"] = "" # 🔒 P0安全增强:密码哈希化存储(不再存储明文密码) - new_user["password"] = hash_password(new_user["password"]) + new_user["password"] = await asyncio.to_thread(hash_password, new_user["password"]) new_user.update({ "created_at": int(time.time()), # 注册时间戳 @@ -317,11 +288,8 @@ async def register_user(request: Request, user: UserRegister): }) # 保存到数据库 - users_db[user.account] = new_user - db.save_data("users.json", users_db) - - # 返回用户信息(排除密码) - return {"status": "success", "message": "注册成功", "data": {k: v for k, v in new_user.items() if k != "password"}} + public_user = await asyncio.to_thread(create_user, new_user) + return {"status": "success", "message": "注册成功", "data": public_user} # ========================================== @@ -332,7 +300,7 @@ async def register_user(request: Request, user: UserRegister): # - 数据库连接.py (读取 users.json 校验密码) # - 前端 登录表单组件.js @router.post("/api/users/login") -@limiter.limit("10/minute") # 🚀 P2优化:每分钟最多10次登录尝试 +@shared_limit(10) # 🚀 P2优化:每分钟最多10次登录尝试 async def login_user(request: Request, user: UserLogin): """ 用户登录接口 @@ -341,29 +309,22 @@ async def login_user(request: Request, user: UserLogin): - account: 账号 - password: 密码 """ - users_db = db.load_data("users.json", default_data={}) - - # 检查账号是否存在 - if user.account not in users_db: - raise HTTPException(status_code=404, detail="账号不存在") - - user_data = users_db[user.account] + user_data = await asyncio.to_thread(read_user, user.account) stored_password = user_data.get("password") or "" - - # 🔒 P0安全增强:密码哈希验证(使用统一验证函数) - if not require_password_match(stored_password, user.password): + if not isinstance(stored_password, str): + raise HTTPException(status_code=503, detail="用户认证数据暂不可用") + password_version = get_password_version(user_data) + if not await asyncio.to_thread(require_password_match, stored_password, user.password): raise HTTPException(status_code=401, detail="密码错误") - - # 🔒 P0安全增强:登录成功后,检查是否需要迁移旧密码为bcrypt - # 只在旧格式密码验证成功后才进行迁移(require_password_match已通过) - if stored_password and not stored_password.startswith('$2b$') and not stored_password.startswith('$2a$'): - # 旧版SHA256密码,自动迁移为bcrypt - user_data["password"] = hash_password(user.password) - db.save_data("users.json", users_db) - + migrated = None + if stored_password and not stored_password.startswith(('$2b$', '$2a$')): + migrated = await asyncio.to_thread(hash_password, user.password) + # bcrypt 运行期间可能发生密码重置,不能用旧密码升级覆盖新密码。 + user_data = await asyncio.to_thread(finish_login, user.account, stored_password, password_version, migrated) + # 🔒 P0安全增强:生成 JWT Token(替代 mock_token) # 获取password_version用于Token生成(如不存在则默认为0) - password_version = user_data.get("password_version", 0) + password_version = get_password_version(user_data) # 根据用户选择的"保持登录"选项设置Token有效期 token = create_token(user.account, extra_data={"pwd_ver": password_version}, remember=user.remember) @@ -381,12 +342,12 @@ async def login_user(request: Request, user: UserLogin): # ========================================== # 作用:通过邮箱/手机验证码重置用户密码 # 关联: -# - verify_code_engine.py 的 VERIFY_CODES (校验验证码) +# - verify_code_engine.py 的 SQL 验证码表 (校验验证码) # - 数据库连接.py (更新 users.json 中的密码) # - 前端 重置密码表单组件.js # 特点:万能解析器,兼容各种前端数据格式 @router.post("/api/users/reset_password") -@limiter.limit("3/minute") # 🔒 P0安全优化:重置密码每分钟最多3次 +@shared_limit(3) # 🔒 P0安全优化:重置密码每分钟最多3次 async def reset_password(request: Request): """ 重置密码接口(万能兼容版) @@ -426,46 +387,29 @@ async def reset_password(request: Request): if not all([account, new_password, verify_contact, verify_type, code]): raise HTTPException(status_code=400, detail="缺失必要参数 (账号/密码/验证码/联系方式),请检查表单") - # ========== 第三步:核心业务逻辑 ========== - users_db = db.load_data("users.json", default_data={}) - - # 检查用户是否存在 - if account not in users_db: - raise HTTPException(status_code=404, detail="该用户不存在") - user = users_db[account] - - # 校验联系方式是否与账号绑定的一致 - if verify_type == "email" and user.get("email") != verify_contact: - raise HTTPException(status_code=400, detail="填写的邮箱与该账号绑定的邮箱不匹配") - if verify_type == "phone" and user.get("phone") != verify_contact: - raise HTTPException(status_code=400, detail="填写的手机号与该账号绑定的手机号不匹配") + if not all(isinstance(value, str) and value for value in (account, new_password, verify_contact, verify_type, code)): + raise HTTPException(status_code=400, detail="请求参数格式不正确") + if verify_type not in ("email", "phone"): + raise HTTPException(status_code=400, detail="联系方式类型不正确") + user = await asyncio.to_thread(read_user, account) + if user.get(verify_type) != verify_contact: + raise HTTPException(status_code=400, detail="填写的联系方式与该账号绑定信息不匹配") # 校验验证码(原子性获取+删除) cache_key = f"{verify_contact}:reset" - # 🔒 P0安全修复:验证码一次性使用,原子性pop防止并发重用 - cached = VERIFY_CODES.pop(cache_key, None) - expire_time = cached.get("expires_at", cached.get("expires", 0)) if cached else 0 - - if not cached or cached["code"] != code or time.time() > expire_time: + if not await asyncio.to_thread(consume_verification_code, cache_key, code): raise HTTPException(status_code=400, detail="验证码不正确或已过期") # 校验新密码格式 - if len(new_password) < 6: - raise HTTPException(status_code=400, detail="新密码必须大于等于6个字符") + if len(new_password) < 6 or len(new_password) > 64: + raise HTTPException(status_code=400, detail="新密码长度必须为6-64个字符") if not re.match(r'^[a-zA-Z0-9!@#$%^&*()_+\-=\[\]{};\':"\\|,.<>\/?]{6,}$', new_password): raise HTTPException(status_code=400, detail="新密码包含不支持的特殊字符") - # ========== 第四步:更新密码并保存 ========== - # 🔒 P0安全增强:新密码哈希化存储 - user["password"] = hash_password(new_password) - - # 🔒 P0安全增强:更新password_version使旧Token失效 - import time as time_module - user["password_version"] = int(time_module.time()) - - db.save_data("users.json", users_db) - - # 🔒 P0安全增强:生成新Token返回给前端替换旧Token - new_token = create_token(account, extra_data={"pwd_ver": user["password_version"]}) - + password_hash = await asyncio.to_thread(hash_password, new_password) + # 保存最新记录时再次检查绑定信息,版本严格递增(同秒重置也撤销旧凭证)。 + version = await asyncio.to_thread(reset_user_password, account, verify_type, verify_contact, password_hash) + new_token = create_token(account, extra_data={"pwd_ver": version}) + return {"status": "success", "message": "密码修改成功", "token": new_token} + diff --git a/router_users_profile.py b/router_users_profile.py index 37e3c390c6b403f7d128a9db183a2333ee0aa839..1119050e6ad50a4ae9ed475bb9e8d2d6c02a67f6 100644 --- a/router_users_profile.py +++ b/router_users_profile.py @@ -12,10 +12,15 @@ # - 个人设置表单组件.js (更新用户资料) # ========================================== -from fastapi import APIRouter, HTTPException, Depends +from fastapi import APIRouter, HTTPException, Depends, Request +from rate_limit import shared_limit import 数据库连接 as db from models import UserUpdate -from 安全认证 import require_auth +from 安全认证 import require_auth, optional_auth +from user_store import update_profile, project_profile, read_user, attach_profile_counts +import logging + +logger = logging.getLogger(__name__) # 创建子路由实例 router = APIRouter() @@ -24,7 +29,7 @@ router = APIRouter() # ========================================== # 📖 获取用户资料接口 # ========================================== -# 作用:根据账号获取用户的完整资料信息 +# 作用:读取公开资料;有效 JWT 所属本人另可读取联系方式 # 关联: # - users.json (存储用户基本信息) # - items.json (统计用户发布的内容获得的互动数据) @@ -32,46 +37,33 @@ router = APIRouter() # - 个人中心视图.js 的 fetchUserProfile() # - 列表卡片组件.js 显示作者信息时也会调用 @router.get("/api/users/{account}") -async def get_user_profile(account: str): +async def get_user_profile(account: str, current_user: str = Depends(optional_auth)): """ - 获取用户资料接口 + 获取公开用户资料,本人 JWT 另可读取联系方式。 路径参数: - account: 用户账号 返回数据: - - 用户基本信息(排除密码) + - 公开展示字段;联系方式仅有效 JWT 所属本人可读 - receivedLikes: 收到的总点赞数 - receivedFavorites: 收到的总收藏数 - receivedUses: 发布内容被使用的总次数 """ - # 加载用户数据库 - users_db = db.load_data("users.json", default_data={}) - - # 检查用户是否存在 - if account not in users_db: - raise HTTPException(status_code=404, detail="用户不存在") - - user_data = users_db[account] - - # ========== 统计用户作品获得的互动数据 ========== - # 作用:遍历所有内容,统计该用户发布的内容收到的互动 - # 关联:items.json 存储所有发布的工具/应用/推荐内容 - items_db = db.load_data("items.json", default_data=[]) - user_items = [item for item in items_db if item.get("author") == account] - - # 累加所有作品的互动数据 - user_data["receivedLikes"] = sum(item.get("likes", 0) for item in user_items) - user_data["receivedFavorites"] = sum(item.get("favorites", 0) for item in user_items) - user_data["receivedUses"] = sum(item.get("uses", 0) for item in user_items) - - # 🔒 运行时自愈:如果 avatarDataUrl 是 base64,清除它并持久化修复 - if (user_data.get("avatarDataUrl") or "").startswith("data:"): + user_data = read_user(account) + # 作品数据读取失败不当作空列表清零既有计数,也不阻断有效资料。 + try: + items_db = db.load_data("items.json", default_data=[], strict=True) + except Exception as error: + logger.warning("用户作品统计暂不可用: %s", type(error).__name__) + items_db = None + user_data = attach_profile_counts(user_data, items_db) + + # GET 不拿旧数据库快照写回;旧 base64 头像只在响应中隐藏。 + if isinstance(user_data.get("avatarDataUrl"), str) and user_data["avatarDataUrl"].startswith("data:"): user_data["avatarDataUrl"] = "" - users_db[account] = user_data - db.save_data("users.json", users_db) - - # 返回用户数据(排除敏感的密码字段) - return {"status": "success", "data": {k: v for k, v in user_data.items() if k != "password"}} + # 白名单不返回密码、验证码或未来新增的内部字段;联系方式/生日仅本人 JWT 可读。 + return {"status": "success", "data": project_profile(user_data, owner=current_user == account)} + # ========================================== @@ -84,7 +76,8 @@ async def get_user_profile(account: str): # 前端调用: # - 个人设置表单组件.js 的 handleSaveProfile() @router.put("/api/users/{account}") -async def update_user_profile(account: str, update_data: UserUpdate, current_user: str = Depends(require_auth)): +@shared_limit(10) +async def update_user_profile(request: Request, account: str, update_data: UserUpdate, current_user: str = Depends(require_auth)): """ 更新用户资料接口 @@ -103,35 +96,10 @@ async def update_user_profile(account: str, update_data: UserUpdate, current_use if account != current_user: raise HTTPException(status_code=403, detail="只能更新自己的资料") - # 加载用户数据库 - users_db = db.load_data("users.json", default_data={}) - - # 检查用户是否存在 - if account not in users_db: - raise HTTPException(status_code=404, detail="用户不存在") - - # ========== 字段校验 ========== - # 个人介绍长度限制 - if update_data.intro and len(update_data.intro) > 100: + # 个人介绍长度沿用原约束,其余字段由 UserUpdate 校验。 + if update_data.intro and len(update_data.intro) > 100: raise HTTPException(status_code=400, detail="个人介绍不能超过100个字符") - - # ========== 更新用户数据 ========== - user = users_db[account] - - # 遍历请求中的字段,只更新非空值 - # exclude_unset=True 表示只包含请求中明确传递的字段 - update_dict = update_data.dict(exclude_unset=True) - - # 🔒 安全防护:拒绝 base64 头像数据,防止 JSON 膨胀 - if (update_dict.get("avatarDataUrl") or "").startswith("data:"): - del update_dict["avatarDataUrl"] - - for k, v in update_dict.items(): - if v is not None: - user[k] = v - - # 保存更新后的数据 - db.save_data("users.json", users_db) - - # 返回更新后的用户数据(排除密码) - return {"status": "success", "data": {k: v for k, v in user.items() if k != "password"}} + dump = getattr(update_data, "model_dump", None) or update_data.dict + # 读取、账号存在性检查、字段更新及保存均在同一文件锁内。 + user = update_profile(account, dump(exclude_unset=True)) + return {"status": "success", "data": user} diff --git a/router_users_social.py b/router_users_social.py index 019609c930a98fb1abf600f362d0fb79537c4dfc..cc211d55065331b167cc146ff163e88c381f121f 100644 --- a/router_users_social.py +++ b/router_users_social.py @@ -13,11 +13,15 @@ # - 个人设置表单组件.js (隐私设置) # ========================================== -from fastapi import APIRouter, HTTPException, Depends -import 数据库连接 as db +from fastapi import APIRouter, HTTPException, Depends, Request +from rate_limit import shared_limit from notifications import add_notification from models import FollowToggle, PrivacySettings from 安全认证 import require_auth +from user_store import set_follow, update_user_privacy +import logging + +logger = logging.getLogger(__name__) # 创建子路由实例 router = APIRouter() @@ -36,7 +40,8 @@ router = APIRouter() # 前端调用: # - 个人中心视图.js 的 handleFollowToggle() @router.post("/api/users/follow") -async def toggle_follow(follow: FollowToggle, current_user: str = Depends(require_auth)): +@shared_limit(30) +async def toggle_follow(request: Request, follow: FollowToggle, current_user: str = Depends(require_auth)): """ 关注/取消关注接口 @@ -45,50 +50,14 @@ async def toggle_follow(follow: FollowToggle, current_user: str = Depends(requir - target_account: 被关注/取关的目标用户账号 - is_active: True=关注, False=取消关注 """ - # 🔒 安全防护:强制使用当前认证用户,防止伪造身份 - follow.user_id = current_user - - # 加载用户数据库 - users_db = db.load_data("users.json", default_data={}) - - # ========== 用户存在性校验 ========== - if follow.target_account not in users_db or follow.user_id not in users_db: - raise HTTPException(status_code=404, detail="用户不存在") - - # 获取双方的关注/粉丝列表 - # setdefault 确保字段存在,不存在则初始化为空列表 - target_followers = users_db[follow.target_account].setdefault("followers", []) - current_following = users_db[follow.user_id].setdefault("following", []) - - # ========== 执行关注或取关操作 ========== - if follow.is_active: - # === 关注操作 === - # 将当前用户添加到目标用户的粉丝列表 - if follow.user_id not in target_followers: - target_followers.append(follow.user_id) - - # 发送关注通知给目标用户 - # 关联:notifications.py 的 add_notification() - add_notification(follow.target_account, { - "type": "follow", - "from_user": follow.user_id - }) - - # 将目标用户添加到当前用户的关注列表 - if follow.target_account not in current_following: - current_following.append(follow.target_account) - else: - # === 取消关注操作 === - # 从目标用户的粉丝列表中移除当前用户 - if follow.user_id in target_followers: - target_followers.remove(follow.user_id) - - # 从当前用户的关注列表中移除目标用户 - if follow.target_account in current_following: - current_following.remove(follow.target_account) - - # 保存更新后的数据 - db.save_data("users.json", users_db) + # 使用 JWT 身份,不修改请求对象或采信 user_id。 + added = set_follow(current_user, follow.target_account, follow.is_active) + # 只有用户记录发布成功、确实新添关系时才通知;通知失败不重放关注。 + if added: + try: + add_notification(follow.target_account, {"type": "follow", "from_user": current_user}) + except Exception as error: + logger.warning("关注已提交,通知暂未发送: %s", type(error).__name__) return {"status": "success"} @@ -107,7 +76,8 @@ async def toggle_follow(follow: FollowToggle, current_user: str = Depends(requir # 前端调用: # - 个人设置表单组件.js 的隐私设置区域 @router.put("/api/users/{account}/privacy") -async def update_privacy(account: str, privacy: PrivacySettings, current_user: str = Depends(require_auth)): +@shared_limit(10) +async def update_privacy(request: Request, account: str, privacy: PrivacySettings, current_user: str = Depends(require_auth)): """ 更新隐私设置接口 @@ -123,16 +93,6 @@ async def update_privacy(account: str, privacy: PrivacySettings, current_user: s if account != current_user: raise HTTPException(status_code=403, detail="只能修改自己的隐私设置") - # 加载用户数据库 - users_db = db.load_data("users.json", default_data={}) - - # 检查用户是否存在 - if account not in users_db: - raise HTTPException(status_code=404, detail="用户不存在") - - # 更新用户的隐私设置 - users_db[account]["privacy"] = privacy.dict() - - # 保存更新后的数据 - db.save_data("users.json", users_db) + dump = getattr(privacy, "model_dump", None) or privacy.dict + update_user_privacy(account, dump()) return {"status": "success"} diff --git a/router_wallet.py b/router_wallet.py index 8ab597f6473eb4f9e5743922dc44cfbdcf7b109e..2d9a02072dd53df8c062b3837421c928d2bbb1f5 100644 --- a/router_wallet.py +++ b/router_wallet.py @@ -11,10 +11,12 @@ # ========================================== from fastapi import APIRouter, Depends, HTTPException, Request +from rate_limit import shared_limit from fastapi.responses import Response from sqlalchemy.orm import Session from pydantic import BaseModel, Field import time +import asyncio import uuid import hashlib import os @@ -25,12 +27,13 @@ from models_sql import Wallet, Transaction, Ownership, Refund, RechargeOrder from models import RechargeRequest, WithdrawRequest, PurchaseRequest, TipRequest import 数据库连接 as json_db from notifications import add_notification +from recharge_transaction import payment_amount, read_order, persist_order, credit_order +from tip_transaction import lock_tip_wallets, lock_wallet_accounts +from purchase_transaction import effective_resource_price, lock_resource_purchase_wallets +from tip_store import run_tip_effect, update_tip_projection from 安全认证 import require_auth, is_admin # 🔒 P0安全优化:API限流 -from slowapi import Limiter -from slowapi.util import get_remote_address -limiter = Limiter(key_func=get_remote_address) # 🔄 P7后悔模式:24小时退款窗口 REFUND_WINDOW_HOURS = 24 @@ -44,56 +47,10 @@ SYSTEM_FEE_ACCOUNT = "system_fee_account" logger = logging.getLogger("ComfyUI-Ranking.Wallet") # 🔐 导入验证码缓存 (提现时需要验证) -from verify_code_engine import VERIFY_CODES +from rate_limit import consume_verification_code router = APIRouter() -# 🔒 P0安全修复:充值订单预存映射,回调时通过订单号查找真实账号,不依赖 subject 字段 -# 🔒 P0数据修复:内存字典仅作热缓存,订单同时落库 RechargeOrder 表,实例重启不丢失 -PENDING_RECHARGE_ORDERS = {} # {order_id: {"account": str, "amount": int, "created_at": int}} - -def _save_pending_order(order_id, account, amount, db): - """预存充值订单:内存缓存 + SQL 持久化双写""" - PENDING_RECHARGE_ORDERS[order_id] = { - "account": account, - "amount": amount, - "created_at": int(time.time()) - } - try: - db.add(RechargeOrder(order_id=order_id, account=account, amount=amount, status="pending")) - db.commit() - except Exception as e: - db.rollback() - logger.warning(f"RECHARGE_ORDER_PERSIST_FAILED | order={order_id} | error={str(e)}") - -def _get_pending_order(order_id, db): - """查找充值订单:优先内存缓存,未命中回查 SQL(重启容灾)""" - info = PENDING_RECHARGE_ORDERS.get(order_id) - if info: - return info - try: - row = db.query(RechargeOrder).filter(RechargeOrder.order_id == order_id).first() - if row: - return { - "account": row.account, - "amount": row.amount, - "created_at": int(row.created_at.timestamp()) if row.created_at else 0 - } - except Exception as e: - logger.warning(f"RECHARGE_ORDER_QUERY_FAILED | order={order_id} | error={str(e)}") - return None - -def _mark_order_paid(order_id, db): - """订单入账后:清理内存缓存并在 SQL 中标记已支付""" - PENDING_RECHARGE_ORDERS.pop(order_id, None) - try: - row = db.query(RechargeOrder).filter(RechargeOrder.order_id == order_id).first() - if row: - row.status = "paid" - db.commit() - except Exception: - db.rollback() - # ========================================== # 脱敏工具函数 # ========================================== @@ -162,179 +119,96 @@ try: ) except Exception as e: alipay = None - alipay_error_msg = f"支付宝 SDK 崩溃: {str(e)}" - print(f"🚨 支付宝初始化异常: {alipay_error_msg}") + alipay_error_msg = f"支付宝 SDK 初始化失败({type(e).__name__})" + print(f"🚨 支付宝初始化异常: error_type={type(e).__name__}") def calculate_tx_hash(tx_id, account, tx_type, amount, prev_hash): data = f"{tx_id}{account}{tx_type}{amount}{prev_hash}" return hashlib.sha256(data.encode()).hexdigest() @router.post("/api/wallet/create_recharge_order") -async def create_recharge_order(req: RechargeRequest, current_user: str = Depends(require_auth), db: Session = Depends(get_db)): +@shared_limit(10) +async def create_recharge_order(request: Request, req: RechargeRequest, current_user: str = Depends(require_auth), db: Session = Depends(get_db)): if not alipay: - # 这里会将真实的错误原因直接弹窗发给前端! raise HTTPException(status_code=500, detail="支付网关配置错误,请联系管理员") - - # 🔒 使用已认证用户账号,忽略客户端传入的 account - authenticated_account = current_user + if type(req.amount) is not int or not 1 <= req.amount <= 10000: + raise HTTPException(status_code=422, detail="充值金额无效") order_id = f"PAY_{uuid.uuid4().hex}" - subject = f"ComfyUI Community Points - {authenticated_account}" - - # 🔒 P0安全修复:预存订单号->账号映射,供回调时安全查找(内存+SQL双写) - _save_pending_order(order_id, authenticated_account, req.amount, db) - - order_string = alipay.api_alipay_trade_precreate( - out_trade_no=order_id, - total_amount=str(req.amount), - subject=subject - ) - - qr_code_url = order_string.get("qr_code") - if not qr_code_url: - raise HTTPException(status_code=500, detail="生成支付二维码失败") - - return {"status": "success", "order_id": order_id, "qr_code": qr_code_url} + try: + # 先持久化;失败时不能生成可付款订单。 + persist_order(db, order_id, current_user, req.amount) + result = await asyncio.to_thread(alipay.api_alipay_trade_precreate, + out_trade_no=order_id, total_amount=str(req.amount), + subject=f"ComfyUI Community Points - {current_user}") + if not isinstance(result, dict) or result.get("code") != "10000" or not isinstance(result.get("qr_code"), str) or not result["qr_code"]: + raise ValueError("invalid payment response") + return {"status": "success", "order_id": order_id, "qr_code": result["qr_code"]} + except Exception as error: + run_tip_effect(lambda: logger.error(f"RECHARGE_CREATE_ERROR | error_type={type(error).__name__}")) + raise HTTPException(status_code=503, detail="创建支付订单失败,请稍后查询确认") + -# 🟢 业务流转细节修复:正确解析 application/x-www-form-urlencoded @router.post("/api/wallet/alipay_notify") async def alipay_notify(request: Request, db: Session = Depends(get_db)): - # 强制将表单数据解析为纯字典,防止由于数据类型错误导致验签失败 - form_data = await request.form() - data = dict(form_data.items()) - - signature = data.pop("sign", None) - data.pop("sign_type", None) - - if not alipay or not signature or not alipay.verify(data, signature): - return Response(content="fail", media_type="text/plain") - - if data.get("trade_status") in ("TRADE_SUCCESS", "TRADE_FINISHED"): + try: + form = await request.form() + pairs = list(form.multi_items()) + if len({key for key, _ in pairs}) != len(pairs): + raise ValueError("duplicate payment fields") + data = dict(pairs) + signature = data.pop("sign", None) + sign_type = data.pop("sign_type", None) + if not alipay or not isinstance(signature, str) or not signature or sign_type != "RSA2": + raise ValueError("invalid signature fields") + if not await asyncio.to_thread(alipay.verify, data, signature): + raise ValueError("invalid signature") + app_id = os.environ.get("ALIPAY_APPID", "").strip() + seller_id = os.environ.get("ALIPAY_SELLER_ID", "").strip() + if not app_id or data.get("app_id") != app_id or (seller_id and data.get("seller_id") != seller_id): + raise ValueError("payment merchant mismatch") order_id = data.get("out_trade_no") - - # 🔒 第一层防御:快速预检查(优化,避免不必要的锁获取) - existing_tx = db.query(Transaction).filter(Transaction.tx_id == order_id).first() - if existing_tx: - return Response(content="success", media_type="text/plain") - - amount = int(float(data.get("total_amount", 0))) - - # 🔒 P0安全修复:通过预存订单映射查找真实账号,不依赖 subject 字段提取(内存未命中时回查SQL) - order_info = _get_pending_order(order_id, db) - if not order_info: - logger.warning(f"RECHARGE_ORDER_NOT_FOUND | order={order_id} | subject={data.get('subject', '')}") - return Response(content="fail", media_type="text/plain") - account = order_info["account"] - - # 🔒 纵深防御:验证 subject 中的账号与预存映射一致 - subject_account = data.get("subject", "").split(" - ")[-1] if " - " in data.get("subject", "") else "" - if subject_account and subject_account != account: - logger.warning(f"RECHARGE_ACCOUNT_MISMATCH | order={order_id} | expected={account} | subject={subject_account}") - return Response(content="fail", media_type="text/plain") - - # 清理已处理的预存订单(内存移除 + SQL 标记 paid) - _mark_order_paid(order_id, db) - - # 🔒 P0安全修复:充值金额有效性校验 - if amount <= 0: - logger.warning(f"RECHARGE_AMOUNT_INVALID | order={order_id} | amount={amount}") - return Response(content="fail", media_type="text/plain") - - # 🔒 P1并发安全:使用悲观锁+异常处理+事务回滚防止并发充值问题 - # 🔒 P1 TOCTOU修复:先获取行锁,再在锁内检查重复,确保检查和操作的原子性 - try: - wallet = db.query(Wallet).filter(Wallet.account == account).with_for_update().first() - - # 🔒 第二层防御:在持有行锁后再检查是否已处理过此订单(原子性防重复充值) - existing_tx = db.query(Transaction).filter(Transaction.tx_id == order_id).first() - if existing_tx: - db.commit() # 释放锁 - return Response(content="success", media_type="text/plain") # 幂等返回成功 - - if not wallet: - wallet = Wallet(account=account, balance=0, earn_balance=0, tip_balance=0, frozen_balance=0) - db.add(wallet) - - wallet.balance = int(wallet.balance or 0) + int(amount) - - last_tx = db.query(Transaction).filter(Transaction.account == account).order_by(Transaction.created_at.desc()).first() - prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH" - tx_hash = calculate_tx_hash(order_id, account, "RECHARGE", amount, prev_hash) - - new_tx = Transaction( - tx_id=order_id, account=account, tx_type="RECHARGE", amount=amount, - prev_hash=prev_hash, tx_hash=tx_hash, - description="支付宝充值" - ) - db.add(new_tx) - db.commit() - - # 📝 P2优化:充值审计日志 - logger.info(f"RECHARGE | account={account} | amount={amount} | order={order_id}") - except Exception as e: - db.rollback() - logger.error(f"RECHARGE_ERROR | account={account} | amount={amount} | order={order_id} | error={str(e)}") - return Response(content="fail", media_type="text/plain") - - return Response(content="success", media_type="text/plain") + info = read_order(db, order_id) + if info is None or payment_amount(data.get("total_amount")) != info["amount"]: + raise ValueError("payment order mismatch") + if data.get("trade_status") in ("TRADE_SUCCESS", "TRADE_FINISHED"): + credit_order(db, order_id, info["amount"]) + run_tip_effect(lambda: logger.info("RECHARGE_COMPLETED")) + elif data.get("trade_status") not in {"WAIT_BUYER_PAY", "TRADE_CLOSED"}: + raise ValueError("invalid trade state") + return Response(content="success", media_type="text/plain") + except Exception as error: + db.rollback() + run_tip_effect(lambda: logger.error(f"RECHARGE_ERROR | error_type={type(error).__name__}")) + return Response(content="fail", media_type="text/plain") + @router.get("/api/wallet/check_order/{order_id}") async def check_order(order_id: str, current_user: str = Depends(require_auth), db: Session = Depends(get_db)): - # 🔒 P0安全修复:使用认证用户账号,不再接受客户端传入的 account 参数 - account = current_user - - # 🔒 纵深防御:优先从预存订单映射查找账号(内存未命中时回查SQL) - order_info = _get_pending_order(order_id, db) - if order_info: - account = order_info["account"] - # 验证认证用户与订单所属用户一致 - if current_user != account: - logger.warning(f"CHECK_ORDER_ACCOUNT_MISMATCH | order={order_id} | auth={current_user} | order_account={account}") + try: + info = read_order(db, order_id) + if info is None or info["account"] != current_user: return {"status": "PENDING"} - # 防线 1:先查本地数据库(如果 Webhook 成功了,这里就能查到) - tx = db.query(Transaction).filter(Transaction.tx_id == order_id).first() - if tx: - return {"status": "SUCCESS"} - - # 防线 2:主动向支付宝发起查单(解决 Hugging Face 收不到回调的绝杀技) - if alipay: - try: - # 拿着订单号去问支付宝:这笔钱到底付了没? - result = alipay.api_alipay_trade_query(out_trade_no=order_id) - if result.get("code") == "10000" and result.get("trade_status") in ("TRADE_SUCCESS", "TRADE_FINISHED"): - - # 🔒 防重检查:支付宝查单成功后,再次检查是否已被回调处理过 - existing_check = db.query(Transaction).filter(Transaction.tx_id == order_id).first() - if existing_check: - return {"status": "SUCCESS"} # 已被回调入账,不重复处理 - - # 钱真的到了!立刻加锁入账 - amount = int(float(result.get("total_amount", 0))) - - wallet = db.query(Wallet).filter(Wallet.account == account).with_for_update().first() - if not wallet: - wallet = Wallet(account=account, balance=0, earn_balance=0, tip_balance=0, frozen_balance=0) - db.add(wallet) - - wallet.balance = int(wallet.balance or 0) + int(amount) - - last_tx = db.query(Transaction).filter(Transaction.account == account).order_by(Transaction.created_at.desc()).first() - prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH" - tx_hash = calculate_tx_hash(order_id, account, "RECHARGE", amount, prev_hash) - - new_tx = Transaction( - tx_id=order_id, account=account, tx_type="RECHARGE", amount=amount, - prev_hash=prev_hash, tx_hash=tx_hash, - description="支付宝充值" - ) - db.add(new_tx) - db.commit() - - return {"status": "SUCCESS"} - except Exception as e: - db.rollback() - print(f"主动查单发生异常: {e}") - - # 如果没查到支付成功状态,继续让前端等 + if info["credited"]: + return {"status": "SUCCESS"} + if not alipay: + return {"status": "PENDING"} + result = await asyncio.to_thread(alipay.api_alipay_trade_query, out_trade_no=order_id) + if not isinstance(result, dict) or result.get("code") != "10000": + return {"status": "PENDING"} + if result.get("out_trade_no") != order_id: + raise ValueError("payment order mismatch") + seller_id = os.environ.get("ALIPAY_SELLER_ID", "").strip() + # 查单响应不保证包含 seller_id;由配置的 SDK 应用身份发起并验签。 + if seller_id and "seller_id" in result and result["seller_id"] != seller_id: + raise ValueError("payment merchant mismatch") + if result.get("trade_status") in ("TRADE_SUCCESS", "TRADE_FINISHED"): + amount = payment_amount(result.get("total_amount")) + credit_order(db, order_id, amount) + run_tip_effect(lambda: logger.info("RECHARGE_COMPLETED")) + return {"status": "SUCCESS"} + except Exception as error: + db.rollback() + run_tip_effect(lambda: logger.error(f"RECHARGE_QUERY_ERROR | error_type={type(error).__name__}")) return {"status": "PENDING"} @router.get("/api/wallet/{account}") @@ -366,186 +240,108 @@ async def get_wallet(account: str, current_user: str = Depends(require_auth), db } @router.post("/api/wallet/purchase") -@limiter.limit("10/minute") # 🔒 P0安全优化:购买每分钟最多10次 +@shared_limit(10) # 🔒 P0安全优化:购买每分钟最多10次 async def purchase_item(request: Request, req: PurchaseRequest, current_user: str = Depends(require_auth), db: Session = Depends(get_db)): - # 🔒 使用已认证用户账号,忽略客户端传入的 account req.account = current_user - items_db = json_db.load_data("items.json", default_data=[]) - item = next((i for i in items_db if i["id"] == req.item_id), None) - - if not item: - raise HTTPException(status_code=404, detail="商品不存在") - - # 🔄 P7后悔模式:检查价格是否延迟生效 - actual_price = item.get("price", 0) - pending_price = item.get("pending_price") - pending_price_effective = item.get("pending_price_effective_at") - if pending_price is not None and pending_price_effective: - # 检查是否已过生效时间 - effective_time = datetime.datetime.fromisoformat(pending_price_effective) - if datetime.datetime.now() >= effective_time: - actual_price = pending_price - # 更新实际价格,清除待生效价格 - item["price"] = pending_price - item["pending_price"] = None - item["pending_price_effective_at"] = None - json_db.save_data("items.json", items_db) - - price = int(actual_price) - seller_account = item.get("author") - - if price <= 0 or req.account == seller_account: - # ☁️ 免费资源或作者本人,确保 Ownership 记录存在 - existing_ownership = db.query(Ownership).filter( - Ownership.account == req.account, - Ownership.item_id == req.item_id, - Ownership.is_refunded == False - ).first() - if not existing_ownership: - new_ownership = Ownership( - account=req.account, - item_id=req.item_id, - price_paid=0 - ) - db.add(new_ownership) - db.commit() - return { - "status": "success", - "already_owned": True, - "netdisk_password": item.get("netdisk_password"), # ☁️ - "is_netdisk": item.get("is_netdisk", False) # ☁️ - } - - # 🔄 P7后悔模式:检查30天禁购 - refund_ban = db.query(Refund).filter( - Refund.account == req.account, - Refund.item_id == req.item_id, - Refund.ban_until > datetime.datetime.utcnow() - ).first() - if refund_ban: - days_left = (refund_ban.ban_until - datetime.datetime.utcnow()).days + 1 - raise HTTPException(status_code=403, detail=f"您已退款过此商品,{days_left}天内禁止再次购买") - - # 检查是否已拥有(排除已退款的记录) - owned = db.query(Ownership).filter( - Ownership.account == req.account, - Ownership.item_id == req.item_id, - Ownership.is_refunded == False - ).first() - if owned: - # ☁️ 已购买用户,返回网盘密码 - return { - "status": "success", - "already_owned": True, - "netdisk_password": item.get("netdisk_password"), # ☁️ - "is_netdisk": item.get("is_netdisk", False) # ☁️ - } - - # 🔒 P1并发安全:使用悲观锁+异常处理+事务回滚防止双花问题 try: - buyer_wallet = db.query(Wallet).filter(Wallet.account == req.account).with_for_update().first() + items = json_db.load_data("items.json", default_data=[], skip_cache=True, strict=True) + if not isinstance(items, list): + raise ValueError("invalid resource store") + item = next((value for value in items if isinstance(value, dict) and value.get("id") == req.item_id), None) + if item is None: + raise HTTPException(status_code=404, detail="商品不存在") + price = effective_resource_price(item) + seller_account = item.get("author") + if not isinstance(seller_account, str) or not seller_account.strip(): + raise ValueError("invalid resource author") + password = item.get("netdisk_password") + is_netdisk = item.get("is_netdisk", False) + if not isinstance(is_netdisk, bool) or (password is not None and not isinstance(password, str)): + raise ValueError("invalid resource delivery") + free = price == 0 or current_user == seller_account + response = {"status": "success", "already_owned": True, + "netdisk_password": password, "is_netdisk": is_netdisk} + except HTTPException: + raise + except Exception: + logger.warning("PURCHASE_RESOURCE_UNAVAILABLE") + raise HTTPException(status_code=503, detail="资源信息暂不可确认,请稍后重试") - # 🔒 锁内二次检查:串行化后重查 Ownership,彻底消除并发重复购买竞态 - owned_locked = db.query(Ownership).filter( - Ownership.account == req.account, - Ownership.item_id == req.item_id, - Ownership.is_refunded == False - ).first() - if owned_locked: + # 用户姓名仅用于流水显示,不能让它决定资金权限或价格。 + users = json_db.load_data("users.json", default_data={}) + if not isinstance(users, dict): + users = {} + seller_info, buyer_info = users.get(seller_account, {}), users.get(current_user, {}) + seller_name = seller_info.get("name", seller_account) if isinstance(seller_info, dict) else seller_account + buyer_name = buyer_info.get("name", current_user) if isinstance(buyer_info, dict) else current_user + try: + # 必须在本 Session 的任何 SQL 读取之前取得 SQLite 写锁/排序的钱包行锁。 + buyer_wallet, seller_wallet = lock_resource_purchase_wallets(db, current_user, seller_account, free) + if not free: + ban = db.query(Refund).filter(Refund.account == current_user, Refund.item_id == req.item_id, + Refund.ban_until > datetime.datetime.utcnow()).with_for_update().first() + if ban: + raise HTTPException(status_code=403, detail="退款禁购期内无法再次购买") + owned = db.query(Ownership).filter(Ownership.account == current_user, + Ownership.item_id == req.item_id, Ownership.is_refunded == False).with_for_update().first() + if owned: db.rollback() - return { - "status": "success", - "already_owned": True, - "netdisk_password": item.get("netdisk_password"), # ☁️ - "is_netdisk": item.get("is_netdisk", False) # ☁️ - } + return response + if free: + db.add(Ownership(account=current_user, item_id=req.item_id, price_paid=0)) + db.commit() + return response - if not buyer_wallet or buyer_wallet.balance < price: + expected = getattr(req, "expected_price", None) + if expected is None: + raise HTTPException(status_code=428, detail="purchase_price_confirmation_required") + if isinstance(expected, bool) or not isinstance(expected, int) or expected < 0: + raise HTTPException(status_code=422, detail="purchase_price_invalid") + if expected != price: + raise HTTPException(status_code=409, detail="purchase_price_changed") + if buyer_wallet is None or buyer_wallet.balance < price: raise HTTPException(status_code=402, detail="余额不足,请先充值") - - seller_wallet = db.query(Wallet).filter(Wallet.account == seller_account).with_for_update().first() - if not seller_wallet: - seller_wallet = Wallet(account=seller_account, balance=0, earn_balance=0, tip_balance=0, frozen_balance=0) - db.add(seller_wallet) - - buyer_wallet.balance = int(buyer_wallet.balance or 0) - int(price) - seller_wallet.balance = int(seller_wallet.balance or 0) + int(price) # 实际收入进统一余额 - seller_wallet.earn_balance = int(seller_wallet.earn_balance or 0) + int(price) # 累计销售收益统计(只增不减) - - # 🔄 P7后悔模式:记录购买价格 - new_ownership = Ownership(account=req.account, item_id=req.item_id, price_paid=price) - db.add(new_ownership) - - tx_id = f"BUY_{int(time.time())}_{uuid.uuid4().hex[:6]}" - last_tx = db.query(Transaction).filter(Transaction.account == req.account).order_by(Transaction.created_at.desc()).first() - prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH" - tx_hash = calculate_tx_hash(tx_id, req.account, "PURCHASE", -price, prev_hash) - - # 获取卖家用户名 - users_db = json_db.load_data("users.json", default_data={}) - seller_info = users_db.get(seller_account, {}) - seller_user_name = seller_info.get("name", seller_account) - - # 创建交易记录 (字段名为 related_account,与 models_sql.py 中 Transaction 模型保持一致) - new_tx = Transaction( - tx_id=tx_id, account=req.account, tx_type="PURCHASE", amount=-price, - related_account=seller_account, item_id=req.item_id, prev_hash=prev_hash, tx_hash=tx_hash, - description=f"购买 {item.get('title', '未知资源')}", - item_title=item.get('title', ''), - item_type=item.get('type', ''), - related_user_name=seller_user_name - ) - db.add(new_tx) - - # 为卖家生成销售收入记录 - buyer_info = users_db.get(req.account, {}) - buyer_user_name = buyer_info.get("name", req.account) - seller_tx_id = f"SALE_{int(time.time())}_{uuid.uuid4().hex[:6]}" - seller_tx = Transaction( - tx_id=seller_tx_id, - account=seller_account, - tx_type="SALE", - amount=price, - related_account=req.account, - item_id=req.item_id, - item_title=item.get('title', ''), - item_type=item.get('type', ''), - related_user_name=buyer_user_name, - description=f"销售收入: {item.get('title', req.item_id)}" - ) - db.add(seller_tx) + if any(isinstance(value, bool) or not isinstance(value, int) or value < 0 + for value in (buyer_wallet.balance, seller_wallet.balance, seller_wallet.earn_balance)): + raise ValueError("invalid wallet balance") + buyer_wallet.balance -= price + seller_wallet.balance += price + seller_wallet.earn_balance += price + db.add(Ownership(account=current_user, item_id=req.item_id, price_paid=price)) + for account, kind, amount, other, other_name in ( + (current_user, "PURCHASE", -price, seller_account, seller_name), + (seller_account, "SALE", price, current_user, buyer_name) + ): + tx_id = ("BUY_" if kind == "PURCHASE" else "SALE_") + uuid.uuid4().hex + last = db.query(Transaction).filter(Transaction.account == account).order_by( + Transaction.created_at.desc(), Transaction.tx_id.desc()).with_for_update().first() + previous = last.tx_hash if last else "GENESIS_HASH" + db.add(Transaction(tx_id=tx_id, account=account, tx_type=kind, amount=amount, + related_account=other, item_id=req.item_id, prev_hash=previous, + tx_hash=calculate_tx_hash(tx_id, account, kind, amount, previous), + description=("购买 " if kind == "PURCHASE" else "销售收入: ") + str(item.get("title", req.item_id)), + item_title=item.get("title", ""), item_type=item.get("type", ""), related_user_name=other_name)) + response["already_owned"] = False db.commit() - - # 📝 P2优化:购买审计日志 - logger.info(f"PURCHASE | buyer={req.account} | seller={seller_account} | item={req.item_id} | amount={price} | tx={tx_id}") - - # 🔔 通知卖家作品被购买 - if seller_account and seller_account != req.account: - add_notification(seller_account, { - "type": "purchase", - "from_user": req.account, - "target_item_id": req.item_id, - "target_item_title": item.get("title", ""), - "content": f"您的作品《{item.get('title', '')}》已被购买" - }) - - # ☁️ 购买成功后返回网盘密码 - return { - "status": "success", - "already_owned": False, - "netdisk_password": item.get("netdisk_password"), # ☁️ 只有购买成功才返回 - "is_netdisk": item.get("is_netdisk", False) # ☁️ - } except HTTPException: db.rollback() raise - except Exception as e: + except Exception as error: db.rollback() - logger.error(f"PURCHASE_ERROR | buyer={req.account} | item={req.item_id} | error={str(e)}") + logger.error("PURCHASE_ERROR | error_type=%s", type(error).__name__) raise HTTPException(status_code=500, detail="购买处理失败,请稍后重试") + # 已提交的交易不能因通知故障变成购买失败;此处不重放扣款。 + run_tip_effect(lambda: logger.info("PURCHASE_COMPLETED")) + run_tip_effect(lambda: add_notification(seller_account, { + "type": "purchase", "from_user": current_user, "target_item_id": req.item_id, + "target_item_title": item.get("title", ""), + "content": f"您的作品《{item.get('title', '')}》已被购买" + })) + return response + @router.post("/api/wallet/tip") -@limiter.limit("20/minute") # 🔒 P0安全优化:打赏每分钟最多20次 +@shared_limit(20) # 🔒 P0安全优化:打赏每分钟最多20次 async def tip_user(request: Request, req: TipRequest, current_user: str = Depends(require_auth), db: Session = Depends(get_db)): # 🔒 使用已认证用户账号,忽略客户端传入的 sender_account req.sender_account = current_user @@ -555,28 +351,25 @@ async def tip_user(request: Request, req: TipRequest, current_user: str = Depend if req.sender_account == req.target_account: raise HTTPException(status_code=400, detail="不能打赏给自己") - # 🔒 P1幂等性防护:检查最近30秒内是否存在相同交易 - recent_cutoff = datetime.datetime.utcnow() - datetime.timedelta(seconds=30) - duplicate_tx = db.query(Transaction).filter( - Transaction.account == req.sender_account, - Transaction.tx_type == "TIP_OUT", - Transaction.amount == -req.amount, - Transaction.related_account == req.target_account, - Transaction.created_at >= recent_cutoff - ).first() - if duplicate_tx: - return {"status": "success", "message": "打赏已处理(重复请求)"} - # 🔒 P1并发安全:使用悲观锁+异常处理+事务回滚防止双花问题 try: - sender_wallet = db.query(Wallet).filter(Wallet.account == req.sender_account).with_for_update().first() - target_wallet = db.query(Wallet).filter(Wallet.account == req.target_account).with_for_update().first() - + sender_wallet, target_wallet = lock_tip_wallets(db, req.sender_account, req.target_account) + # 🔒 P1幂等性防护:检查最近30秒内是否存在相同交易 + recent_cutoff = datetime.datetime.utcnow() - datetime.timedelta(seconds=30) + duplicate_tx = db.query(Transaction).filter( + Transaction.account == req.sender_account, + Transaction.tx_type == "TIP_OUT", + Transaction.amount == -req.amount, + Transaction.related_account == req.target_account, + Transaction.created_at >= recent_cutoff + ).with_for_update().first() + if duplicate_tx: + db.rollback() # 重复请求释放锁,不执行任何后续操作 + return {"status": "success", "message": "打赏已处理(重复请求)"} + if not sender_wallet or sender_wallet.balance < req.amount: raise HTTPException(status_code=400, detail="余额不足") - if not target_wallet: - target_wallet = Wallet(account=req.target_account, balance=0, earn_balance=0, tip_balance=0, frozen_balance=0) - db.add(target_wallet) + sender_wallet.balance = int(sender_wallet.balance or 0) - int(req.amount) target_wallet.balance = int(target_wallet.balance or 0) + int(req.amount) # 实际收入进统一余额 @@ -611,7 +404,7 @@ async def tip_user(request: Request, req: TipRequest, current_user: str = Depend # 发送方交易记录 (字段名为 related_account,与 models_sql.py 中 Transaction 模型保持一致) tx_sender = Transaction( tx_id=tx_id_sender, account=req.sender_account, tx_type="TIP_OUT", amount=-req.amount, - related_account=req.target_account, prev_hash=prev_hash_sender, + item_id=req.item_id, related_account=req.target_account, prev_hash=prev_hash_sender, tx_hash=calculate_tx_hash(tx_id_sender, req.sender_account, "TIP_OUT", -req.amount, prev_hash_sender), description=f"打赏给 {target_user_name}" + (f" 的 {item_title}" if item_title else ""), item_title=item_title, @@ -622,7 +415,7 @@ async def tip_user(request: Request, req: TipRequest, current_user: str = Depend # 接收方交易记录 tx_target = Transaction( tx_id=tx_id_target, account=req.target_account, tx_type="TIP_IN", amount=req.amount, - related_account=req.sender_account, prev_hash=prev_hash_target, + item_id=req.item_id, related_account=req.sender_account, prev_hash=prev_hash_target, tx_hash=calculate_tx_hash(tx_id_target, req.target_account, "TIP_IN", req.amount, prev_hash_target), description=f"收到 {sender_user_name} 的打赏" + (f" ({item_title})" if item_title else ""), item_title=item_title, @@ -632,11 +425,19 @@ async def tip_user(request: Request, req: TipRequest, current_user: str = Depend db.add(tx_sender) db.add(tx_target) + committed_balance = int(sender_wallet.balance) db.commit() - # 📝 P2优化:打赏审计日志 - logger.info(f"TIP | from={req.sender_account} | to={req.target_account} | amount={req.amount} | item={req.item_id or 'N/A'} | anon={req.is_anonymous}") - + except HTTPException: + db.rollback() + raise + except Exception as e: + db.rollback() + logger.error(f"TIP_ERROR | error_type={type(e).__name__}") + raise HTTPException(status_code=500, detail="打赏处理失败,请稍后重试") + + logger.info("TIP_COMPLETED") + def notify_tip(): # 🔔 打赏通知(考虑匿名) if not req.is_anonymous: sender_display = sender_user_name if sender_user_name else req.sender_account @@ -655,195 +456,111 @@ async def tip_user(request: Request, req: TipRequest, current_user: str = Depend "target_item_title": "", "content": f"您收到了一份 {req.amount} 积分的匿名打赏" }) - - # 🚀 核心新增:记录打赏榜单和月度收益趋势 (写入 JSON 以供高频读取) - users_db = json_db.load_data("users.json", default_data={}) - items_db = json_db.load_data("items.json", default_data=[]) - current_month = datetime.date.today().strftime("%Y-%m") - - # 1. 更新创作者的总打赏榜与收益趋势 - if req.target_account in users_db: - u = users_db[req.target_account] - if "tip_history" not in u: u["tip_history"] = {} - u["tip_history"][current_month] = u["tip_history"].get(current_month, 0) + req.amount - - if "tip_board" not in u: u["tip_board"] = [] - if req.is_anonymous: - sender_entry = next((x for x in u["tip_board"] if x.get("account") is None and x.get("is_anon")), None) - else: - sender_entry = next((x for x in u["tip_board"] if x.get("account") == req.sender_account), None) - if sender_entry: - sender_entry["amount"] += req.amount - else: - tip_entry = {"amount": req.amount, "is_anon": req.is_anonymous} - if not req.is_anonymous: - tip_entry["account"] = req.sender_account - else: - tip_entry["account"] = None - u["tip_board"].append(tip_entry) - u["tip_board"] = sorted(u["tip_board"], key=lambda x: x["amount"], reverse=True) - json_db.save_data("users.json", users_db) - - # 2. 如果关联了具体作品,更新作品详情的专属打赏榜与收益趋势 - if req.item_id: - for item in items_db: - if item["id"] == req.item_id: - if "tip_history" not in item: item["tip_history"] = {} - item["tip_history"][current_month] = item["tip_history"].get(current_month, 0) + req.amount - - if "tip_board" not in item: item["tip_board"] = [] - if req.is_anonymous: - sender_entry = next((x for x in item["tip_board"] if x.get("account") is None and x.get("is_anon")), None) - else: - sender_entry = next((x for x in item["tip_board"] if x.get("account") == req.sender_account), None) - if sender_entry: - sender_entry["amount"] += req.amount - else: - tip_entry = {"amount": req.amount, "is_anon": req.is_anonymous} - if not req.is_anonymous: - tip_entry["account"] = req.sender_account - else: - tip_entry["account"] = None - item["tip_board"].append(tip_entry) - item["tip_board"] = sorted(item["tip_board"], key=lambda x: x["amount"], reverse=True) - json_db.save_data("items.json", items_db) - break - - return {"status": "success", "balance": sender_wallet.balance} - except HTTPException: - db.rollback() - raise - except Exception as e: - db.rollback() - logger.error(f"TIP_ERROR | from={req.sender_account} | to={req.target_account} | amount={req.amount} | error={str(e)}") - raise HTTPException(status_code=500, detail="打赏处理失败,请稍后重试") + + run_tip_effect(notify_tip) + current_month = datetime.date.today().strftime("%Y-%m") + run_tip_effect(lambda: update_tip_projection("users.json", req.target_account, req.sender_account, + req.amount, req.is_anonymous, current_month)) + if req.item_id: + run_tip_effect(lambda: update_tip_projection("items.json", req.item_id, req.sender_account, + req.amount, req.is_anonymous, current_month)) + return {"status": "success", "balance": committed_balance} @router.post("/api/wallet/withdraw") -@limiter.limit("3/minute") # 🔒 P0安全优化:提现每分钟最多3次 +@shared_limit(3) # 🔒 P0安全优化:提现每分钟最多3次 async def withdraw(request: Request, req: WithdrawRequest, current_user: str = Depends(require_auth), db: Session = Depends(get_db)): - # 🔒 使用已认证用户账号,忽略客户端传入的 account req.account = current_user - # 🔒 P1幂等性防护:检查最近10秒内是否存在相同提现请求 - recent_cutoff = datetime.datetime.utcnow() - datetime.timedelta(seconds=10) - duplicate_tx = db.query(Transaction).filter( - Transaction.account == req.account, - Transaction.tx_type == "WITHDRAW", - Transaction.amount == -req.amount, - Transaction.created_at >= recent_cutoff - ).first() - if duplicate_tx: - return {"status": "success", "message": "提现申请已提交(重复请求)"} - - # 🔒 最小金额检查 - if req.amount < 1: - raise HTTPException(status_code=400, detail="最小提现金额为1积分") - - # 🔒 验证码缓存键格式:{contact}:{action_type},与 send_code 接口一致 - # 先通过账号查询用户邮箱或手机号,再用其构建缓存键(不 fallback 到 account) - users_db = json_db.load_data("users.json", default_data={}) - user_info = users_db.get(req.account, {}) - user_email = user_info.get("email") or user_info.get("phone", "") - if not user_email: - raise HTTPException(status_code=400, detail="请先绑定邮箱或手机号") - key = f"{user_email}:withdraw" - code_data = VERIFY_CODES.get(key) - # 🔒 P0安全修复:统一使用 expires_at 字段,兼容旧版 expires - expire_time = code_data.get("expires_at", code_data.get("expires", 0)) if code_data else 0 - if not code_data or code_data["code"] != req.code or time.time() > expire_time: - raise HTTPException(status_code=400, detail="验证码无效或已过期") - - # 🔒 P1并发安全:使用悲观锁+异常处理+事务回滚防止并发问题 + if isinstance(req.amount, bool) or not isinstance(req.amount, int) or not 1 <= req.amount <= 5000: + raise HTTPException(status_code=422, detail="提现金额必须为1到5000的整数") + req.alipayAccount, req.real_name = req.alipayAccount.strip(), req.real_name.strip() + if not req.alipayAccount or not req.real_name: + raise HTTPException(status_code=422, detail="请填写提现账号和姓名") + if current_user == SYSTEM_FEE_ACCOUNT: + raise HTTPException(status_code=403, detail="系统手续费账户不能通过用户接口提现") + try: + users = json_db.load_data("users.json", default_data={}, strict=True, skip_cache=True) + user_info = users.get(current_user) + if not isinstance(user_info, dict): + raise ValueError("invalid user record") + contact = user_info.get("email") or user_info.get("phone", "") + if not isinstance(contact, str): + raise ValueError("invalid contact") + if not contact: + raise HTTPException(status_code=400, detail="请先绑定邮箱或手机号") + except HTTPException: + raise + except Exception as error: + logger.error("WITHDRAW_SOURCE_ERROR | error_type=%s", type(error).__name__) + raise HTTPException(status_code=503, detail="账户资料暂时无法核实") try: - wallet = db.query(Wallet).filter(Wallet.account == req.account).with_for_update().first() - if not wallet: + # 钱包锁必须先于任何查重、历史或验证码SQL;固定排序并冲突安全补系统钱包。 + wallets = lock_wallet_accounts(db, (current_user, SYSTEM_FEE_ACCOUNT), (SYSTEM_FEE_ACCOUNT,)) + wallet, sys_wallet = wallets[current_user], wallets[SYSTEM_FEE_ACCOUNT] + duplicate = db.query(Transaction).filter( + Transaction.account == current_user, Transaction.tx_type == "WITHDRAW", + Transaction.amount == -req.amount, Transaction.alipay_account == req.alipayAccount, + Transaction.real_name == req.real_name, + Transaction.created_at >= datetime.datetime.utcnow()-datetime.timedelta(seconds=10)).first() + if duplicate: + db.rollback() + return {"status": "success", "message": "提现申请已提交(重复请求)"} + if wallet is None: raise HTTPException(status_code=400, detail="钱包不存在") - - # 🚀 核心新增:阶梯手续费计算 (与前端逻辑统一) - # 查询历史累计提现总额 (WITHDRAW 类型的 amount 是负数,需要取绝对值) - # 🚀 P1性能优化:使用聚合函数代替 .all() + sum - from sqlalchemy import func as sql_func - withdrawals_sum = db.query(sql_func.coalesce(sql_func.sum(Transaction.amount), 0)).filter( - Transaction.account == req.account, - Transaction.tx_type == 'WITHDRAW' - ).scalar() or 0 - total_withdrawn = abs(withdrawals_sum) - - # 手续费规则:100积分免手续费额度,超出部分收取 10% - free_quota = max(0, 100 - total_withdrawn) # 剩余免责额度 - fee_amount = 0 - if req.amount > free_quota: - fee_amount = int((req.amount - free_quota) * 0.10) # 只对超出部分收 10% - - actual_withdraw = req.amount # 从账户扣除的金额 - net_amount = req.amount - fee_amount # 用户实际到账金额 - - # 可提现额度改为 wallet.balance(统一余额) - if actual_withdraw > wallet.balance: + if any(isinstance(value, bool) or not isinstance(value, int) for value in + (wallet.balance, wallet.frozen_balance, sys_wallet.balance)) or wallet.frozen_balance < 0: + raise ValueError("invalid wallet amount") + if req.amount > wallet.balance: raise HTTPException(status_code=400, detail="可提现余额不足") - - wallet.balance -= actual_withdraw # 直接从统一余额扣除 - wallet.frozen_balance += net_amount # 冻结的是到账金额,非手续费部分 - - # 💰 手续费转入系统账户 - if fee_amount > 0: - sys_wallet = db.query(Wallet).filter(Wallet.account == SYSTEM_FEE_ACCOUNT).with_for_update().first() - if not sys_wallet: - sys_wallet = Wallet(account=SYSTEM_FEE_ACCOUNT, balance=0, earn_balance=0, tip_balance=0, task_balance=0, frozen_balance=0) - db.add(sys_wallet) - db.flush() - sys_wallet.balance += fee_amount - - tx_id = f"WD_{int(time.time())}_{uuid.uuid4().hex[:6]}" - last_tx = db.query(Transaction).filter(Transaction.account == req.account).order_by(Transaction.created_at.desc()).first() + if not consume_verification_code(f"{contact}:withdraw", req.code, session=db): + raise HTTPException(status_code=400, detail="验证码无效或已过期") + # WITHDRAW记录全额;WITHDRAW_FEE为其中费用明细,不能再从买方余额扣一次。 + history = db.query(Transaction).filter( + Transaction.account == current_user, Transaction.tx_type == "WITHDRAW").all() + if any(isinstance(row.amount, bool) or not isinstance(row.amount, int) or row.amount >= 0 for row in history): + raise ValueError("invalid withdrawal history") + total_withdrawn = -sum(row.amount for row in history) + free_quota = max(0, 100-total_withdrawn) + fee_amount = max(0, req.amount-free_quota)//10 + net_amount = req.amount-fee_amount + wallet.balance -= req.amount + wallet.frozen_balance += net_amount + sys_wallet.balance += fee_amount + tx_id = f"WD_{uuid.uuid4().hex}" + last_tx = db.query(Transaction).filter(Transaction.account == current_user).order_by( + Transaction.created_at.desc(), Transaction.tx_id.desc()).with_for_update().first() prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH" - tx_hash = calculate_tx_hash(tx_id, req.account, "WITHDRAW", -actual_withdraw, prev_hash) - - # 脱敏支付宝账号和姓名 - masked_alipay = _mask_alipay(req.alipayAccount) - masked_name = _mask_name(req.real_name) - - new_tx = Transaction( - tx_id=tx_id, account=req.account, tx_type="WITHDRAW", amount=-actual_withdraw, - alipay_account=req.alipayAccount, - real_name=req.real_name, - withdraw_status="pending", - net_amount=net_amount, # 记录实际到账金额,用于解冻时准确扣减 - prev_hash=prev_hash, tx_hash=tx_hash, - description=f"提现到支付宝 {masked_alipay} ({masked_name})" - ) - db.add(new_tx) - - # 🚀 如果有手续费,额外记录一笔手续费交易 - if fee_amount > 0: - fee_tx_id = f"FEE_{int(time.time())}_{uuid.uuid4().hex[:6]}" - fee_tx_hash = calculate_tx_hash(fee_tx_id, req.account, "WITHDRAW_FEE", -fee_amount, tx_hash) - fee_tx = Transaction( - tx_id=fee_tx_id, account=req.account, tx_type="WITHDRAW_FEE", amount=-fee_amount, - prev_hash=tx_hash, tx_hash=fee_tx_hash, - description="提现手续费" - ) - db.add(fee_tx) - + tx_hash = calculate_tx_hash(tx_id, current_user, "WITHDRAW", -req.amount, prev_hash) + db.add(Transaction(tx_id=tx_id, account=current_user, tx_type="WITHDRAW", amount=-req.amount, + alipay_account=req.alipayAccount, real_name=req.real_name, withdraw_status="pending", + net_amount=net_amount, prev_hash=prev_hash, tx_hash=tx_hash, + description=f"提现到支付宝 {_mask_alipay(req.alipayAccount)} ({_mask_name(req.real_name)})")) + if fee_amount: + fee_id = f"FEE_{uuid.uuid4().hex}" + db.add(Transaction(tx_id=fee_id, account=current_user, tx_type="WITHDRAW_FEE", + amount=-fee_amount, item_id=tx_id, related_account=SYSTEM_FEE_ACCOUNT, + prev_hash=tx_hash, tx_hash=calculate_tx_hash(fee_id, current_user, "WITHDRAW_FEE", -fee_amount, tx_hash), + description="提现手续费")) + last_sys = db.query(Transaction).filter(Transaction.account == SYSTEM_FEE_ACCOUNT).order_by( + Transaction.created_at.desc(), Transaction.tx_id.desc()).with_for_update().first() + sys_prev = last_sys.tx_hash if last_sys else "GENESIS_HASH" + sys_id = f"FEE_IN_{uuid.uuid4().hex}" + db.add(Transaction(tx_id=sys_id, account=SYSTEM_FEE_ACCOUNT, tx_type="WITHDRAW_FEE", + amount=fee_amount, item_id=tx_id, related_account=current_user, prev_hash=sys_prev, + tx_hash=calculate_tx_hash(sys_id, SYSTEM_FEE_ACCOUNT, "WITHDRAW_FEE", fee_amount, sys_prev), + description="提现手续费收入")) + response = {"status": "success", "withdraw_amount": req.amount, "fee_amount": fee_amount, + "net_amount": net_amount, "free_quota_used": min(req.amount, free_quota)} db.commit() - - # 📝 P2优化:提现审计日志 - logger.info(f"WITHDRAW | account={req.account} | amount={actual_withdraw} | fee={fee_amount} | net={net_amount} | tx={tx_id}") - - del VERIFY_CODES[key] - return { - "status": "success", - "withdraw_amount": actual_withdraw, - "fee_amount": fee_amount, - "net_amount": net_amount, - "free_quota_used": min(req.amount, free_quota + total_withdrawn) - total_withdrawn # 本次消耗的免责额度 - } except HTTPException: db.rollback() raise - except Exception as e: + except Exception as error: db.rollback() - logger.error(f"WITHDRAW_ERROR | account={req.account} | amount={req.amount} | error={str(e)}") + logger.error("WITHDRAW_ERROR | error_type=%s", type(error).__name__) raise HTTPException(status_code=500, detail="提现处理失败,请稍后重试") + run_tip_effect(lambda: logger.info("WITHDRAW_REQUESTED")) + return response + # ========================================== # 💳 P6支付增强:交易明细查询API @@ -1126,7 +843,7 @@ async def get_purchase_status(account: str, item_id: str, current_user: str = De ).first() if not ownership: - return {"status": "success", "owned": False} + return {"status": "success", "owned": False, "price_confirmation_supported": True} # 读取商品,获取 allow_refund(旧数据默认 True) items_db = json_db.load_data("items.json", default_data=[]) @@ -1150,6 +867,7 @@ async def get_purchase_status(account: str, item_id: str, current_user: str = De return { "status": "success", "owned": True, + "price_confirmation_supported": True, "purchased_at": purchased_at.isoformat(), "price_paid": ownership.price_paid, "can_refund": can_refund, @@ -1158,132 +876,143 @@ async def get_purchase_status(account: str, item_id: str, current_user: str = De } @router.post("/api/wallet/refund") -@limiter.limit("3/minute") # 🔒 P0安全优化:退款每分钟最多3次 +@shared_limit(3) # 🔒 P0安全优化:退款每分钟最多3次 async def refund_purchase(request: Request, item_id: str, current_user: str = Depends(require_auth), db: Session = Depends(get_db)): - # 🔒 使用已认证用户账号,忽略客户端传入的 account + """按原购买流水退款;写锁内查重,成功快照与通知分离。""" account = current_user - """ - 🔄 P7后悔模式:申请退款 - - 24小时内可退款 - - 退款后30天内禁止再次购买 - - 退款后权限回收 - """ - items_db = json_db.load_data("items.json", default_data=[]) - item = next((i for i in items_db if i["id"] == item_id), None) - - if not item: - raise HTTPException(status_code=404, detail="商品不存在") - - # 检查资源是否支持退款 - if not item.get("allow_refund", True): # 默认支持退款(向后兼容) - raise HTTPException(status_code=400, detail="该资源不支持退款") - - # 查找购买记录 - ownership = db.query(Ownership).filter( - Ownership.account == account, - Ownership.item_id == item_id, - Ownership.is_refunded == False - ).first() - - if not ownership: - raise HTTPException(status_code=404, detail="未找到购买记录") - - # 检查是否在退款窗口内 - purchased_at = ownership.purchased_at - now = datetime.datetime.utcnow() - refund_deadline = purchased_at + datetime.timedelta(hours=REFUND_WINDOW_HOURS) - - if now >= refund_deadline: - hours_passed = (now - purchased_at).total_seconds() / 3600 - raise HTTPException(status_code=400, detail=f"已超过24小时退款窗口(已购买{hours_passed:.1f}小时)") - - refund_amount = ownership.price_paid or 0 - seller_account = item.get("author") - - if refund_amount <= 0: - raise HTTPException(status_code=400, detail="该商品为免费资源,无需退款") - - # 🔒 P1并发安全:使用悲观锁+异常处理+事务回滚防止并发问题 try: - # 执行退款 - buyer_wallet = db.query(Wallet).filter(Wallet.account == account).with_for_update().first() - seller_wallet = db.query(Wallet).filter(Wallet.account == seller_account).with_for_update().first() - - if seller_wallet: - # 直接扣减,允许余额为负(退款优先于卖家余额安全) - seller_wallet.balance = int(seller_wallet.balance or 0) - int(refund_amount) # 卖家扣回 - if seller_wallet.balance < 0: - logger.warning(f"NEGATIVE_BALANCE | seller balance went negative after refund | seller={seller_account}") - # 不修改 earn_balance(保持"累计销售收益只增不减"语义) - - if buyer_wallet: - buyer_wallet.balance = int(buyer_wallet.balance or 0) + int(refund_amount) # 买家退款 - else: - buyer_wallet = Wallet(account=account, balance=refund_amount) - db.add(buyer_wallet) - - # 标记所有权为已退款 - ownership.is_refunded = True - ownership.refunded_at = now - - # 创建退款记录(30天禁购) - ban_until = now + datetime.timedelta(days=REFUND_BAN_DAYS) - new_refund = Refund( - account=account, - item_id=item_id, - amount=refund_amount, - ban_until=ban_until - ) - db.add(new_refund) - - # 获取卖家用户名 - users_db = json_db.load_data("users.json", default_data={}) - seller_info = users_db.get(seller_account, {}) - seller_user_name = seller_info.get("name", seller_account) - - # 记录退款交易 - tx_id = f"REFUND_{int(time.time())}_{uuid.uuid4().hex[:6]}" - last_tx = db.query(Transaction).filter(Transaction.account == account).order_by(Transaction.created_at.desc()).first() - prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH" - tx_hash = calculate_tx_hash(tx_id, account, "REFUND", refund_amount, prev_hash) - - new_tx = Transaction( - tx_id=tx_id, account=account, tx_type="REFUND", amount=refund_amount, - related_account=seller_account, item_id=item_id, prev_hash=prev_hash, tx_hash=tx_hash, - description=f"退款: {item.get('title', '未知资源')}", - item_title=item.get('title', ''), - item_type=item.get('type', ''), - related_user_name=seller_user_name - ) - db.add(new_tx) + items = json_db.load_data("items.json", default_data=[], strict=True, skip_cache=True) + if not isinstance(items, list): + raise ValueError("invalid item collection") + item = next((i for i in items if isinstance(i, dict) and i.get("id") == item_id), None) + if item is None: + raise HTTPException(status_code=404, detail="商品不存在") + allow_refund = item.get("allow_refund", True) + if not isinstance(allow_refund, bool): + raise ValueError("invalid refund policy") + if not allow_refund: + raise HTTPException(status_code=400, detail="该资源不支持退款") + + # 独立只读 Session 只用于定位锁集合,不能提前开启业务 Session 的读事务。 + with Session(bind=db.get_bind(), autoflush=False) as lookup: + ownerships = lookup.query(Ownership).filter( + Ownership.account == account, Ownership.item_id == item_id, + Ownership.is_refunded == False).all() + if not ownerships: + raise HTTPException(status_code=404, detail="未找到购买记录") + if len(ownerships) != 1: + raise ValueError("ambiguous ownership") + snapshot = ownerships[0] + if isinstance(snapshot.price_paid, bool) or not isinstance(snapshot.price_paid, int) or snapshot.price_paid < 0: + raise ValueError("invalid paid amount") + if snapshot.price_paid == 0: + raise HTTPException(status_code=400, detail="该商品为免费资源,无需退款") + purchase = lookup.query(Transaction).filter( + Transaction.account == account, Transaction.item_id == item_id, + Transaction.tx_type == "PURCHASE").order_by( + Transaction.created_at.desc(), Transaction.tx_id.desc()).first() + if purchase is None: + raise ValueError("purchase ledger unavailable") + seller_account = purchase.related_account + if not isinstance(seller_account, str) or not seller_account.strip() or seller_account == account: + raise ValueError("invalid original seller") + snapshot_id, purchase_id = snapshot.id, purchase.tx_id + except HTTPException: + raise + except Exception as error: + logger.error("REFUND_SOURCE_ERROR | error_type=%s", type(error).__name__) + raise HTTPException(status_code=503, detail="购买记录暂时无法核实,请稍后重试") + + # 用户名只作展示,读取故障不阻止退款;不在资金写锁内读取 JSON。 + try: + users = json_db.load_data("users.json", default_data={}) + seller_name = users.get(seller_account, {}).get("name", seller_account) + buyer_name = users.get(account, {}).get("name", account) + if not isinstance(seller_name, str): + seller_name = seller_account + if not isinstance(buyer_name, str): + buyer_name = account + except Exception: + seller_name, buyer_name = seller_account, account + + try: + # 首项业务 SQL:SQLite BEGIN IMMEDIATE / 稳定排序的钱包行锁。 + wallets = lock_wallet_accounts(db, (account, seller_account)) + ownerships = db.query(Ownership).filter( + Ownership.account == account, Ownership.item_id == item_id, + Ownership.is_refunded == False).populate_existing().with_for_update().all() + if not ownerships: + raise HTTPException(status_code=404, detail="未找到购买记录") + if len(ownerships) != 1 or ownerships[0].id != snapshot_id: + raise HTTPException(status_code=409, detail="购买记录已变化,请重新确认") + ownership = ownerships[0] + purchase = db.query(Transaction).filter( + Transaction.account == account, Transaction.item_id == item_id, + Transaction.tx_type == "PURCHASE").order_by( + Transaction.created_at.desc(), Transaction.tx_id.desc()).with_for_update().first() + refund_amount = ownership.price_paid + if (purchase is None or purchase.tx_id != purchase_id or purchase.related_account != seller_account or + isinstance(refund_amount, bool) or not isinstance(refund_amount, int) or refund_amount <= 0 or + isinstance(purchase.amount, bool) or not isinstance(purchase.amount, int) or + purchase.amount != -refund_amount or purchase.item_type == "prompt"): + raise HTTPException(status_code=503, detail="购买记录暂时无法核实,请稍后重试") + now = datetime.datetime.utcnow() + if not isinstance(purchase.created_at, datetime.datetime) or purchase.created_at > now: + raise HTTPException(status_code=503, detail="购买记录暂时无法核实,请稍后重试") + if not isinstance(ownership.purchased_at, datetime.datetime): + raise ValueError("invalid purchase time") + if ownership.purchased_at > now: + raise ValueError("future purchase time") + if (now >= ownership.purchased_at + datetime.timedelta(hours=REFUND_WINDOW_HOURS) or + now >= purchase.created_at + datetime.timedelta(hours=REFUND_WINDOW_HOURS)): + raise HTTPException(status_code=400, detail="已超过24小时退款窗口") + buyer_wallet, seller_wallet = wallets[account], wallets[seller_account] + if buyer_wallet is None or seller_wallet is None: + raise HTTPException(status_code=503, detail="钱包记录暂时无法核实,请稍后重试") + if any(isinstance(w.balance, bool) or not isinstance(w.balance, int) for w in (buyer_wallet, seller_wallet)): + raise ValueError("invalid wallet amount") + seller_wallet.balance -= refund_amount # 保留退款优先、卖方可负余额的原有规则。 + buyer_wallet.balance += refund_amount + negative_balance = seller_wallet.balance < 0 + ownership.is_refunded, ownership.refunded_at = True, now + db.add(Refund(account=account, item_id=item_id, amount=refund_amount, + ban_until=now + datetime.timedelta(days=REFUND_BAN_DAYS))) + # 标题与资源类型沿用购买时的审计快照,不依赖后来变更的作者/价格。 + title, item_type = purchase.item_title or "", purchase.item_type or "" + for actor, other, kind, amount, name in ( + (account, seller_account, "REFUND", refund_amount, seller_name), + (seller_account, account, "SALE_REFUND", -refund_amount, buyer_name) + ): + last_tx = db.query(Transaction).filter(Transaction.account == actor).order_by( + Transaction.created_at.desc(), Transaction.tx_id.desc()).with_for_update().first() + prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH" + tx_id = f"{kind}_{uuid.uuid4().hex}" + db.add(Transaction(tx_id=tx_id, account=actor, tx_type=kind, amount=amount, + related_account=other, item_id=item_id, prev_hash=prev_hash, + tx_hash=calculate_tx_hash(tx_id, actor, kind, amount, prev_hash), + description=f"退款: {title}", item_title=title, item_type=item_type, + related_user_name=name)) + response = {"status": "success", "message": f"退款成功,{refund_amount}积分已退还", + "refund_amount": refund_amount, "ban_days": REFUND_BAN_DAYS} db.commit() - - logger.info(f"REFUND | buyer={account} | seller={seller_account} | item={item_id} | amount={refund_amount} | ban_until={ban_until.isoformat()}") - - # 🔔 通知卖家退款 - if seller_account: - add_notification(seller_account, { - "type": "refund", - "from_user": account, - "target_item_id": item_id, - "target_item_title": item.get("title", ""), - "content": f"您的作品《{item.get('title', '')}》已被退款" - }) - - return { - "status": "success", - "message": f"退款成功,{refund_amount}积分已退还", - "refund_amount": refund_amount, - "ban_days": REFUND_BAN_DAYS - } except HTTPException: db.rollback() raise - except Exception as e: + except Exception as error: db.rollback() - logger.error(f"REFUND_ERROR | buyer={account} | item={item_id} | amount={refund_amount} | error={str(e)}") + logger.error("REFUND_ERROR | error_type=%s", type(error).__name__) raise HTTPException(status_code=500, detail="退款处理失败,请稍后重试") + run_tip_effect(lambda: logger.info("REFUND_COMPLETED")) + if negative_balance: + run_tip_effect(lambda: logger.warning("REFUND_NEGATIVE_BALANCE")) + run_tip_effect(lambda: add_notification(seller_account, { + "type": "refund", "from_user": account, "target_item_id": item_id, + "target_item_title": title, "content": f"您的作品《{title}》已被退款" + })) + return response + + # ========================================== # 💰 管理员提现管理 API # ========================================== @@ -1327,47 +1056,91 @@ class CompleteWithdrawRequest(BaseModel): payment_order_id: str = Field(..., min_length=1, max_length=64, description="支付宝/银行打款流水号") @router.post("/api/admin/withdrawals/{tx_id}/complete") +@shared_limit(20) async def complete_withdrawal( + request: Request, tx_id: str, req: CompleteWithdrawRequest, current_user: str = Depends(require_auth), db: Session = Depends(get_db) ): - """管理员确认打款完成""" + """管理员已实际打款后的原子确认;不代替外部付款。""" if not is_admin(current_user): raise HTTPException(status_code=403, detail="需要管理员权限") - - tx = db.query(Transaction).filter(Transaction.tx_id == tx_id, Transaction.tx_type == "WITHDRAW").first() - if not tx: - raise HTTPException(status_code=404, detail="提现记录不存在") - if tx.withdraw_status == "completed": - raise HTTPException(status_code=400, detail="该提现已完成打款") - - # 更新提现状态 - tx.withdraw_status = "completed" - tx.payment_order_id = req.payment_order_id - - # 解冻余额(使用 net_amount 字段,确保解冻金额与冻结时一致) - wallet = db.query(Wallet).filter(Wallet.account == tx.account).first() - if wallet: - if tx.net_amount is not None: - net_amount = tx.net_amount - else: - # 历史数据兼容:查找关联手续费交易 - fee_tx = db.query(Transaction).filter( - Transaction.account == tx.account, - Transaction.tx_type == "WITHDRAW_FEE", - Transaction.created_at >= tx.created_at - datetime.timedelta(seconds=5), - Transaction.created_at <= tx.created_at + datetime.timedelta(seconds=5) - ).first() - fee_amount = abs(fee_tx.amount) if fee_tx else 0 - net_amount = abs(tx.amount) - fee_amount - logger.warning(f"WITHDRAW net_amount为NULL,已计算恢复: tx_id={tx.tx_id}, net={net_amount}, fee={fee_amount}") - wallet.frozen_balance = max(0, wallet.frozen_balance - net_amount) - - db.commit() - - # 📝 P2优化:提现完成审计日志 - logger.info(f"WITHDRAW_COMPLETED | admin={current_user} | tx_id={tx_id} | account={tx.account} | amount={tx.amount} | net_amount={tx.net_amount} | order_id={req.payment_order_id}") - - return {"status": "success", "message": f"提现 {tx_id} 已确认打款完成"} \ No newline at end of file + payment_order_id = req.payment_order_id.strip() + if not payment_order_id or len(payment_order_id) > 64: + raise HTTPException(status_code=422, detail="请填写有效打款订单号") + try: + # 只读定位锁集合;业务Session不能先执行SELECT再尝试SQLite写锁。 + with Session(bind=db.get_bind(), autoflush=False) as lookup: + snapshot = lookup.query(Transaction).filter(Transaction.tx_id == tx_id, + Transaction.tx_type == "WITHDRAW").first() + if snapshot is None: + raise HTTPException(status_code=404, detail="提现记录不存在") + account = snapshot.account + if not isinstance(account, str) or not account.strip(): + raise HTTPException(status_code=503, detail="提现账户暂时无法核实") + wallets = lock_wallet_accounts(db, (account,)) + tx = db.query(Transaction).filter(Transaction.tx_id == tx_id, + Transaction.tx_type == "WITHDRAW").populate_existing().with_for_update().first() + if tx is None or tx.account != account: + raise HTTPException(status_code=409, detail="提现记录已变化") + # 管理列表将历史NULL展示为已完成,不能再次扣冻结余额。 + if tx.withdraw_status in ("completed", None): + raise HTTPException(status_code=400, detail="该提现已完成打款") + if tx.withdraw_status != "pending": + raise HTTPException(status_code=503, detail="提现状态暂时无法核实") + if isinstance(tx.amount, bool) or not isinstance(tx.amount, int) or tx.amount >= 0: + raise HTTPException(status_code=503, detail="提现金额暂时无法核实") + gross = -tx.amount + net_amount = tx.net_amount + if net_amount is None: + # 优先精确关联;老流水缺少关联ID时,仅接受唯一的时间窗候选。 + candidates = db.query(Transaction).filter(Transaction.account == account, + Transaction.tx_type == "WITHDRAW_FEE", Transaction.item_id == tx_id).all() + if not candidates: + neighbors = db.query(Transaction).filter(Transaction.account == account, + Transaction.tx_type == "WITHDRAW", Transaction.tx_id != tx_id, + Transaction.created_at >= tx.created_at-datetime.timedelta(seconds=5), + Transaction.created_at <= tx.created_at+datetime.timedelta(seconds=5)).first() + if neighbors: + raise HTTPException(status_code=503, detail="历史手续费关联不明确,请核实到账金额") + candidates = db.query(Transaction).filter(Transaction.account == account, + Transaction.tx_type == "WITHDRAW_FEE", Transaction.item_id == None, + Transaction.created_at >= tx.created_at-datetime.timedelta(seconds=5), + Transaction.created_at <= tx.created_at+datetime.timedelta(seconds=5)).all() + if len(candidates) > 1: + raise HTTPException(status_code=503, detail="历史手续费暂时无法核实") + if candidates and (isinstance(candidates[0].amount, bool) or + not isinstance(candidates[0].amount, int) or candidates[0].amount >= 0): + raise HTTPException(status_code=503, detail="历史手续费暂时无法核实") + fee_amount = -candidates[0].amount if candidates else 0 + if not candidates: + prior = db.query(Transaction).filter(Transaction.account == account, + Transaction.tx_type == "WITHDRAW", Transaction.created_at < tx.created_at).all() + if any(isinstance(row.amount, bool) or not isinstance(row.amount, int) or row.amount >= 0 for row in prior): + raise HTTPException(status_code=503, detail="历史提现金额暂时无法核实") + expected_fee = max(0, gross-max(0, 100+sum(row.amount for row in prior)))//10 + if expected_fee: + raise HTTPException(status_code=503, detail="历史手续费记录缺失,请核实到账金额") + if isinstance(fee_amount, bool) or not isinstance(fee_amount, int) or not 0 <= fee_amount < gross: + raise HTTPException(status_code=503, detail="历史手续费暂时无法核实") + net_amount = gross-fee_amount + if isinstance(net_amount, bool) or not isinstance(net_amount, int) or not 0 < net_amount <= gross: + raise HTTPException(status_code=503, detail="到账金额暂时无法核实") + wallet = wallets[account] + if wallet is None or isinstance(wallet.frozen_balance, bool) or not isinstance(wallet.frozen_balance, int) or wallet.frozen_balance < net_amount: + raise HTTPException(status_code=503, detail="冻结余额暂时无法核实") + wallet.frozen_balance -= net_amount + tx.net_amount, tx.withdraw_status, tx.payment_order_id = net_amount, "completed", payment_order_id + response = {"status": "success", "message": f"提现 {tx_id} 已确认打款完成"} + db.commit() + except HTTPException: + db.rollback() + raise + except Exception as error: + db.rollback() + logger.error("WITHDRAW_COMPLETE_ERROR | error_type=%s", type(error).__name__) + raise HTTPException(status_code=500, detail="提现确认失败,请稍后重试") + run_tip_effect(lambda: logger.info("WITHDRAW_COMPLETED")) + return response diff --git a/safe_http_transport.py b/safe_http_transport.py new file mode 100644 index 0000000000000000000000000000000000000000..21c1fe5052f2c5854f7662b0dcce4fb652d7d1c0 --- /dev/null +++ b/safe_http_transport.py @@ -0,0 +1,94 @@ +"""HTTPX transport that connects only to the public IPs validated for each request.""" +from contextvars import ContextVar +import asyncio +import ipaddress +import socket + +import httpcore +import httpx + + +_validated_ip = ContextVar("ranking_validated_ip", default=None) + + +async def _resolve_public_ips(host: str, port: int) -> tuple[str, ...]: + normalized_host = host.rstrip(".").lower() + try: + address = ipaddress.ip_address(normalized_host) + addresses = [address] + except ValueError: + try: + records = await asyncio.to_thread( + socket.getaddrinfo, normalized_host, port, type=socket.SOCK_STREAM + ) + except OSError as exc: + raise httpx.ConnectError("目标域名无法解析") from exc + addresses = [ + ipaddress.ip_address(record[4][0].split("%", 1)[0]) + for record in records + ] + + if not addresses or any(not address.is_global for address in addresses): + raise httpx.ConnectError("目标域名解析到非公网地址") + return tuple(dict.fromkeys(str(address) for address in addresses)) + + +class _PinnedNetworkBackend(httpcore.AsyncNetworkBackend): + """Use the validated IP for TCP while HTTP Core retains the original TLS hostname.""" + + def __init__(self): + self._backend = httpcore.AnyIOBackend() + + async def connect_tcp(self, host, port, timeout=None, local_address=None, socket_options=None): + pinned_ips = _validated_ip.get() + if not pinned_ips: + raise httpcore.ConnectError("缺少已校验的目标 IP") + last_error = None + for pinned_ip in pinned_ips: + try: + return await self._backend.connect_tcp( + pinned_ip, + port, + timeout=timeout, + local_address=local_address, + socket_options=socket_options, + ) + except (httpcore.ConnectError, httpcore.ConnectTimeout) as exc: + last_error = exc + raise last_error or httpcore.ConnectError("无法连接到已校验的公网地址") + + async def connect_unix_socket(self, path, timeout=None, socket_options=None): + raise httpcore.ConnectError("下载代理不允许 Unix Socket") + + async def sleep(self, seconds): + await self._backend.sleep(seconds) + + +class ValidatedAsyncHTTPTransport(httpx.AsyncHTTPTransport): + """Pin every new TCP connection to a DNS address validated for its request.""" + + def __init__(self): + super().__init__(verify=True, trust_env=False, retries=0) + previous_pool = self._pool + self._network_backend = _PinnedNetworkBackend() + self._pool = httpcore.AsyncConnectionPool( + ssl_context=previous_pool._ssl_context, + max_connections=previous_pool._max_connections, + max_keepalive_connections=previous_pool._max_keepalive_connections, + keepalive_expiry=previous_pool._keepalive_expiry, + http1=previous_pool._http1, + http2=previous_pool._http2, + network_backend=self._network_backend, + ) + + async def handle_async_request(self, request): + host = request.url.host.rstrip(".").lower() + port = request.url.port or (443 if request.url.scheme == "https" else 80) + pinned_ips = await _resolve_public_ips(host, port) + token = _validated_ip.set(pinned_ips) + try: + # HTTP Core passes the original origin hostname to start_tls(), preserving + # SNI and certificate hostname validation while TCP uses pinned_ip. + return await super().handle_async_request(request) + finally: + _validated_ip.reset(token) diff --git a/task_application.py b/task_application.py new file mode 100644 index 0000000000000000000000000000000000000000..1d8d61f15e60f5e500aa2f3f0ec7d7d2163407fa --- /dev/null +++ b/task_application.py @@ -0,0 +1,75 @@ +"""报名/撤回与指派共用发布者锁,只更新当前报名列表。""" +import copy +import time +from models_sql import Transaction, TaskAssignment, TaskDisputeRecord, TaskDisputeResponseRecord +from task_terms import funded_amount, latest_terms +from tip_transaction import lock_wallet_accounts + + +class ApplicationConflict(ValueError): + """已报名/未报名等原有业务校验,保持400。""" + + +class ApplicationStateConflict(ValueError): + """SQL已指派或闭账,页面状态尚未投影,返回409。""" + + +def validated_applicants(task): + applicants = task.get('applicants', []) + if (not isinstance(applicants, list) or any(not isinstance(a, dict) + or not isinstance(a.get('account'), str) or not a['account'].strip() for a in applicants) + or len({a['account'] for a in applicants}) != len(applicants)): + raise ValueError('invalid task applicants') + return applicants + + +def change_application(session, task_id, actor, load_task, store, message=None, withdraw=False): + if not isinstance(actor, str) or not actor.strip() or (message is not None and not isinstance(message, str)): + raise ValueError('invalid application input') + snapshot = load_task(task_id) + publisher = snapshot.get('publisher') + try: + wallets = lock_wallet_accounts(session, (publisher,)) + task = load_task(task_id) + if task.get('publisher') != publisher: + raise ValueError('task publisher changed') + if not withdraw and actor == publisher: + raise ApplicationConflict('不能申请自己发布的任务') + rows = session.query(Transaction).filter(Transaction.item_id == task_id, + Transaction.tx_type.like('TASK_%')).populate_existing().with_for_update().all() + if (session.get(TaskAssignment, task_id) is not None or session.get(TaskDisputeRecord, task_id) is not None or session.get(TaskDisputeResponseRecord, task_id) is not None + or any(row.tx_type in {'TASK_DEPOSIT', 'TASK_PAYMENT', 'TASK_INCOME', 'TASK_REFUND', 'TASK_CANCEL_REFUND'} for row in rows)): + raise ApplicationStateConflict('任务状态已改变,请刷新核对') + task, _ = latest_terms(session, task) + if task.get('status') != 'open' or task.get('assignee') or task.get('dispute_id'): + raise ApplicationConflict('任务已开始,无法取消申请' if withdraw else '该任务不在开放接单状态') + amount = funded_amount(rows, publisher) + wallet = wallets[publisher] + if (type(task.get('total_price')) is not int or task['total_price'] != amount + or type(task.get('frozen_amount')) is not int or task['frozen_amount'] != amount + or wallet is None or type(wallet.frozen_balance) is not int or wallet.frozen_balance < amount): + raise ValueError('application task funding mismatch') + def update(tasks): + if not isinstance(tasks, list): + raise ValueError('invalid task source') + matches = [t for t in tasks if isinstance(t, dict) and t.get('id') == task_id] + if len(matches) != 1 or matches[0].get('publisher') != publisher: + raise ValueError('application task changed') + target = matches[0] + if target.get('status') != 'open' or target.get('assignee') or target.get('dispute_id'): + raise ApplicationStateConflict('任务状态已改变,请刷新核对') + applicants = validated_applicants(target) + exists = any(a['account'] == actor for a in applicants) + if withdraw: + if not exists: + raise ApplicationConflict('您未申请该任务') + target['applicants'] = [a for a in applicants if a['account'] != actor] + else: + if exists: + raise ApplicationConflict('您已申请过该任务') + target['applicants'] = applicants + [dict(account=actor, message=message or '', applied_at=int(time.time()))] + store.atomic_update('tasks.json', update, default_data=[]) + return copy.deepcopy(task) + finally: + # 只读SQL用于串行化与闭账核查,无钱包/流水变更。 + session.rollback() diff --git a/task_assignment.py b/task_assignment.py new file mode 100644 index 0000000000000000000000000000000000000000..473be7570d3c2c745fc1d0022ebf8675be9541b0 --- /dev/null +++ b/task_assignment.py @@ -0,0 +1,106 @@ +"""任务指派核对最新SQL资金/条款,订金和待投影状态同提交。""" +import hashlib +import time +import uuid +from task_projection import scoped_pending +from sqlalchemy.orm import Session +from models_sql import Transaction, TaskAssignment +from tip_transaction import lock_wallet_accounts +from task_terms import funded_amount, latest_terms + + +def assign_open_task(session, task_id, publisher, assignee, load_task): + try: + wallet = lock_wallet_accounts(session, (publisher,))[publisher] + task = load_task(task_id) + if task.get('publisher') != publisher or not isinstance(assignee,str) or not assignee.strip() or assignee == publisher: + raise ValueError('invalid task participants') + task, _ = latest_terms(session, task) + rows = session.query(Transaction).filter(Transaction.item_id == task_id, + Transaction.tx_type.like('TASK_%')).populate_existing().with_for_update().all() + amount = funded_amount(rows,publisher) + deposit = task.get('deposit_amount') + ratio = task.get('deposit_ratio') + if (type(task.get('total_price')) is not int or task['total_price'] != amount + or type(ratio) is not int or ratio not in {10,20,30,50} + or type(deposit) is not int or deposit != amount*ratio//100): + raise ValueError('task funding terms mismatch') + if any(row.tx_type in {'TASK_REFUND','TASK_CANCEL_REFUND','TASK_PAYMENT','TASK_INCOME'} for row in rows): + raise ValueError('task funds closed') + deposits = [row for row in rows if row.tx_type == 'TASK_DEPOSIT'] + assignment = session.get(TaskAssignment,task_id) + if deposits: + if (len(deposits)!=1 or deposits[0].account!=publisher or deposits[0].related_account!=assignee + or type(deposits[0].amount) is not int or deposits[0].amount!=-deposit): + raise ValueError('task already assigned differently') + if assignment is None: + assignment = TaskAssignment(task_id=task_id,publisher=publisher,assignee=assignee, + deposit=deposit,assigned_at=task.get('assigned_at') or int(time.time()),projected=False) + session.add(assignment) + if assignment.publisher!=publisher or assignment.assignee!=assignee or assignment.deposit!=deposit: + raise ValueError('task assignment mismatch') + session.commit() + return {'deposit':deposit,'credited':False,'task':task} + if assignment is not None or task.get('status')!='open' or task.get('assignee'): + raise ValueError('task not open') + applicants = task.get('applicants') + if not isinstance(applicants,list) or not any(isinstance(a,dict) and a.get('account')==assignee for a in applicants): + raise ValueError('assignee has not applied') + if wallet is None or type(wallet.frozen_balance) is not int or wallet.frozen_balance 1: + raise ValueError('ambiguous dispute') + if matches: + for key in ('task_id', 'publisher', 'assignee', 'initiator', 'initiator_role', 'reason', 'evidence', 'created_at'): + if matches[0].get(key) != payload.get(key): + raise ValueError('dispute original evidence changed') + if matches[0].get('status') not in {'pending', 'responded', 'resolved'}: + raise ValueError('invalid dispute state') + else: + if any(isinstance(d, dict) and d.get('task_id') == task_id for d in disputes): + raise ValueError('another dispute for task') + disputes.append(copy.deepcopy(payload)) + # 先保存证据,再关联任务;双方成功才确认。重复不会覆写回应/裁决。 + store.atomic_update('disputes.json', append, default_data=[]) + def update(tasks): + matches = [t for t in tasks if isinstance(t, dict) and t.get('id') == task_id] + if len(matches) != 1: + raise ValueError('dispute task changed') + target = matches[0] + validate_participants(payload, target, publisher, row.assignee) + if target.get('dispute_id') not in (None, row.dispute_id): + raise ResolutionConflict('task dispute link changed') + if target.get('status') in {'in_progress', 'submitted'} and not closed: + target.update(status='disputed', dispute_id=row.dispute_id) + elif (target.get('status') not in {'disputed', 'completed'} + or target.get('dispute_id') != row.dispute_id + or (target.get('status') == 'completed' and not closed)): + raise ResolutionConflict('task dispute state changed') + store.atomic_update('tasks.json', update, default_data=[]) + row.projected = True + session.commit() + count += 1 + except Exception: + session.rollback() + raise + return count + + +def respond_to_dispute(session, dispute_id, actor, response, evidence, load_dispute, load_task, store): + """保留回复调用契约,先持久保存再独立投影。""" + from task_dispute_response import record_response + return record_response(session, dispute_id, actor, response, evidence, load_dispute, load_task, store) diff --git a/task_dispute_read.py b/task_dispute_read.py new file mode 100644 index 0000000000000000000000000000000000000000..393f969e0fea4a6c5a3eda3c1d52db3808c9eddb --- /dev/null +++ b/task_dispute_read.py @@ -0,0 +1,120 @@ +"""私有申诉读取合并SQL事实;不写JSON、不确认投影,也不锁钱包。""" +import copy +import json +from models_sql import TaskDisputeRecord, TaskDisputeResponseRecord, TaskResolution, Transaction +from task_dispute import dispute_payload +from task_dispute_response import response_payload, ORIGINAL_FIELDS, RESPONSE_FIELDS +from task_resolution import validate_resolution_ledgers, validate_participants +from task_terms import funded_amount + + +class DisputeReadDenied(PermissionError): + pass + + +class DisputeReadMissing(LookupError): + pass + + +def dispute_source(store): + source = store.load_data('disputes.json', default_data=[], strict=True, skip_cache=True) + if not isinstance(source, list): + raise ValueError('invalid dispute source') + return source + + +def merged_dispute(session, source, identifier, actor, admin=False, *, records=None): + if type(admin) is not bool: + raise ValueError('invalid admin scope') + matches = [d for d in source if isinstance(d, dict) and d.get('id') == identifier] + if len(matches) > 1: + raise ValueError('ambiguous dispute') + current = matches[0] if matches else None + if records is None: + records = tuple(session.query(model).filter(model.dispute_id == identifier).populate_existing().one_or_none() + for model in (TaskDisputeRecord, TaskDisputeResponseRecord, TaskResolution)) + created, response, resolution = records + primary = created or response or resolution + if primary is None and current is None: + raise DisputeReadMissing('dispute not found') + publisher = primary.publisher if primary is not None else current.get('publisher') + assignee = primary.assignee if primary is not None else current.get('assignee') + # 先用持久参与者授权,不能靠被旧JSON改掉的账号获取私有证据。 + if not admin and actor not in (publisher, assignee): + raise DisputeReadDenied('not dispute participant') + if publisher == assignee or any(not isinstance(v, str) or not v.strip() for v in (publisher, assignee)): + raise ValueError('invalid dispute participants') + for row in (created, response, resolution): + if row is not None and (row.dispute_id != identifier or row.publisher != publisher or row.assignee != assignee or row.task_id != primary.task_id): + raise ValueError('conflicting durable dispute identities') + if current is not None and (current.get('publisher') != publisher or current.get('assignee') != assignee + or (primary is not None and current.get('task_id') != primary.task_id)): + raise ValueError('json dispute identity differs from sql') + result = copy.deepcopy(current) if current is not None else None + if created is not None: + original = dispute_payload(created) + if result is None: + result = copy.deepcopy(original) + elif any(result.get(k) != original.get(k) for k in ('id', 'task_id', 'publisher', 'assignee')+ORIGINAL_FIELDS): + raise ValueError('json original evidence differs from sql') + result['task_title'] = original.get('task_title', '') + if result is None and response is not None: + # 旧申诉仅有持久回复时,原始角色/理由/证据仍可按首次记录读取。 + payload = json.loads(response.payload) + if not isinstance(payload, dict): + raise ValueError('invalid response source') + result = dict(id=identifier, task_id=response.task_id, publisher=publisher, assignee=assignee, + task_title='', status='pending', **{k: copy.deepcopy(payload.get(k)) for k in ORIGINAL_FIELDS}) + if result is None: + # 仅裁决没有原始理由/证据时,不能编造申诉内容。 + raise ValueError('original dispute missing') + task = dict(id=result.get('task_id'), publisher=publisher, assignee=assignee) + validate_participants(result, task, publisher, assignee) + if result.get('status') == 'resolved' and primary is not None and resolution is None: + raise ValueError('resolved state lacks durable resolution') + if response is not None: + payload = response_payload(response, result, task) + result.update({k: copy.deepcopy(payload[k]) for k in RESPONSE_FIELDS}) + result['status'] = 'responded' + if resolution is not None: + if resolution.initiator_role != result.get('initiator_role'): + raise ValueError('resolution role differs from original dispute') + rows = session.query(Transaction).filter(Transaction.item_id == resolution.task_id, + Transaction.tx_type.like('TASK_%')).populate_existing().all() + amount = funded_amount(rows, publisher) + deposits = [r for r in rows if r.tx_type == 'TASK_DEPOSIT'] + if (amount != resolution.amount or len(deposits) != 1 or deposits[0].account != publisher + or deposits[0].related_account != assignee or type(deposits[0].amount) is not int + or deposits[0].amount != -resolution.deposit): + raise ValueError('resolution differs from actual funding') + validate_resolution_ledgers(session, resolution, lock=False) + result.update(status='resolved', resolution=resolution.resolution, resolution_ratio=resolution.ratio, + resolution_note=resolution.note, resolved_by=resolution.resolved_by, resolved_at=resolution.resolved_at) + if result.get('status') not in {'pending', 'responded', 'resolved'}: + raise ValueError('invalid dispute state') + return result + + +def read_dispute_state(session, store, identifier, actor, admin=False): + return merged_dispute(session, dispute_source(store), identifier, actor, admin) + + +def read_admin_disputes(session, store): + source = dispute_source(store) + if any(not isinstance(d, dict) or not isinstance(d.get('id'), str) or not d['id'] for d in source): + raise ValueError('invalid dispute records') + source_map = {d['id']: d for d in source} + if len(source_map) != len(source): + raise ValueError('ambiguous dispute records') + identifiers = set(source_map) + maps = [] + for model in (TaskDisputeRecord, TaskDisputeResponseRecord, TaskResolution): + records = {} + for row in session.query(model).populate_existing().all(): + if not isinstance(row.dispute_id, str) or not row.dispute_id or row.dispute_id in records: + raise ValueError('invalid durable dispute ids') + records[row.dispute_id] = row + maps.append(records) + identifiers.update(records) + return [merged_dispute(session, [source_map[identifier]] if identifier in source_map else [], identifier, None, True, + records=tuple(records.get(identifier) for records in maps)) for identifier in sorted(identifiers)] diff --git a/task_dispute_response.py b/task_dispute_response.py new file mode 100644 index 0000000000000000000000000000000000000000..b1dbf9606dd61eb68899612e20153bff2a22b807 --- /dev/null +++ b/task_dispute_response.py @@ -0,0 +1,142 @@ +"""首次回复先SQL保存;JSON局部投影失败可重试,不能覆盖裁决。""" +import copy +import json +import time +from sqlalchemy.orm import Session +from models_sql import TaskDisputeResponseRecord, TaskResolution +from task_projection import scoped_pending, report_pending +from task_settlement import closed_transactions, settlement_terms +from task_resolution import validate_participants, validate_resolution_ledgers, ResolutionConflict +from tip_transaction import lock_wallet_accounts + +ORIGINAL_FIELDS = ('initiator', 'initiator_role', 'reason', 'evidence', 'created_at') +RESPONSE_FIELDS = ('response', 'response_evidence', 'responded_at') + + +def response_payload(row, dispute, task): + validate_participants(dispute, task, row.publisher, row.assignee) + payload = json.loads(row.payload) + if (row.task_id != dispute.get('task_id') or row.dispute_id != dispute.get('id') + or not isinstance(payload, dict) or set(payload) != set(ORIGINAL_FIELDS+RESPONSE_FIELDS) + or any(payload.get(k) != dispute.get(k) for k in ORIGINAL_FIELDS) + or row.respondent != (row.assignee if dispute.get('initiator_role') == 'publisher' else row.publisher) + or not isinstance(payload.get('response'), str) or not payload['response'].strip() + or not isinstance(payload.get('response_evidence'), list) or len(payload['response_evidence']) > 6 + or any(not isinstance(v, str) for v in payload['response_evidence']) + or type(payload.get('responded_at')) is not int or payload['responded_at'] < 0): + raise ValueError('invalid durable dispute response') + return payload + + +def verify_response_original(session, dispute, task): + row = session.get(TaskDisputeResponseRecord, task['id']) + if row is not None: + response_payload(row, dispute, task) + + +def record_response(session, dispute_id, actor, response, evidence, load_dispute, load_task, store): + from task_dispute import DisputePermissionError, verify_durable_dispute + snapshot = load_dispute(dispute_id) + publisher, assignee, task_id = snapshot.get('publisher'), snapshot.get('assignee'), snapshot.get('task_id') + role = snapshot.get('initiator_role') + if publisher == assignee or role not in {'publisher', 'assignee'} or any(not isinstance(v, str) or not v.strip() for v in (publisher, assignee, task_id)): + raise ValueError('invalid response participants') + if actor != (assignee if role == 'publisher' else publisher): + raise DisputePermissionError('not dispute respondent') + if not isinstance(response, str) or not response.strip() or not isinstance(evidence, list) or any(not isinstance(v, str) for v in evidence): + raise ValueError('invalid dispute response') + try: + wallets = lock_wallet_accounts(session, (publisher,)) + if wallets[publisher] is None: + raise ValueError('response publisher wallet missing') + if closed_transactions(session, task_id): + raise ResolutionConflict('dispute funds closed') + dispute, task = load_dispute(dispute_id), load_task(task_id) + source = store.load_data('disputes.json', default_data=[], strict=True, skip_cache=True) + if not isinstance(source, list) or len([d for d in source if isinstance(d, dict) and d.get('id') == dispute_id]) != 1: + raise ValueError('response dispute missing or ambiguous') + validate_participants(dispute, task, publisher, assignee) + verify_durable_dispute(session, dispute) + if dispute.get('task_id') != task_id or dispute.get('initiator_role') != role: + raise ValueError('response identity changed') + settlement_terms(session, task, publisher, assignee) + if task.get('status') != 'disputed' or task.get('dispute_id') != dispute_id: + raise ResolutionConflict('task no longer disputed') + row = session.get(TaskDisputeResponseRecord, task_id) + changed = row is None + if row is not None: + payload = response_payload(row, dispute, task) + if (row.respondent != actor or payload['response'] != response or payload['response_evidence'] != evidence[:6] + or dispute.get('status') not in {'pending', 'responded'}): + raise ResolutionConflict('original response already fixed') + else: + if dispute.get('status') != 'pending': + raise ResolutionConflict('dispute not awaiting response') + payload = {k: copy.deepcopy(dispute.get(k)) for k in ORIGINAL_FIELDS} + payload.update(response=response, response_evidence=list(evidence[:6]), responded_at=int(time.time())) + row = TaskDisputeResponseRecord(task_id=task_id, dispute_id=dispute_id, publisher=publisher, + assignee=assignee, respondent=actor, payload=json.dumps(payload, ensure_ascii=False, allow_nan=False)) + response_payload(row, dispute, task) + session.add(row) + row.projected = False + result = copy.deepcopy(dispute); result.update({k: copy.deepcopy(payload[k]) for k in RESPONSE_FIELDS}) + result.update(status='responded', _response_changed=changed) + session.commit() + except Exception: + session.rollback() + raise + try: + project_responses(session.get_bind(), store, task_id=task_id) + except Exception as error: + report_pending('task_dispute_responses', error, task_id) + return result + + +def project_responses(bind, store, limit=50, *, task_id=None): + from task_dispute import verify_durable_dispute + with Session(bind=bind, autoflush=False) as lookup: + pending = scoped_pending(lookup.query(TaskDisputeResponseRecord.task_id, TaskDisputeResponseRecord.publisher).filter( + TaskDisputeResponseRecord.projected.is_(False)), TaskDisputeResponseRecord, task_id).order_by(TaskDisputeResponseRecord.task_id).limit(limit).all() + count = 0 + for identifier, publisher in pending: + with Session(bind=bind, autoflush=False) as session: + try: + wallets = lock_wallet_accounts(session, (publisher,)) + row = session.query(TaskDisputeResponseRecord).filter(TaskDisputeResponseRecord.task_id == identifier).populate_existing().with_for_update().one() + if row.publisher != publisher or wallets[publisher] is None: + raise ValueError('response publisher mismatch') + tasks = store.load_data('tasks.json', default_data=[], strict=True, skip_cache=True) + if not isinstance(tasks, list): + raise ValueError('invalid task source') + matches = [t for t in tasks if isinstance(t, dict) and t.get('id') == identifier] + if len(matches) != 1: + raise ValueError('response task missing') + task, amount, deposit, _ = settlement_terms(session, matches[0], publisher, row.assignee) + closed = closed_transactions(session, identifier) + if closed: + resolution = session.get(TaskResolution, identifier) + if (resolution is None or resolution.dispute_id != row.dispute_id or resolution.publisher != publisher + or resolution.assignee != row.assignee or resolution.amount != amount or resolution.deposit != deposit): + raise ResolutionConflict('response conflicts with closed funds') + validate_resolution_ledgers(session, resolution) + def update(disputes): + if not isinstance(disputes, list): + raise ValueError('invalid disputes root') + matches = [d for d in disputes if isinstance(d, dict) and d.get('id') == row.dispute_id] + if len(matches) != 1: + raise ValueError('response dispute missing or ambiguous') + target = matches[0] + verify_durable_dispute(session, target) + payload = response_payload(row, target, task) + if target.get('status') not in {'pending', 'responded', 'resolved'} or (target.get('status') == 'resolved' and not closed): + raise ResolutionConflict('response projection conflicts with state') + target.update({k: copy.deepcopy(payload[k]) for k in RESPONSE_FIELDS}) + if not closed: + target['status'] = 'responded' + store.atomic_update('disputes.json', update, default_data=[]) + row.projected = True + session.commit(); count += 1 + except Exception: + session.rollback() + raise + return count diff --git a/task_projection.py b/task_projection.py new file mode 100644 index 0000000000000000000000000000000000000000..455e5d45aa20d5a5ff5936bf2ded0a6d249b628b --- /dev/null +++ b/task_projection.py @@ -0,0 +1,98 @@ +"""任务状态恢复逐条隔离,并用轮转游标避免坏记录占满每一批。""" +import asyncio +import logging +from sqlalchemy.orm import Session + + +def scoped_pending(query, model, task_id): + """接口可只恢复当前任务;默认保留原批量调用契约。""" + if task_id is not None: + if not isinstance(task_id, str) or not task_id.strip(): + raise ValueError('invalid projection task id') + query = query.filter(model.task_id == task_id) + return query + + +def projection_stages(): + # 保持运行时导入,避免各资金模块与入口形成初始化循环。 + from models_sql import TaskPublication, TaskRevision, TaskAssignment, TaskDelivery, TaskDisputeRecord, TaskDisputeResponseRecord, TaskSettlement, TaskResolution + from task_publication import project_publications + from task_terms import project_revisions + from task_assignment import project_assignments + from task_delivery import project_deliveries + from task_dispute import project_disputes + from task_dispute_response import project_responses + from task_settlement import project_settlements + from task_resolution import project_resolutions + return ((TaskPublication, project_publications), (TaskRevision, project_revisions), + (TaskAssignment, project_assignments), (TaskDelivery, project_deliveries), (TaskDisputeRecord, project_disputes), + (TaskDisputeResponseRecord, project_responses), (TaskSettlement, project_settlements), (TaskResolution, project_resolutions)) + + +def report_pending(stage, error, task_id=None): + try: + logging.getLogger('ComfyUI-Ranking.TaskProjection').warning( + 'TASK_PROJECTION_PENDING | stage=%s | task=%s | error_type=%s', stage, task_id, type(error).__name__) + except Exception: + pass + + +def recover_stage(bind, store, model, project, after=None, limit=50, should_stop=lambda: False): + """只读选取独立任务;单条故障不确认、不删除,也不阻断下一条。""" + if type(limit) is not int or not 1 <= limit <= 500: + raise ValueError('invalid projection batch limit') + with Session(bind=bind, autoflush=False) as lookup: + query = lookup.query(model.task_id).filter(model.projected.is_(False)).distinct() + selected = query.filter(model.task_id > after) if after is not None else query + pending = selected.order_by(model.task_id).limit(limit).all() + if not pending and after is not None: + pending = query.order_by(model.task_id).limit(limit).all() + count, cursor = 0, after + for (task_id,) in pending: + if should_stop(): + break + try: + count += project(bind, store, limit=1, task_id=task_id) + except Exception as error: + report_pending(model.__tablename__, error, task_id) + # 失败也推进游标;下一轮走后续任务,到末尾再回头重试。 + cursor = task_id + return count, cursor if pending else None + + +async def recovery_worker(bind, store, invalidate, stop, interval=30): + from task_repair import recover_snapshots + stages = projection_stages() + cursors = {} + repair_cursor = None + while not stop.is_set(): + count = 0 + for model, project in stages: + if stop.is_set(): + break + try: + recovered, cursor = await asyncio.to_thread( + recover_stage, bind, store, model, project, cursors.get(model), 50, stop.is_set) + cursors[model] = cursor + count += recovered + except Exception as error: + # 表/连接/批次读取故障仅影响该阶段;之后的阶段仍可恢复。 + report_pending(model.__tablename__, error) + if not stop.is_set(): + try: + recovered, repair_cursor = await asyncio.to_thread( + recover_snapshots, bind, store, repair_cursor, 50, stop.is_set) + count += recovered + except Exception as error: + report_pending('task_snapshots', error) + if count: + try: + invalidate('tasks:') + except Exception as error: + report_pending('sort_cache', error) + if stop.is_set(): + break + try: + await asyncio.wait_for(stop.wait(), timeout=interval) + except asyncio.TimeoutError: + pass diff --git a/task_publication.py b/task_publication.py new file mode 100644 index 0000000000000000000000000000000000000000..6992d798e537ec04e6f768a8cc1f7b326a34e4af --- /dev/null +++ b/task_publication.py @@ -0,0 +1,96 @@ +"""发布资金与待同步内容同事务;JSON仅在提交后追加,不覆写已修改任务。""" +import copy +import hashlib +import json +import uuid +from task_projection import scoped_pending +from sqlalchemy.orm import Session +from models_sql import TaskPublication, Transaction +from tip_transaction import lock_wallet_accounts + + +class InsufficientTaskBalance(ValueError): + pass + + +def freeze_task(session, task): + publisher, task_id, amount = task['publisher'], task['id'], task['total_price'] + if type(amount) is not int or not 0 <= amount <= 2147483647: + raise ValueError('invalid task amount') + payload = json.dumps(task, ensure_ascii=False, allow_nan=False) + try: + wallet = lock_wallet_accounts(session, (publisher,), (publisher,) if amount == 0 else ())[publisher] + if wallet is None: + raise InsufficientTaskBalance('task balance insufficient') + if type(wallet.balance) is not int or type(wallet.frozen_balance) is not int or wallet.frozen_balance < 0: + raise ValueError('task wallet unavailable') + if wallet.balance < amount: + raise InsufficientTaskBalance('task balance insufficient') + if session.get(TaskPublication, task_id) is not None: + raise ValueError('task already published') + wallet.balance -= amount + wallet.frozen_balance += amount + tx_id = 'TASK_FREEZE_' + uuid.uuid4().hex + last = session.query(Transaction).filter(Transaction.account == publisher).order_by( + Transaction.created_at.desc(), Transaction.tx_id.desc()).with_for_update().first() + previous = last.tx_hash if last else 'GENESIS_HASH' + digest = hashlib.sha256(f'{tx_id}{publisher}TASK_FREEZE{-amount}{previous}'.encode()).hexdigest() + session.add(Transaction(tx_id=tx_id, account=publisher, tx_type='TASK_FREEZE', amount=-amount, + item_id=task_id, item_title=task.get('title'), item_type='task', prev_hash=previous, + tx_hash=digest, description='任务冻结')) + session.add(TaskPublication(task_id=task_id, publisher=publisher, payload=payload, projected=False)) + result = copy.deepcopy(task) + session.commit() + return result + except Exception: + session.rollback() + raise + + +def project_publications(bind, store, limit=50, *, task_id=None): + """独立会话读取待同步内容,JSON成功后再确认;重试不重复插入。""" + with Session(bind=bind, autoflush=False) as session: + rows = scoped_pending(session.query(TaskPublication).filter(TaskPublication.projected.is_(False)), TaskPublication, task_id).order_by( + TaskPublication.created_at, TaskPublication.task_id).limit(limit).all() + pending = [] + for row in rows: + payload = json.loads(row.payload) + if not isinstance(payload, dict) or payload.get('id') != row.task_id or payload.get('publisher') != row.publisher: + raise ValueError('invalid task publication') + freezes = session.query(Transaction).filter(Transaction.item_id == row.task_id, + Transaction.tx_type == 'TASK_FREEZE').all() + if (len(freezes) != 1 or freezes[0].account != row.publisher + or type(payload.get('total_price')) is not int or payload['total_price'] < 0 + or type(freezes[0].amount) is not int or freezes[0].amount != -payload['total_price']): + raise ValueError('invalid task funding') + pending.append((row.task_id, row.publisher, row.payload, payload)) + if not pending: + return 0 + def append(tasks): + if not isinstance(tasks, list): + raise ValueError('invalid task source') + for task_id, publisher, _, payload in pending: + matches = [task for task in tasks if isinstance(task, dict) and task.get('id') == task_id] + if len(matches) > 1 or (matches and matches[0].get('publisher') != publisher): + raise ValueError('task publication collision') + if not matches: + tasks.insert(0, copy.deepcopy(payload)) + store.atomic_update('tasks.json', append, default_data=[]) + # JSON存储或SQL确认失败时保留待同步记录,下一次只追加缺失任务。 + with Session(bind=bind, autoflush=False) as session: + try: + for task_id, publisher, payload, _ in pending: + session.query(TaskPublication).filter(TaskPublication.task_id == task_id, + TaskPublication.publisher == publisher, TaskPublication.payload == payload).update( + {'projected': True}, synchronize_session=False) + session.commit() + except Exception: + session.rollback() + raise + return len(pending) + + +async def publication_worker(bind, store, invalidate, stop, interval=30): + """保留启动/停机入口,逐任务隔离与轮转恢复由共享协调器处理。""" + from task_projection import recovery_worker + await recovery_worker(bind, store, invalidate, stop, interval) diff --git a/task_read.py b/task_read.py new file mode 100644 index 0000000000000000000000000000000000000000..44e59eead47ccae7426409a9fb2db4484209c00f --- /dev/null +++ b/task_read.py @@ -0,0 +1,214 @@ +"""任务页面只读快照:合并持久状态,不改钱包、JSON或投影确认。""" +import copy +import json +from collections import defaultdict +from sqlalchemy import func +from models_sql import (TaskPublication, TaskRevision, TaskAssignment, TaskDelivery, + TaskDisputeRecord, TaskDisputeResponseRecord, TaskSettlement, + TaskResolution, Transaction) +from task_terms import validate_patch, funded_amount +from task_delivery import delivery_patch +from task_dispute import dispute_payload +from task_resolution import distribution + + +MODELS = (TaskPublication, TaskRevision, TaskAssignment, TaskDelivery, + TaskDisputeRecord, TaskDisputeResponseRecord, TaskSettlement, TaskResolution) +CLOSING = {'TASK_PAYMENT', 'TASK_INCOME', 'TASK_REFUND', 'TASK_CANCEL_REFUND'} + + +def ledger(rows, identifier, kind, account, related, amount): + matches = [r for r in rows if r.tx_id == identifier] + if (len(matches) != 1 or matches[0].tx_type != kind or matches[0].account != account + or matches[0].related_account != related or type(matches[0].amount) is not int + or matches[0].amount != amount): + raise ValueError('task closing ledger mismatch') + + +def merged_task(current, records, funds): + publication, revision, assignment, delivery, dispute, response, settlement, resolution = records + if current is None and publication is None: + raise ValueError('original task missing') + original = json.loads(publication.payload) if publication is not None else None + if publication is not None: + if (not isinstance(original, dict) or original.get('id') != publication.task_id + or original.get('publisher') != publication.publisher): + raise ValueError('invalid task publication') + freezes = [r for r in funds if r.tx_type == 'TASK_FREEZE'] + if (len(freezes) != 1 or freezes[0].account != publication.publisher + or type(original.get('total_price')) is not int or original['total_price'] < 0 + or type(freezes[0].amount) is not int or freezes[0].amount != -original['total_price']): + raise ValueError('invalid original task funding') + result = copy.deepcopy(current if current is not None else original) + publisher = publication.publisher if publication is not None else result.get('publisher') + if not isinstance(publisher, str) or not publisher.strip() or result.get('publisher') != publisher: + raise ValueError('task publisher mismatch') + identifier = result.get('id') + for row in records: + if row is not None and (row.task_id != identifier or row.publisher != publisher): + raise ValueError('durable task identity mismatch') + if original is not None: + for key in ('title', 'description', 'reference_images', 'reference_link', 'total_price', + 'frozen_amount', 'deposit_ratio', 'deposit_amount', 'deadline', 'created_at'): + if key in original: + result[key] = copy.deepcopy(original[key]) + if revision is not None: + if type(revision.revision) is not int or revision.revision < 1: + raise ValueError('invalid task revision') + patch = json.loads(revision.payload) + validate_patch(patch) + result.update(copy.deepcopy(patch)) + durable = any(r is not None for r in records) + amount = funded_amount(funds, publisher) if durable or funds else None + if amount is not None and (type(result.get('total_price')) is not int or result['total_price'] != amount): + raise ValueError('task price differs from funding') + # 持久指派优先于尚未同步的空承接人;已有不同承接人不能被悄悄覆盖。 + participants = [r.assignee for r in (assignment, delivery, dispute, response, settlement, resolution) if r is not None] + assignee = participants[0] if participants else result.get('assignee') + if participants and (any(v != assignee for v in participants) or not isinstance(assignee, str) + or not assignee.strip() or assignee == publisher or result.get('assignee') not in (None, assignee)): + raise ValueError('task assignee mismatch') + deposits = [r for r in funds if r.tx_type == 'TASK_DEPOSIT'] + if participants or deposits: + deposit = result.get('deposit_amount') + if (len(deposits) != 1 or type(deposit) is not int or amount is None or not 0 <= deposit <= amount + or deposits[0].account != publisher or deposits[0].related_account != assignee + or type(deposits[0].amount) is not int or deposits[0].amount != -deposit): + raise ValueError('task deposit mismatch') + result.update(assignee=assignee, deposit_paid=deposit) + if assignment is not None: + if type(assignment.deposit) is not int or assignment.deposit != deposit or type(assignment.assigned_at) is not int: + raise ValueError('invalid assignment') + result['assigned_at'] = assignment.assigned_at + if result.get('status') == 'open': + result['status'] = 'in_progress' + if delivery is not None: + patch = copy.deepcopy(delivery_patch(delivery, result)) + if result.get('status') in {'completed', 'disputed', 'cancelled', 'expired'}: + patch.pop('status') + patch.pop('feedback') + result.update(patch) + dispute_id = None + if dispute is not None: + dispute_payload(dispute) # 核实私有原始记录,不将理由/证据输出至公开任务。 + dispute_id = dispute.dispute_id + if response is not None: + payload = json.loads(response.payload) + from task_dispute_response import response_payload, ORIGINAL_FIELDS + if not isinstance(payload, dict): + raise ValueError('invalid dispute response') + private = dispute_payload(dispute) if dispute is not None else dict( + id=response.dispute_id, task_id=identifier, publisher=publisher, assignee=assignee, + **{k: payload.get(k) for k in ORIGINAL_FIELDS}) + response_payload(response, private, result) + if dispute_id is not None and dispute_id != response.dispute_id: + raise ValueError('dispute linkage mismatch') + dispute_id = response.dispute_id + if dispute_id is not None: + if result.get('dispute_id') not in (None, dispute_id): + raise ValueError('task dispute differs from sql') + result.update(status='disputed', dispute_id=dispute_id) + closed = [r for r in funds if r.tx_type in CLOSING] + if settlement is not None: + if resolution is not None or dispute_id is not None: + raise ValueError('conflicting task closures') + if (type(settlement.amount) is not int or settlement.amount != amount + or type(settlement.deposit) is not int or settlement.deposit != deposit or len(closed) != 2): + raise ValueError('invalid task settlement') + ledger(closed, settlement.payment_tx_id, 'TASK_PAYMENT', publisher, assignee, -(amount-deposit)) + ledger(closed, settlement.income_tx_id, 'TASK_INCOME', assignee, publisher, amount) + result.update(status='completed', completed_at=settlement.completed_at, feedback=settlement.feedback) + elif resolution is not None: + if (dispute_id is not None and resolution.dispute_id != dispute_id + or result.get('dispute_id') not in (None, resolution.dispute_id) + or type(resolution.amount) is not int or resolution.amount != amount + or type(resolution.deposit) is not int or resolution.deposit != deposit or len(closed) != 3 + or distribution(amount, resolution.initiator_role, resolution.resolution, resolution.ratio) + != (resolution.publisher_refund, resolution.assignee_income)): + raise ValueError('invalid task resolution') + original_role = dispute_payload(dispute)['initiator_role'] if dispute is not None else ( + json.loads(response.payload)['initiator_role'] if response is not None else resolution.initiator_role) + if original_role != resolution.initiator_role: + raise ValueError('resolution role mismatch') + ledger(closed, resolution.payment_tx_id, 'TASK_PAYMENT', publisher, assignee, -(amount-deposit)) + ledger(closed, resolution.refund_tx_id, 'TASK_REFUND', publisher, assignee, resolution.publisher_refund) + ledger(closed, resolution.income_tx_id, 'TASK_INCOME', assignee, publisher, resolution.assignee_income) + result.update(status='completed', completed_at=resolution.resolved_at, dispute_id=resolution.dispute_id, + feedback=f"申诉裁决:{resolution.note or '无'}(结果:{resolution.resolution})") + elif closed: + # 开放任务退款无独立状态表,以唯一完整退款流水为事实。 + if len(closed) == 1 and not deposits and not participants and closed[0].tx_type in {'TASK_REFUND', 'TASK_CANCEL_REFUND'}: + row = closed[0] + ledger(closed, row.tx_id, row.tx_type, publisher, None, amount) + status = 'cancelled' if row.tx_type == 'TASK_CANCEL_REFUND' else 'expired' + result.update(status=status, refunded=True, refund_amount=amount) + elif (not durable and result.get('status') == 'completed' and len(closed) == 2 + and len([r for r in closed if r.tx_type == 'TASK_PAYMENT']) == 1 + and len([r for r in closed if r.tx_type == 'TASK_INCOME']) == 1): + # 旧已完成任务可以按有效双流水读取,不生成猜测的完成时间或反馈。 + payment = next(r for r in closed if r.tx_type == 'TASK_PAYMENT') + income = next(r for r in closed if r.tx_type == 'TASK_INCOME') + ledger(closed, payment.tx_id, 'TASK_PAYMENT', publisher, assignee, -(amount-deposit)) + ledger(closed, income.tx_id, 'TASK_INCOME', assignee, publisher, amount) + else: + raise ValueError('closing task lacks matching durable state') + elif durable and result.get('status') in {'completed', 'cancelled', 'expired'}: + raise ValueError('closed task lacks closing funds') + applicants = result.get('applicants', []) + if result.get('status') not in {'open', 'in_progress', 'submitted', 'disputed', 'completed', 'cancelled', 'expired'}: + raise ValueError('invalid task status') + if (not isinstance(applicants, list) or any(not isinstance(a, dict) or not isinstance(a.get('account'), str) + or not a['account'].strip() for a in applicants) + or len({a['account'] for a in applicants}) != len(applicants)): + raise ValueError('invalid task applicants') + return result + + +def merge_task_source(session, source, task_id=None): + """合并调用方已经严格读取的快照;不保存或复用跨轮次缓存。""" + if not isinstance(source, list): + raise ValueError('invalid task source') + current = {} + for task in source: + if not isinstance(task, dict) or not isinstance(task.get('id'), str) or not task['id'] or task['id'] in current: + raise ValueError('invalid or duplicate task') + current[task['id']] = task + identifiers = set(current) if task_id is None else ({task_id} if task_id in current else set()) + maps = [] + for model in MODELS: + query = session.query(model) + if model is TaskRevision: + latest = session.query(TaskRevision.task_id, func.max(TaskRevision.revision).label('version')) + if task_id is not None: + latest = latest.filter(TaskRevision.task_id == task_id) + latest = latest.group_by(TaskRevision.task_id).subquery() + query = query.join(latest, (TaskRevision.task_id == latest.c.task_id) + & (TaskRevision.revision == latest.c.version)) + if task_id is not None: + query = query.filter(model.task_id == task_id) + rows = query.populate_existing().all() + records = {} + for row in rows: + if model is TaskRevision: + prior = records.get(row.task_id) + if prior is not None and prior.revision > row.revision: + continue + elif row.task_id in records: + raise ValueError('duplicate durable task state') + records[row.task_id] = row + identifiers.add(row.task_id) + maps.append(records) + query = session.query(Transaction).filter(Transaction.tx_type.in_( + ['TASK_FREEZE', 'TASK_PRICE_ADJUST', 'TASK_DEPOSIT', *CLOSING])) + if task_id is not None: + query = query.filter(Transaction.item_id == task_id) + funds = defaultdict(list) + for row in query.populate_existing().all(): + funds[row.item_id].append(row) + return [merged_task(current.get(identifier), tuple(m.get(identifier) for m in maps), funds[identifier]) + for identifier in sorted(identifiers)] + + +def read_tasks(session, store, task_id=None): + source = store.load_data('tasks.json', default_data=[], strict=True, skip_cache=True) + return merge_task_source(session, source, task_id) diff --git a/task_refund_transaction.py b/task_refund_transaction.py new file mode 100644 index 0000000000000000000000000000000000000000..abc226afb9432585a6edbe655c4dbbc575c7dec8 --- /dev/null +++ b/task_refund_transaction.py @@ -0,0 +1,65 @@ +"""开放任务取消/过期退款:SQL流水防重,JSON状态仅作投影。""" +import hashlib +import uuid +import datetime +from models_sql import Transaction +from tip_transaction import lock_wallet_accounts +from task_terms import funded_amount, latest_terms + + +def refund_open_task(session, task_id, publisher, load_task, reason): + if reason not in {'cancelled', 'expired'}: + raise ValueError('invalid refund reason') + try: + wallet = lock_wallet_accounts(session, (publisher,))[publisher] + task = load_task(task_id) + if not isinstance(task, dict) or task.get('publisher') != publisher: + raise ValueError('task owner changed') + task, _ = latest_terms(session, task) + rows = session.query(Transaction).filter(Transaction.item_id == task_id, + Transaction.tx_type.in_(['TASK_FREEZE', 'TASK_PRICE_ADJUST', 'TASK_DEPOSIT', 'TASK_PAYMENT', 'TASK_INCOME', + 'TASK_REFUND', 'TASK_CANCEL_REFUND'])).populate_existing().with_for_update().all() + freezes = [row for row in rows if row.tx_type == 'TASK_FREEZE'] + refunds = [row for row in rows if row.tx_type in {'TASK_REFUND', 'TASK_CANCEL_REFUND'}] + if len(freezes) != 1 or any(row.tx_type in {'TASK_DEPOSIT', 'TASK_PAYMENT', 'TASK_INCOME'} for row in rows): + raise ValueError('task funding not refundable') + freeze = freezes[0] + if freeze.account != publisher or type(freeze.amount) is not int or freeze.amount > 0: + raise ValueError('invalid task freeze') + amount = funded_amount(rows, publisher) + if len(refunds) > 1: + raise ValueError('ambiguous task refund') + if refunds: + refund = refunds[0] + if refund.account != publisher or type(refund.amount) is not int or refund.amount != amount: + raise ValueError('invalid task refund') + state = 'cancelled' if refund.tx_type == 'TASK_CANCEL_REFUND' else 'expired' + session.rollback() + return {'amount': amount, 'status': state, 'credited': False} + if task.get('status') != 'open' or task.get('assignee'): + raise ValueError('task no longer open') + if reason == 'expired' and datetime.date.fromisoformat(task.get('deadline')) >= datetime.date.today(): + raise ValueError('task deadline changed') + frozen = task.get('frozen_amount', task.get('total_price')) + if type(frozen) is not int or frozen != amount or type(task.get('total_price')) is not int or task['total_price'] != amount: + raise ValueError('task funding mismatch') + if (wallet is None or type(wallet.balance) is not int or type(wallet.frozen_balance) is not int + or wallet.frozen_balance < amount): + raise ValueError('task frozen funds missing') + wallet.frozen_balance -= amount + wallet.balance += amount + tx_type = 'TASK_CANCEL_REFUND' if reason == 'cancelled' else 'TASK_REFUND' + tx_id = 'TASK_' + tx_type + '_' + uuid.uuid4().hex + last = session.query(Transaction).filter(Transaction.account == publisher).order_by( + Transaction.created_at.desc(), Transaction.tx_id.desc()).with_for_update().first() + previous = last.tx_hash if last else 'GENESIS_HASH' + digest = hashlib.sha256(f'{tx_id}{publisher}{tx_type}{amount}{previous}'.encode()).hexdigest() + session.add(Transaction(tx_id=tx_id, account=publisher, tx_type=tx_type, amount=amount, + item_id=task_id, item_title=task.get('title'), item_type='task', prev_hash=previous, + tx_hash=digest, description='任务取消退款' if reason == 'cancelled' else '任务过期退款')) + result = {'amount': amount, 'status': reason, 'credited': True} + session.commit() + return result + except Exception: + session.rollback() + raise diff --git a/task_repair.py b/task_repair.py new file mode 100644 index 0000000000000000000000000000000000000000..6e1f6366c188e64332af1edee7ed5761adeea0e3 --- /dev/null +++ b/task_repair.py @@ -0,0 +1,106 @@ +"""定期核对已确认任务投影:仅修复有SQL事实的字段,不重新执行资金操作。""" +import copy +from sqlalchemy.orm import Session +from models_sql import TaskDisputeRecord, TaskDisputeResponseRecord, TaskResolution +from task_read import MODELS, merge_task_source +from task_dispute_read import merged_dispute +from task_projection import report_pending +from tip_transaction import lock_wallet_accounts + + +TASK_FIELDS = ('title', 'description', 'reference_images', 'reference_link', 'total_price', + 'frozen_amount', 'deposit_ratio', 'deposit_amount', 'deadline', 'created_at', + 'status', 'assignee', 'assigned_at', 'deposit_paid', 'deliverables', 'submit_note', + 'submitted_at', 'feedback', 'completed_at', 'dispute_id', 'refunded', 'refund_amount') +DISPUTE_FIELDS = ('task_title', 'initiator', 'initiator_role', 'reason', 'evidence', 'created_at', + 'status', 'response', 'response_evidence', 'responded_at', 'resolution', + 'resolution_ratio', 'resolution_note', 'resolved_by', 'resolved_at') + + +def source_record(store, filename, identifier): + source = store.load_data(filename, default_data=[], strict=True, skip_cache=True) + if (not isinstance(source, list) or any(not isinstance(r, dict) or not isinstance(r.get('id'), str) + or not r['id'] for r in source) or len({r['id'] for r in source}) != len(source)): + raise ValueError('invalid repair source') + return next((r for r in source if r['id'] == identifier), None) + + +def repair_record(store, filename, canonical, expected, fields): + identifier = canonical['id'] + patch = {k: copy.deepcopy(canonical[k]) for k in fields if k in canonical} + if expected is not None and all(k in expected and expected[k] == v for k, v in patch.items()): + return 0 + def update(records): + if (not isinstance(records, list) or any(not isinstance(r, dict) or not isinstance(r.get('id'), str) + or not r['id'] for r in records) or len({r['id'] for r in records}) != len(records)): + raise ValueError('invalid atomic repair source') + current = next((r for r in records if r['id'] == identifier), None) + if expected is None: + if current is not None: + raise ValueError('record appeared during repair') + records.append(copy.deepcopy(canonical)) + else: + if (current is None or any(current.get(k) != expected.get(k) or (k in current) != (k in expected) + for k in ('publisher', 'assignee', 'task_id', *fields))): + raise ValueError('record changed during repair') + current.update(patch) # 报名、互动和扩展字段保留回调中最新值。 + store.atomic_update(filename, update, default_data=[]) + return 1 + + +def repair_task(bind, store, task_id): + with Session(bind=bind, autoflush=False) as session: + try: + owner = None + for model in MODELS: + row = session.query(model).filter(model.task_id == task_id).first() + if row is not None: + owner = row.publisher + break + if owner is None: + raise ValueError('durable task missing') + # 与编辑/交付/资金提交串行,读取与文件写入之间不会发生新SQL版本。 + lock_wallet_accounts(session, (owner,)) + expected = source_record(store, 'tasks.json', task_id) + tasks = merge_task_source(session, [] if expected is None else [expected], task_id) + if len(tasks) != 1 or tasks[0].get('publisher') != owner: + raise ValueError('repair task identity mismatch') + canonical = tasks[0] + count = repair_record(store, 'tasks.json', canonical, expected, TASK_FIELDS) + durable_dispute = None + for model in (TaskDisputeRecord, TaskDisputeResponseRecord, TaskResolution): + row = session.query(model).filter(model.task_id == task_id).populate_existing().one_or_none() + if row is not None: + if durable_dispute is not None and durable_dispute != row.dispute_id: + raise ValueError('conflicting repair disputes') + durable_dispute = row.dispute_id + if durable_dispute is not None: + expected = source_record(store, 'disputes.json', durable_dispute) + private = merged_dispute(session, [] if expected is None else [expected], durable_dispute, None, True) + if private.get('task_id') != task_id or private.get('publisher') != owner: + raise ValueError('repair dispute identity mismatch') + count += repair_record(store, 'disputes.json', private, expected, DISPUTE_FIELDS) + return count + finally: + session.rollback() # 只释放协调锁;不改变余额、流水或projected。 + + +def recover_snapshots(bind, store, after=None, limit=50, should_stop=lambda: False): + if type(limit) is not int or not 1 <= limit <= 500: + raise ValueError('invalid repair batch limit') + with Session(bind=bind, autoflush=False) as session: + query = session.query(MODELS[0].task_id).union(*(session.query(m.task_id) for m in MODELS[1:])) + selected = query.filter(MODELS[0].task_id > after) if after is not None else query + pending = selected.order_by(MODELS[0].task_id).limit(limit).all() + if not pending and after is not None: + pending = query.order_by(MODELS[0].task_id).limit(limit).all() + count, cursor = 0, after + for (task_id,) in pending: + if should_stop(): + break + try: + count += repair_task(bind, store, task_id) + except Exception as error: + report_pending('task_snapshots', error, task_id) + cursor = task_id + return count, cursor if pending else None diff --git a/task_resolution.py b/task_resolution.py new file mode 100644 index 0000000000000000000000000000000000000000..b5c71cb54255b8f3b546be71291a5dc9a3fefaee --- /dev/null +++ b/task_resolution.py @@ -0,0 +1,192 @@ +"""仲裁以真实托管金额分配,资金与裁决记录同事务,双JSON仅作结果投影。""" +import hashlib +import time +import uuid +from task_projection import scoped_pending +from sqlalchemy.orm import Session +from models_sql import Transaction,TaskResolution +from task_settlement import settlement_terms,closed_transactions +from tip_transaction import lock_wallet_accounts + + +class ResolutionConflict(ValueError): + pass + + +def distribution(amount,role,resolution,ratio=None): + if type(amount) is not int or amount<0 or role not in {'publisher','assignee'}: + raise ValueError('invalid resolution terms') + if resolution=='split': + if type(ratio) is not int or not 0<=ratio<=100: + raise ValueError('invalid split ratio') + initiator=amount*ratio//100 + elif resolution=='favor_initiator': + initiator=amount + elif resolution=='favor_respondent': + initiator=0 + else: + raise ValueError('invalid resolution') + respondent=amount-initiator + return (initiator,respondent) if role=='publisher' else (respondent,initiator) + + +def validate_participants(dispute,task,publisher,assignee): + role=dispute.get('initiator_role') + if (task.get('publisher')!=publisher or task.get('assignee')!=assignee or dispute.get('publisher')!=publisher + or dispute.get('assignee')!=assignee or dispute.get('task_id')!=task.get('id') + or role not in {'publisher','assignee'} or dispute.get('initiator')!=(publisher if role=='publisher' else assignee)): + raise ValueError('dispute participants mismatch') + + +def result_from(row,paid,task): + return {'paid':paid,'amount':row.amount,'publisher_refund':row.publisher_refund,'assignee_income':row.assignee_income, + 'publisher':row.publisher,'assignee':row.assignee,'initiator_role':row.initiator_role, + 'resolution':row.resolution,'ratio':row.ratio,'task':task} + + +def validate_resolution_ledgers(session,row, *, lock=True): + if (type(row.amount) is not int or type(row.deposit) is not int or not 0<=row.deposit<=row.amount + or type(row.publisher_refund) is not int or type(row.assignee_income) is not int + or distribution(row.amount,row.initiator_role,row.resolution,row.ratio)!=(row.publisher_refund,row.assignee_income)): + raise ValueError('resolution amounts mismatch') + closed=closed_transactions(session,row.task_id,lock=lock) + if len(closed)!=3 or {tx.tx_id for tx in closed}!={row.payment_tx_id,row.refund_tx_id,row.income_tx_id}: + raise ValueError('ambiguous resolution closing ledgers') + for tx_id,account,kind,value,related in ( + (row.payment_tx_id,row.publisher,'TASK_PAYMENT',-(row.amount-row.deposit),row.assignee), + (row.refund_tx_id,row.publisher,'TASK_REFUND',row.publisher_refund,row.assignee), + (row.income_tx_id,row.assignee,'TASK_INCOME',row.assignee_income,row.publisher)): + tx=session.get(Transaction,tx_id) + if (tx is None or tx.item_id!=row.task_id or tx.account!=account or tx.tx_type!=kind + or tx.related_account!=related or type(tx.amount) is not int or tx.amount!=value): + raise ValueError('resolution ledger mismatch') + + +def resolve_task(session,dispute_id,resolution,ratio,note,resolver,load_dispute,load_task): + snapshot=load_dispute(dispute_id) + task_id=snapshot.get('task_id') + publisher,assignee=snapshot.get('publisher'),snapshot.get('assignee') + if publisher==assignee or any(not isinstance(v,str) or not v.strip() for v in (publisher,assignee,resolver)): + raise ValueError('invalid resolution participants') + ratio=ratio if resolution=='split' else None + distribution(0,snapshot.get('initiator_role'),resolution,ratio) + with Session(bind=session.get_bind(),autoflush=False) as lookup: + closed=lookup.query(Transaction.tx_id).filter(Transaction.item_id==task_id, + Transaction.tx_type.in_(['TASK_PAYMENT','TASK_INCOME','TASK_REFUND','TASK_CANCEL_REFUND'])).first() is not None + try: + wallets=lock_wallet_accounts(session,(publisher,assignee),() if closed else (assignee,)) + dispute=load_dispute(dispute_id);task=load_task(task_id) + if dispute.get('task_id')!=task_id: + raise ValueError('dispute task changed') + validate_participants(dispute,task,publisher,assignee) + from task_dispute import verify_durable_dispute + verify_durable_dispute(session,dispute) + from task_dispute_response import verify_response_original + verify_response_original(session,dispute,task) + task,amount,deposit,_=settlement_terms(session,task,publisher,assignee) + row=session.get(TaskResolution,task_id) + if row is not None: + if (row.dispute_id!=dispute_id or row.publisher!=publisher or row.assignee!=assignee + or row.initiator_role!=dispute['initiator_role'] or row.amount!=amount or row.deposit!=deposit): + raise ValueError('resolution record mismatch') + if row.resolution!=resolution or row.ratio!=ratio: + raise ResolutionConflict('resolution already fixed') + if wallets[publisher] is None or wallets[assignee] is None: + raise ValueError('resolved task wallet missing') + validate_resolution_ledgers(session,row) + if task.get('status')!='completed' or dispute.get('status')!='resolved': + row.projected=False + result=result_from(row,False,task) + session.commit() + return result + if closed_transactions(session,task_id): + raise ResolutionConflict('task funds already closed') + if (task.get('status')!='disputed' or task.get('dispute_id')!=dispute_id + or dispute.get('status') not in {'pending','responded'}): + raise ResolutionConflict('dispute not awaiting resolution') + owner,worker=wallets[publisher],wallets[assignee] + remaining=amount-deposit + if (owner is None or type(owner.balance) is not int or type(owner.frozen_balance) is not int + or owner.frozen_balance0): + raise ValueError('invalid task deposit') + deposit=-deposits[0].amount + if (deposit>amount or type(task.get('total_price')) is not int or task['total_price']!=amount + or type(task.get('deposit_amount')) is not int or task['deposit_amount']!=deposit + or ('deposit_paid' in task and (type(task['deposit_paid']) is not int or task['deposit_paid']!=deposit))): + raise ValueError('task settlement funding mismatch') + return task,amount,deposit,rows + + +def settle_task(session,task_id,publisher,load_task,feedback=None): + # 读取只用于确定锁集合;持锁后再读取并核实接单者未变。 + snapshot=load_task(task_id) + assignee=snapshot.get('assignee') + if snapshot.get('publisher')!=publisher or not isinstance(assignee,str) or not assignee.strip() or publisher==assignee: + raise ValueError('invalid settlement participants') + with Session(bind=session.get_bind(),autoflush=False) as lookup: + has_payment=lookup.query(Transaction.tx_id).filter(Transaction.item_id==task_id, + Transaction.tx_type.in_(['TASK_PAYMENT','TASK_INCOME'])).first() is not None + try: + wallets=lock_wallet_accounts(session,(publisher,assignee),() if has_payment else (assignee,)) + task=load_task(task_id) + if task.get('publisher')!=publisher or task.get('assignee')!=assignee: + raise ValueError('task participants changed') + if session.get(TaskDisputeRecord,task_id) is not None or session.get(TaskDisputeResponseRecord,task_id) is not None: + raise ValueError('task has durable dispute') + task,amount,deposit,rows=settlement_terms(session,task,publisher,assignee) + payments=[row for row in rows if row.tx_type=='TASK_PAYMENT'] + incomes=[row for row in rows if row.tx_type=='TASK_INCOME'] + if any(row.tx_type in {'TASK_REFUND','TASK_CANCEL_REFUND'} for row in rows): + raise ValueError('task already refunded') + remaining=amount-deposit + settlement=session.get(TaskSettlement,task_id) + if payments or incomes: + if wallets[publisher] is None or wallets[assignee] is None: + raise ValueError('settled task wallet missing') + if (len(payments)!=1 or len(incomes)!=1 or payments[0].account!=publisher + or payments[0].related_account!=assignee or type(payments[0].amount) is not int + or payments[0].amount!=-remaining or incomes[0].account!=assignee + or incomes[0].related_account!=publisher or type(incomes[0].amount) is not int + or incomes[0].amount!=amount): + raise ValueError('invalid prior task settlement') + if settlement is None: + settlement=TaskSettlement(task_id=task_id,publisher=publisher,assignee=assignee,amount=amount, + deposit=deposit,payment_tx_id=payments[0].tx_id,income_tx_id=incomes[0].tx_id, + completed_at=int(time.time()),feedback=task.get('feedback'),projected=False) + session.add(settlement) + if (settlement.publisher!=publisher or settlement.assignee!=assignee or type(settlement.amount) is not int + or settlement.amount!=amount or settlement.deposit!=deposit + or settlement.payment_tx_id!=payments[0].tx_id or settlement.income_tx_id!=incomes[0].tx_id): + raise ValueError('task settlement record mismatch') + if task.get('status')!='completed': + settlement.projected=False + result={'amount':amount,'assignee':assignee,'paid':False,'task':task} + session.commit() + return result + from task_delivery import latest_delivery + task = latest_delivery(session,task) + if settlement is not None or task.get('status')!='submitted': + raise ValueError('task not awaiting acceptance') + owner,worker=wallets[publisher],wallets[assignee] + if (owner is None or type(owner.frozen_balance) is not int or owner.frozen_balance 0: + raise ValueError('invalid task freeze') + amount = -freezes[0].amount + for row in rows: + if row.tx_type == 'TASK_PRICE_ADJUST': + if row.account != publisher or type(row.amount) is not int or row.amount == 0: + raise ValueError('invalid task adjustment') + amount -= row.amount + if not 0 <= amount <= 2147483647: + raise ValueError('invalid task funding') + return amount + + +def latest_terms(session, task): + rows = session.query(TaskRevision).filter(TaskRevision.task_id == task['id']).order_by( + TaskRevision.revision.desc()).populate_existing().with_for_update().all() + result = copy.deepcopy(task) + if rows: + row = rows[0] + patch = json.loads(row.payload) + validate_patch(patch) + if row.publisher != task.get('publisher') or not isinstance(patch, dict): + raise ValueError('invalid task revision') + result.update(patch) + return result, rows + + +def edit_task(session, task_id, publisher, changes, load_task): + try: + wallet = lock_wallet_accounts(session, (publisher,))[publisher] + source = load_task(task_id) + if source.get('publisher') != publisher: + raise PermissionError('task owner mismatch') + task, revisions = latest_terms(session, source) + if task.get('status') not in {'open', 'in_progress', 'submitted'}: + raise ValueError('task not editable') + rows = session.query(Transaction).filter(Transaction.item_id == task_id, + Transaction.tx_type.like('TASK_%')).populate_existing().with_for_update().all() + if any(row.tx_type in {'TASK_REFUND', 'TASK_CANCEL_REFUND', 'TASK_PAYMENT', 'TASK_INCOME'} for row in rows): + raise ValueError('task funds already closed') + amount = funded_amount(rows, publisher) + if type(task.get('total_price')) is not int or task['total_price'] != amount: + raise ValueError('task price funding mismatch') + patch = json.loads(revisions[0].payload) if revisions else {} + for key in ('title', 'description', 'reference_images', 'reference_link'): + if key in changes: + patch[key] = changes[key] + financial = any(key in changes for key in ('total_price', 'deposit_ratio', 'deadline')) + if financial and (task.get('status') != 'open' or task.get('assignee') or any(row.tx_type == 'TASK_DEPOSIT' for row in rows)): + raise ValueError('task funding terms already assigned') + if financial: + price = changes.get('total_price', amount) + ratio = changes.get('deposit_ratio', task.get('deposit_ratio')) + if type(price) is not int or not 0 <= price <= 2147483647 or type(ratio) is not int or ratio not in {10,20,30,50}: + raise ValueError('invalid task terms') + deadline = datetime.date.fromisoformat(changes.get('deadline', task.get('deadline'))).isoformat() + if (wallet is None or type(wallet.balance) is not int or type(wallet.frozen_balance) is not int + or wallet.frozen_balance < amount): + raise ValueError('task frozen funds missing') + delta = price - amount + if delta > 0 and wallet.balance < delta: + raise ValueError('task balance insufficient') + wallet.balance -= delta + wallet.frozen_balance += delta + if delta: + tx_id = 'TASK_PRICE_ADJUST_' + uuid.uuid4().hex + last = session.query(Transaction).filter(Transaction.account == publisher).order_by( + Transaction.created_at.desc(), Transaction.tx_id.desc()).with_for_update().first() + previous = last.tx_hash if last else 'GENESIS_HASH' + digest = hashlib.sha256(f'{tx_id}{publisher}TASK_PRICE_ADJUST{-delta}{previous}'.encode()).hexdigest() + session.add(Transaction(tx_id=tx_id, account=publisher, tx_type='TASK_PRICE_ADJUST', amount=-delta, + item_id=task_id, item_title=task.get('title'), item_type='task', prev_hash=previous, + tx_hash=digest, description='任务价格调整')) + patch.update(total_price=price, frozen_amount=price, deposit_ratio=ratio, + deposit_amount=price*ratio//100, deadline=deadline) + revision = revisions[0].revision + 1 if revisions else 1 + session.add(TaskRevision(task_id=task_id, revision=revision, publisher=publisher, + payload=json.dumps(patch, ensure_ascii=False, allow_nan=False), projected=False)) + result = copy.deepcopy(task) + result.update(patch) + session.commit() + return result + except Exception: + session.rollback() + raise + + +def project_revisions(bind, store, limit=50, *, task_id=None): + with Session(bind=bind, autoflush=False) as lookup: + pending = scoped_pending(lookup.query(TaskRevision.task_id, TaskRevision.publisher).filter( + TaskRevision.projected.is_(False)), TaskRevision, task_id).distinct().limit(limit).all() + count = 0 + for task_id, publisher in pending: + with Session(bind=bind, autoflush=False) as session: + try: + lock_wallet_accounts(session, (publisher,)) + rows = session.query(TaskRevision).filter(TaskRevision.task_id == task_id).order_by( + TaskRevision.revision.desc()).populate_existing().with_for_update().all() + if not rows or rows[0].publisher != publisher: + raise ValueError('invalid task revision') + patch = json.loads(rows[0].payload) + validate_patch(patch) + if not isinstance(patch, dict): + raise ValueError('invalid task revision payload') + if 'total_price' in patch: + funds = session.query(Transaction).filter(Transaction.item_id == task_id, + Transaction.tx_type.in_(['TASK_FREEZE','TASK_PRICE_ADJUST'])).all() + if funded_amount(funds,publisher) != patch['total_price']: + raise ValueError('task revision funding mismatch') + def update(tasks): + matches = [task for task in tasks if isinstance(task, dict) and task.get('id') == task_id] + if len(matches) != 1 or matches[0].get('publisher') != publisher: + raise ValueError('task projection source mismatch') + matches[0].update(copy.deepcopy(patch)) + store.atomic_update('tasks.json', update, default_data=[]) + for row in rows: + row.projected = True + session.commit() + count += 1 + except Exception: + session.rollback() + raise + return count diff --git a/tip_store.py b/tip_store.py new file mode 100644 index 0000000000000000000000000000000000000000..22f0f7561601b4f48f8c421231791c72a04ea6d9 --- /dev/null +++ b/tip_store.py @@ -0,0 +1,64 @@ +"""SQL 打赏提交后的派生数据;失败不能改变已经提交的资金结果。""" +import copy +import logging + +import 数据库连接 as db + +logger = logging.getLogger(__name__) + + +def run_tip_effect(effect): + """各后续操作独立尝试,不重放资金操作,不输出账号或异常正文。""" + try: + effect() + return True + except Exception as error: + logger.warning("TIP_DERIVED_EFFECT_FAILED | error_type=%s", type(error).__name__) + return False + + +def _add_tip(record, sender, amount, anonymous, month): + # 先完整校验副本;损坏统计不能触发部分写入或静默归零。 + history = copy.deepcopy(record.get("tip_history", {})) + board = copy.deepcopy(record.get("tip_board", [])) + if not isinstance(history, dict) or any(type(value) is not int or value < 0 for value in history.values()): + raise ValueError("invalid tip history") + if not isinstance(board, list) or any(not isinstance(entry, dict) + or type(entry.get("amount")) is not int or entry["amount"] < 0 for entry in board): + raise ValueError("invalid tip board") + account = None if anonymous else sender + entry = next((item for item in board if item.get("account") == account + and bool(item.get("is_anon")) == anonymous), None) + if entry is None: + board.append({"amount": amount, "is_anon": anonymous, "account": account}) + else: + entry["amount"] += amount + history[month] = history.get(month, 0) + amount + board.sort(key=lambda item: item["amount"], reverse=True) + record["tip_history"] = history + record["tip_board"] = board + + +def update_tip_projection(filename, target, sender, amount, anonymous, month): + """仅更新锁内最新记录的打赏字段;文件间不构成 SQL/JSON 事务。""" + if filename not in {"users.json", "items.json"} or type(amount) is not int or amount <= 0: + raise ValueError("invalid tip projection") + if type(anonymous) is not bool or not isinstance(month, str) or not month: + raise ValueError("invalid tip projection") + + def updater(data): + if filename == "users.json": + if not isinstance(data, dict): + raise ValueError("invalid users") + record = data.get(target) + if not isinstance(record, dict) or record.get("account", target) != target: + raise ValueError("invalid user") + else: + if not isinstance(data, list): + raise ValueError("invalid items") + record = next((item for item in data if isinstance(item, dict) and item.get("id") == target), None) + if record is None: + raise ValueError("missing item") + _add_tip(record, sender, amount, anonymous, month) + + return db.atomic_update(filename, updater, default_data={} if filename == "users.json" else []) diff --git a/tip_transaction.py b/tip_transaction.py new file mode 100644 index 0000000000000000000000000000000000000000..54b200dbc635023e7a6571a4d161afbac8b6397c --- /dev/null +++ b/tip_transaction.py @@ -0,0 +1,50 @@ +"""打赏事务先串行化钱包访问,再检查重复流水;不自行提交或重试扣款。""" +from sqlalchemy.dialects.sqlite import insert as sqlite_insert +from sqlalchemy.dialects.postgresql import insert as postgres_insert +from sqlalchemy.dialects.mysql import insert as mysql_insert + +from models_sql import Wallet + + +def lock_wallet_accounts(session, accounts, create_accounts=()): + """按稳定顺序锁钱包;创建集合只补缺失记录,不覆盖余额。""" + accounts, create_accounts = set(accounts), set(create_accounts) + if not accounts or any(not isinstance(account, str) or not account.strip() for account in accounts) or not create_accounts <= accounts: + raise ValueError("invalid wallet accounts") + dialect = session.get_bind().dialect.name + if dialect == "sqlite": + # SQLite 忽略 FOR UPDATE;读余额/重复流水之前必须取得写事务。 + session.connection().exec_driver_sql("BEGIN IMMEDIATE") + elif dialect not in {"postgresql", "mysql"}: + raise RuntimeError("unsupported wallet dialect") + + wallets = {} + # 相反方向的打赏亦使用相同锁顺序,避免各持有一方钱包等待另一方。 + for account in sorted(accounts): + query = session.query(Wallet).filter(Wallet.account == account) + wallet = query.populate_existing().with_for_update().first() + if wallet is None and account in create_accounts: + values = {"account": account, "balance": 0, "earn_balance": 0, "tip_balance": 0, + "task_balance": 0, "frozen_balance": 0, "version": 1} + if dialect == "sqlite": + statement = sqlite_insert(Wallet).values(**values).on_conflict_do_nothing(index_elements=[Wallet.account]) + elif dialect == "postgresql": + statement = postgres_insert(Wallet).values(**values).on_conflict_do_nothing(index_elements=[Wallet.account]) + else: + statement = mysql_insert(Wallet).values(**values) + statement = statement.on_duplicate_key_update(account=statement.inserted.account) + session.execute(statement) + # 另一打赏可能已创建此钱包;读取最新余额,绝不覆盖为零。 + wallet = query.populate_existing().with_for_update().first() + if wallet is None: + raise RuntimeError("recipient wallet unavailable") + wallets[account] = wallet + return wallets + + +def lock_tip_wallets(session, sender, recipient): + """要求调用方传入尚未执行 SQL 的业务 Session,事务由调用方结束。""" + if sender == recipient: + raise ValueError("self tip") + wallets = lock_wallet_accounts(session, (sender, recipient), (recipient,)) + return wallets[sender], wallets[recipient] diff --git a/upload_body_guard.py b/upload_body_guard.py new file mode 100644 index 0000000000000000000000000000000000000000..273bb5745cda4ebb64c9cbda9d163b4c1c96939a --- /dev/null +++ b/upload_body_guard.py @@ -0,0 +1,101 @@ +"""Upload admission before multipart parsing: aggregate bytes, capacity and receive time.""" +import asyncio +import math +import re +import threading +import time +from starlette.formparsers import MultiPartException +from starlette.responses import JSONResponse + +UPLOAD_REQUEST_MAX_BYTES = 100 * 1024 * 1024 + 64 * 1024 +UPLOAD_RECEIVE_IDLE_SECONDS = 30 +UPLOAD_RECEIVE_TOTAL_SECONDS = 300 + +class _UploadBodyRejected(MultiPartException): + pass + +class UploadBodyGuard: + def __init__(self, app, *, capacity=4, max_bytes=UPLOAD_REQUEST_MAX_BYTES, + idle_seconds=UPLOAD_RECEIVE_IDLE_SECONDS, total_seconds=UPLOAD_RECEIVE_TOTAL_SECONDS, + error_response=None): + if type(capacity) is not int or not 1 <= capacity <= 16 or type(max_bytes) is not int or max_bytes <= 0: + raise ValueError('invalid upload admission budget') + if any(type(value) not in (int, float) or not math.isfinite(value) or value <= 0 + for value in (idle_seconds, total_seconds)): + raise ValueError('invalid upload receive budget') + self.app, self.max_bytes = app, max_bytes + self.idle_seconds, self.total_seconds = idle_seconds, total_seconds + self.slots = threading.BoundedSemaphore(capacity) + self.error_response = error_response + + async def reject(self, scope, receive, send, status, detail, category): + content = (self.error_response(status, detail, category) if self.error_response else + {'success': False, 'status': 'error', 'code': status, 'detail': detail, 'type': category}) + await JSONResponse(content, status_code=status, headers={'Cache-Control': 'no-store', + 'Vary': 'Authorization'})(scope, receive, send) + + async def __call__(self, scope, receive, send): + if scope['type'] != 'http' or scope.get('method') != 'POST' or scope.get('path', '').rstrip('/') != '/api/upload': + return await self.app(scope, receive, send) + headers = scope.get('headers', []) + lengths = [value.strip() for name, value in headers if name.lower() == b'content-length'] + if lengths and (len(lengths) != 1 or re.fullmatch(rb'[0-9]{1,20}', lengths[0]) is None or + any(name.lower() == b'transfer-encoding' for name, _ in headers)): + return await self.reject(scope, receive, send, 400, '上传请求长度无效', 'bad_request') + if lengths and int(lengths[0]) > self.max_bytes: + return await self.reject(scope, receive, send, 413, '上传请求体积超过限制', 'payload_too_large') + if not self.slots.acquire(blocking=False): + return await self.reject(scope, receive, send, 429, '当前上传较多,请稍后重试', 'rate_limited') + received = 0 + complete, started, failure = False, False, None + cancelled = None + deadline = time.monotonic() + self.total_seconds + def fail(status, detail, category): + nonlocal failure + if failure is None: + failure = (status, detail, category) + # Starlette closes partial multipart files for MultiPartException. + raise _UploadBodyRejected(failure[1]) + async def guarded_receive(): + nonlocal received, complete, cancelled + if failure is not None: + return {'type': 'http.disconnect'} + if complete: + return await receive() + remaining = deadline - time.monotonic() + if remaining <= 0: + fail(408, '上传接收超时,请稍后重试', 'request_timeout') + try: + message = await asyncio.wait_for(receive(), min(self.idle_seconds, remaining)) + except asyncio.CancelledError as error: + cancelled = error + fail(408, '上传接收已取消', 'request_cancelled') + except asyncio.TimeoutError: + fail(408, '上传接收超时,请稍后重试', 'request_timeout') + if message['type'] == 'http.request': + received += len(message.get('body', b'')) + if received > self.max_bytes: + fail(413, '上传请求体积超过限制', 'payload_too_large') + complete = not message.get('more_body', False) + elif message['type'] == 'http.disconnect': + fail(400, '上传连接已中断', 'bad_request') + return message + async def guarded_send(message): + nonlocal started + if failure is not None and not started: + return + if message['type'] == 'http.response.start': + started = True + await send(message) + try: + try: + await self.app(scope, guarded_receive, guarded_send) + except Exception: + if failure is None or started: + raise + if cancelled is not None: + raise cancelled + if failure is not None and not started: + await self.reject(scope, receive, send, *failure) + finally: + self.slots.release() diff --git a/upload_service.py b/upload_service.py new file mode 100644 index 0000000000000000000000000000000000000000..c2171e084cde6188d2710028d92d84139c683aff --- /dev/null +++ b/upload_service.py @@ -0,0 +1,248 @@ +"""上传服务:类别/流式限额/签名、媒体处理、HF保存及临时文件收尾。""" +import hashlib +import logging +import os +import subprocess +import tempfile +import time +import uuid +from image_file_budget import inspect_image_file, ImageBudgetError +from deployment_config import PUBLIC_API_BASE_URL +from fastapi import HTTPException +from fastapi.responses import JSONResponse + +logger = logging.getLogger("ComfyUI-Ranking") + +AUDIO_TRANSCODE_ARGS = { + "mp3": ["-acodec", "libmp3lame", "-b:a", "192k"], + "wav": ["-acodec", "pcm_s16le", "-ar", "44100"], + "m4a": ["-acodec", "aac", "-b:a", "192k"], + "ogg": ["-acodec", "libvorbis", "-b:a", "192k"], +} + +AUDIO_MAX_BYTES = 30 * 1024 * 1024 +AUDIO_TIMEOUT = 120 +AUDIO_DEMUXERS = {"mp3": "mp3", "wav": "wav", "m4a": "mov", "ogg": "ogg"} + + +def sanitize_audio_file(source_path: str, ext: str, max_size: int): + """转码所属临时文件;超限或失败不替换输入,不把音频读回Python内存。""" + if ext not in AUDIO_TRANSCODE_ARGS or type(max_size) is not int or not 0 < max_size <= AUDIO_MAX_BYTES: + raise HTTPException(status_code=400, detail="音频处理参数无效") + fd, output_path = tempfile.mkstemp(prefix="ranking_audio_", suffix=f".{ext}", + dir=os.path.dirname(os.path.abspath(source_path))) + try: + os.close(fd) + cmd = ["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", "-y", + "-protocol_whitelist", "file", "-f", AUDIO_DEMUXERS[ext], "-i", source_path, + "-map", "0:a:0", "-vn", "-sn", "-dn", "-map_metadata", "-1", + "-map_chapters", "-1", "-fflags", "+bitexact"] \ + + AUDIO_TRANSCODE_ARGS[ext] + ["-fs", str(max_size + 1), output_path] + try: + result = subprocess.run(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, timeout=AUDIO_TIMEOUT) + except subprocess.TimeoutExpired: + logger.error("AUDIO_SANITIZE_TIMEOUT | ext=%s", ext) + raise HTTPException(status_code=400, detail="音频处理超时,请上传更短的音频") from None + if result.returncode != 0: + logger.error("AUDIO_SANITIZE_FAIL | ext=%s | returncode=%s", ext, result.returncode) + raise HTTPException(status_code=400, detail="音频文件解析失败,请转换格式后重新上传") + size = os.path.getsize(output_path) + if size == 0: + raise HTTPException(status_code=400, detail="音频处理结果为空,请重新上传") + if size > max_size: + raise HTTPException(status_code=400, detail=f"音频处理后仍超过 {max_size // (1024*1024)}MB 限制,请上传更短的音频") + os.replace(output_path, source_path) + logger.info("AUDIO_SANITIZE_OK | ext=%s | size=%s", ext, size) + finally: + try: + os.remove(output_path) + except FileNotFoundError: + pass + except OSError as error: + logger.warning("音频临时文件清理失败: %s", type(error).__name__) + + +from workflow_upload import WORKFLOW_UPLOAD_TYPES, validate_workflow_file +from workflow_source import register_workflow_upload + +def _validate_upload_signature(path: str, ext: str) -> bool: + """Validate media signatures or a bounded workflow JSON object.""" + if ext == "json": + return validate_workflow_file(path) + with open(path, "rb") as source: + header = source.read(16) + if ext in {"jpg", "jpeg"}: + return header.startswith(b"\xff\xd8\xff") + if ext == "png": + return header.startswith(b"\x89PNG\r\n\x1a\n") + if ext == "gif": + return header.startswith((b"GIF87a", b"GIF89a")) + if ext == "webp": + return header.startswith(b"RIFF") and header[8:12] == b"WEBP" + if ext in {"mp4", "mov", "m4a"}: + return len(header) >= 12 and header[4:8] == b"ftyp" + if ext == "webm": + return header.startswith(b"\x1a\x45\xdf\xa3") + if ext == "wav": + return header.startswith(b"RIFF") and header[8:12] == b"WAVE" + if ext == "ogg": + return header.startswith(b"OggS") + if ext == "mp3": + return header.startswith(b"ID3") or ( + len(header) >= 2 and header[0] == 0xff and (header[1] & 0xe0) == 0xe0 + ) + return False + + +def process_upload(file, file_type, current_user, *, store, storage_factory, moderate, limiter, error_response): + db, HfApi, moderate_image_sync = store, storage_factory, moderate + _UPLOAD_SEMAPHORE, create_error_response = limiter, error_response + # 上传类别同时决定存储路径和审核策略,必须先做精确枚举校验。 + allowed_extensions_by_type = { + "app": {"json"}, + "tool": {"json"}, + "recommend": {"json"}, + "avatar": {"jpg", "jpeg", "png", "webp"}, + "cover": {"jpg", "jpeg", "png", "gif", "webp"}, + "banner": {"jpg", "jpeg", "png", "gif", "webp"}, + "post": {"jpg", "jpeg", "png", "gif", "webp"}, + "task": {"jpg", "jpeg", "png", "gif", "webp"}, + "dispute": {"jpg", "jpeg", "png", "gif", "webp"}, + "post_video": {"mp4", "webm", "mov"}, + "post_audio": {"mp3", "wav", "m4a", "ogg"}, + } + if file_type not in allowed_extensions_by_type: + raise HTTPException(status_code=400, detail="不支持的上传类型") + + if not file.filename: + raise HTTPException(status_code=400, detail="文件名不能为空") + ext = file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else "" + allowed_exts = allowed_extensions_by_type[file_type] + if ext not in allowed_exts: + raise HTTPException(status_code=400, detail=f"不支持的文件格式,{file_type} 类型仅支持 {', '.join(sorted(allowed_exts))}") + + # 🟢 动态文件大小风控 + # 🔒 先确定每种类型的大小上限;上传时逐块累计并立即中止超限请求。 + max_size = 10 * 1024 * 1024 + if file_type == "avatar": + max_size = 2 * 1024 * 1024 + elif file_type == "cover": + # GIF 动图允许更大文件(前端不压缩,保留动画) + if file.filename and file.filename.lower().endswith('.gif'): + max_size = 50 * 1024 * 1024 # GIF: 50MB + else: + max_size = 5 * 1024 * 1024 # 其他图片: 5MB + elif file_type == "banner": + # 广告横幅图片:支持GIF动图 + if file.filename and file.filename.lower().endswith('.gif'): + max_size = 50 * 1024 * 1024 # GIF: 50MB + else: + max_size = 10 * 1024 * 1024 # 其他图片: 10MB + elif file_type == "post_video": + max_size = 100 * 1024 * 1024 + elif file_type == "post_audio": + max_size = AUDIO_MAX_BYTES # 音乐提示词音频:30MB + + # 视频等大文件直接流式写入临时文件,避免把整个请求体保存在 Python 内存。 + if not _UPLOAD_SEMAPHORE.acquire(blocking=False): + raise HTTPException(status_code=429, detail="当前上传较多,请稍后重试") + _CHUNK_SIZE = 1024 * 1024 + _received = 0 + try: + fd, local_tmp_path = tempfile.mkstemp(prefix="ranking_upload_", suffix=f".{ext}", dir="/tmp") + except Exception: + _UPLOAD_SEMAPHORE.release() + raise + try: + digest = hashlib.sha256() + with os.fdopen(fd, "wb") as tmp_file: + while True: + chunk = file.file.read(_CHUNK_SIZE) + if not chunk: + break + _received += len(chunk) + if _received > max_size: + raise HTTPException(status_code=400, detail=f"文件过大,{file_type} 类型请限制在 {max_size // (1024*1024)}MB 以内") + tmp_file.write(chunk) + digest.update(chunk) + + if not _validate_upload_signature(local_tmp_path, ext): + raise HTTPException(status_code=400, detail="文件内容与所选格式不匹配") + + # 音频和视频保持文件路径;仅图片审核需要有上限的完整内容。 + if file_type == "post_audio": + sanitize_audio_file(local_tmp_path, ext, max_size) + elif ext in {"jpg", "jpeg", "png", "gif", "webp"}: + try: + inspect_image_file(local_tmp_path, ext) + except ImageBudgetError: + raise HTTPException(status_code=400, detail="图片尺寸、动画或文件结构不符合上传限制") from None + with open(local_tmp_path, "rb") as source: + content = source.read() + try: + moderation_result = moderate_image_sync(content, ext) + finally: + # HF只读取临时文件;审核结束即撤销本服务对整份图片的引用。 + del content + if not moderation_result.passed: + raise HTTPException(status_code=400, detail=f"图片内容审核未通过:{moderation_result.label or '内容不合规'}") + del moderation_result + + logger.info(f"UPLOAD | file_type={file_type} | size={os.path.getsize(local_tmp_path)}") + if file_type in WORKFLOW_UPLOAD_TYPES or file_type in ("post_video", "post_audio"): + safe_filename = f"{file_type}_{uuid.uuid4().hex}.{ext}" + else: + safe_filename = f"{file_type}_{digest.hexdigest()}.{ext}" + + hf_token = os.environ.get("HF_TOKEN") + # 上传与 image_proxy/工作流下载统一使用数据层的仓库配置。 + dataset_repo_id = db.DATASET_REPO_ID + api = HfApi() + is_workflow = file_type in WORKFLOW_UPLOAD_TYPES + storage_path = f"workflows/{file_type}/{safe_filename}" if is_workflow else f"uploads/{file_type}/{safe_filename}" + if is_workflow: + if not hf_token: + raise HTTPException(status_code=503, detail="工作流存储暂不可用") + repository = api.repo_info(repo_id=dataset_repo_id, repo_type="dataset", token=hf_token) + if getattr(repository, "private", None) is not True: + raise HTTPException(status_code=503, detail="工作流需要私有存储") + api.upload_file( + path_or_fileobj=local_tmp_path, + path_in_repo=storage_path, + repo_id=dataset_repo_id, + repo_type="dataset", + token=hf_token, + commit_message=f"Upload media: {safe_filename}" + ) + if is_workflow: + register_workflow_upload(db, storage_path, current_user, dataset_repo_id) + except HTTPException: + raise + except Exception as e: + error_id = f"ERR_{int(time.time())}_{uuid.uuid4().hex[:8]}" + logger.error(f"UPLOAD_SYNC_FAILED | error_id={error_id} | file_type={file_type} | error_type={type(e).__name__}") + return JSONResponse( + status_code=502, + content=create_error_response(502, "文件暂时无法保存,请稍后重试", "upstream_error", error_id=error_id), + ) + finally: + if os.path.exists(local_tmp_path): + try: + os.remove(local_tmp_path) + except OSError as cleanup_error: + logger.warning("上传临时文件清理失败: %s", type(cleanup_error).__name__) + _UPLOAD_SEMAPHORE.release() + + # 🚀 核心修复:不再返回暴露隐私且报 401 的 HF 直链,而是返回我们刚写好的 Proxy 代理链接 + if file_type in WORKFLOW_UPLOAD_TYPES: + # 工作流不经过无认证的图片代理;现有下载代理先核查资源所有权。 + permanent_url = f"https://huggingface.co/datasets/{dataset_repo_id}/resolve/main/{storage_path}" + elif file_type == "post_video": + # 视频文件返回相对路径,由前端 proxyImages 统一处理 + # 前端会将其转换为 /community_hub/video 本地视频代理(支持 Range 请求和流式传输) + permanent_url = f"uploads/{file_type}/{safe_filename}" + else: + permanent_url = f"{PUBLIC_API_BASE_URL}/api/image_proxy?path=uploads/{file_type}/{safe_filename}" + return {"status": "success", "url": permanent_url} + diff --git a/user_store.py b/user_store.py new file mode 100644 index 0000000000000000000000000000000000000000..9ad4bc6dd65ac84a276d2bd14fc4087754e43dab --- /dev/null +++ b/user_store.py @@ -0,0 +1,186 @@ +"""资料、隐私、关注、注册与密码只修改锁内读取的最新用户记录。""" +import copy +import logging +import time + +from fastapi import HTTPException +import 数据库连接 as db + +logger = logging.getLogger(__name__) +PROFILE_FIELDS = frozenset({"account", "name", "avatar", "avatarDataUrl", "bannerUrl", "intro", "gender", "age", + "country", "region", "created_at", "followers", "following", "privacy", "is_admin", + "receivedLikes", "receivedFavorites", "receivedUses"}) +OWNER_FIELDS = frozenset({"email", "phone", "birthday"}) +EDITABLE_FIELDS = frozenset({"name", "intro", "age", "birthday", "country", "region", "gender", "avatarDataUrl", "bannerUrl"}) +PRIVACY_FIELDS = frozenset({"follows", "likes", "favorites", "downloads"}) + + +def project_profile(record, owner=False): + fields = PROFILE_FIELDS | OWNER_FIELDS if owner else PROFILE_FIELDS + return copy.deepcopy({key: value for key, value in record.items() if key in fields}) + + +def _user(data, account): + if not isinstance(data, dict): + raise HTTPException(status_code=503, detail="用户数据暂不可用") + if account not in data: + raise HTTPException(status_code=404, detail="用户不存在") + record = data[account] + if not isinstance(record, dict) or ("account" in record and record["account"] != account): + raise HTTPException(status_code=503, detail="用户数据暂不可用") + return record + + +def _mutate(updater): + try: + return db.atomic_update("users.json", updater, default_data={}) + except HTTPException: + raise + except Exception as error: + logger.warning("用户资料写入失败: %s", type(error).__name__) + raise HTTPException(status_code=503, detail="用户数据暂不可用") from None + + +def read_user(account): + try: + data = db.load_data("users.json", default_data={}, strict=True) + return copy.deepcopy(_user(data, account)) + except HTTPException: + raise + except Exception as error: + logger.warning("用户资料读取失败: %s", type(error).__name__) + raise HTTPException(status_code=503, detail="用户数据暂不可用") from None + + +def attach_profile_counts(record, items): + """主内容记录可读时才校对;损坏项不参与统计,不修改用户源记录。""" + result = copy.deepcopy(record) + if not isinstance(items, list): + return result + authored = [item for item in items if isinstance(item, dict) and item.get("author") == record.get("account")] + for field, source in (("receivedLikes", "likes"), ("receivedFavorites", "favorites"), ("receivedUses", "uses")): + result[field] = sum(item[source] for item in authored if type(item.get(source)) is int and item[source] >= 0) + return result + + +def update_profile(account, updates): + # 路由已经过 UserUpdate 校验;这里再限定字段,避免未来调用方扩散写入范围。 + patch = copy.deepcopy({key: value for key, value in updates.items() + if key in EDITABLE_FIELDS and value is not None}) + if isinstance(patch.get("avatarDataUrl"), str) and patch["avatarDataUrl"].startswith("data:"): + del patch["avatarDataUrl"] + + def updater(data): + record = _user(data, account) + record.update(patch) + return project_profile(record, owner=True) + return _mutate(updater) + + +def update_user_privacy(account, privacy): + # 保持 PrivacySettings 的完整替换语义,而非隐式合并部分设置。 + if not isinstance(privacy, dict) or any(key not in PRIVACY_FIELDS or type(value) is not bool + for key, value in privacy.items()): + raise HTTPException(status_code=400, detail="隐私设置不正确") + settings = copy.deepcopy(privacy) + + def updater(data): + _user(data, account)["privacy"] = settings + _mutate(updater) + + +def _accounts(record, field): + value = record.get(field, []) + if not isinstance(value, list) or any(not isinstance(item, str) or not item for item in value): + raise HTTPException(status_code=503, detail="用户关注数据暂不可用") + return list(value) + + +def set_follow(account, target_account, active): + def updater(data): + current = _user(data, account) + target = _user(data, target_account) + # 先验证双方,任何一边损坏都不能部分更改另一边。 + following = _accounts(current, "following") + followers = _accounts(target, "followers") + added = active and account not in followers + if active: + if target_account not in following: + following.append(target_account) + if account not in followers: + followers.append(account) + else: + following = [value for value in following if value != target_account] + followers = [value for value in followers if value != account] + current["following"] = following + target["followers"] = followers + return bool(added) + return _mutate(updater) + + +def get_password_version(record): + version = record.get("password_version", 0) + if type(version) is not int or version < 0: + raise HTTPException(status_code=503, detail="用户认证数据暂不可用") + return version + + +def _registration_available(data, account, email, phone): + if not isinstance(data, dict): + raise HTTPException(status_code=503, detail="用户数据暂不可用") + if account in data: + raise HTTPException(status_code=400, detail="该账号已被注册,请更换一个") + for key in data: + existing = _user(data, key) + if email and existing.get("email") == email: + raise HTTPException(status_code=400, detail="此邮箱已注册,请直接登录或找回密码") + if phone and existing.get("phone") == phone: + raise HTTPException(status_code=400, detail="该手机号已被绑定") + + +def check_registration(account, email, phone): + try: + data = db.load_data("users.json", default_data={}, strict=True) + _registration_available(data, account, email, phone) + except HTTPException: + raise + except Exception as error: + logger.warning("用户注册检查失败: %s", type(error).__name__) + raise HTTPException(status_code=503, detail="用户数据暂不可用") from None + + +def create_user(record): + incoming = copy.deepcopy(record) + def updater(data): + _registration_available(data, incoming["account"], incoming.get("email"), incoming.get("phone")) + data[incoming["account"]] = incoming + return project_profile(incoming, owner=True) + return _mutate(updater) + + +def finish_login(account, expected_password, expected_version, migrated_password=None): + def check(record): + if record.get("password", "") != expected_password or get_password_version(record) != expected_version: + raise HTTPException(status_code=401, detail="登录信息已变化,请重新登录") + return record + if migrated_password is None: + return check(read_user(account)) + def updater(data): + record = check(_user(data, account)) + record["password"] = migrated_password + return copy.deepcopy(record) + return _mutate(updater) + + +def reset_user_password(account, contact_type, contact, password_hash): + if contact_type not in ("email", "phone"): + raise HTTPException(status_code=400, detail="联系方式类型不正确") + def updater(data): + record = _user(data, account) + if record.get(contact_type) != contact: + raise HTTPException(status_code=400, detail="填写的联系方式与该账号绑定信息不匹配") + version = max(int(time.time()), get_password_version(record) + 1) + record["password"] = password_hash + record["password_version"] = version + return version + return _mutate(updater) diff --git a/verify_code_engine.py b/verify_code_engine.py index 02155467d7998376c04413db02c6a7449724d900..6120cda1d8ace84398b963d717defce04e9c1001 100644 --- a/verify_code_engine.py +++ b/verify_code_engine.py @@ -2,10 +2,10 @@ # ========================================== # 📧 验证码发送引擎 # ========================================== -# 作用:提供验证码内存缓存与多渠道发送能力(邮件/短信) +# 作用:提供 SQL 验证码状态与多渠道发送能力(邮件/短信) # 关联文件: # - router_users_auth.py (调用此模块发送验证码) -# - router_wallet.py (提现时也会调用验证码缓存) +# - router_wallet.py (提现时也会消费 SQL 验证码) # ========================================== import os @@ -16,17 +16,9 @@ import urllib.parse import base64 from fastapi import HTTPException +from rate_limit import reserve_contact_cooldown -# ========================================== -# 验证码内存缓存 (全局共享字典) -# ========================================== -# 作用:存储所有待验证的验证码,格式为 {key: {code, expires_at}} -# 关联:router_users_auth.py 和 router_wallet.py 都会读写此字典 -# 注意:服务重启后缓存会清空,生产环境建议改用 Redis -VERIFY_CODES = {} - -# 🔒 发送频率限制配置 -SEND_COOLDOWN = {} # {contact: last_send_timestamp} +# 🔒 发送频率限制配置(SQL 共享状态,只保存联系方式哈希) COOLDOWN_SECONDS = 60 # 同一联系方式60秒内最多发送1条验证码 # 🚀 P1性能优化:记录上次清理时间,避免频繁清理 @@ -69,50 +61,18 @@ def check_send_cooldown(contact: str): 抛出: - HTTPException: 如果在冷却期内,返回429状态码 """ - now = time.time() - last_send = SEND_COOLDOWN.get(contact, 0) - if now - last_send < COOLDOWN_SECONDS: - remaining = int(COOLDOWN_SECONDS - (now - last_send)) + remaining = reserve_contact_cooldown(contact, COOLDOWN_SECONDS) + if remaining > 0: raise HTTPException(status_code=429, detail=f"请等待{remaining}秒后再发送验证码") - SEND_COOLDOWN[contact] = now def cleanup_expired_codes(): - """ - 🚀 P1性能优化:清理过期验证码,防止内存泄漏 - 🔒 同时清理过期的发送频率限制记录 - """ - global _last_cleanup_time - now = time.time() - - # 限制清理频率,避免每次请求都清理 - if now - _last_cleanup_time < _CLEANUP_INTERVAL: - return - - _last_cleanup_time = now - - # 收集过期的验证码键 - expired_keys = [ - key for key, data in VERIFY_CODES.items() - if now > data.get("expires_at", data.get("expires", 0)) - ] - - # 删除过期验证码 - for key in expired_keys: - del VERIFY_CODES[key] - - # 🔒 清理过期的发送频率限制记录,防止内存泄漏 - expired_cooldown_contacts = [ - contact for contact, last_send in SEND_COOLDOWN.items() - if now - last_send >= COOLDOWN_SECONDS - ] - for contact in expired_cooldown_contacts: - del SEND_COOLDOWN[contact] - - total_cleaned = len(expired_keys) + len(expired_cooldown_contacts) - if total_cleaned > 0: - print(f"🧹 已清理 {len(expired_keys)} 个过期验证码,{len(expired_cooldown_contacts)} 个过期冷却记录,当前缓存: {len(VERIFY_CODES)} 条验证码,{len(SEND_COOLDOWN)} 条冷却记录") - + """验证码已存入 SQL;清理由限流模块统一按时间间隔执行。""" + global _last_cleanup_time + now = time.time() + if now - _last_cleanup_time < _CLEANUP_INTERVAL: + return + _last_cleanup_time = now def send_email_code(to_email: str, code: str, action: str): """ @@ -176,7 +136,11 @@ def send_email_code(to_email: str, code: str, action: str): # 🔒 安全修复:不再打印验证码与邮箱明文(日志对外可见),仅留脱敏标识与动作类型 print(f"✅ 成功触发 Webhook 发送验证码邮件至 {_mask_contact(to_email)}(动作: {action})") except Exception as e: - print(f"❌ Webhook 触发失败: {e}") + status = getattr(getattr(e, "response", None), "status_code", None) + if not isinstance(status, int): + status = getattr(e, "code", None) + status_text = f", status={status}" if isinstance(status, int) and 100 <= status <= 599 else "" + print(f"❌ Webhook 触发失败: action={action}, target={_mask_contact(to_email)}, error_type={type(e).__name__}{status_text}") def send_sms_code(phone: str, code: str, action: str): @@ -226,7 +190,11 @@ def send_sms_code(phone: str, code: str, action: str): with urllib.request.urlopen(req, timeout=10) as response: print(f"✅ Twilio 短信已成功下发至 {_mask_contact(phone)}") except Exception as e: - print(f"❌ Twilio 发送失败: {e}") + status = getattr(getattr(e, "response", None), "status_code", None) + if not isinstance(status, int): + status = getattr(e, "code", None) + status_text = f", status={status}" if isinstance(status, int) and 100 <= status <= 599 else "" + print(f"❌ Twilio 发送失败: target={_mask_contact(phone)}, error_type={type(e).__name__}{status_text}") return # ========================================== @@ -267,9 +235,10 @@ def send_sms_code(phone: str, code: str, action: str): if response.body.code == "OK": print(f"✅ 阿里云短信已成功下发至 {_mask_contact(phone)}") else: - print(f"❌ 阿里云下发失败: {response.body.message}") + response_code = getattr(response.body, "code", "unknown") + print(f"❌ 阿里云下发失败: target={_mask_contact(phone)}, provider_code={response_code}") except Exception as e: - print(f"❌ 阿里云请求异常: {e}") + print(f"❌ 阿里云请求异常: target={_mask_contact(phone)}, error_type={type(e).__name__}") return # ========================================== diff --git a/workflow_decode.py b/workflow_decode.py new file mode 100644 index 0000000000000000000000000000000000000000..bd558bac76e07bcb1fbb2d0e3d713218c7bb934c --- /dev/null +++ b/workflow_decode.py @@ -0,0 +1,114 @@ +"""工作流HTTP正文的有界流式解码;不使用HTTPX先整块自动解压。""" +import asyncio +import re +import zlib + +CHUNK_BYTES = 64 * 1024 +MAX_GZIP_MEMBERS = 64 + + +class WorkflowDecodeError(RuntimeError): + pass + + +class WorkflowSizeError(WorkflowDecodeError): + pass + + +def workflow_response_length(headers, *, max_bytes): + """只接受有界ASCII十进制声明;缺失长度仍由流式双预算保护。""" + if type(max_bytes) is not int or max_bytes <= 0: + raise WorkflowDecodeError("invalid workflow byte budget") + value = headers.get('content-length') + if value is None: + return None + if not isinstance(value, str) or not re.fullmatch(r'[0-9]{1,20}', value): + raise WorkflowDecodeError("invalid workflow content length") + length = int(value) + if length <= 0: + raise WorkflowDecodeError("invalid workflow content length") + if length > max_bytes: + raise WorkflowSizeError("proxy download exceeded size limit") + return length + + +def workflow_encoding(value): + if value is not None and not isinstance(value, str): + raise WorkflowDecodeError("unsupported workflow content encoding") + encoding = value.strip().lower() if isinstance(value, str) else "identity" + if encoding == "": + encoding = "identity" + if encoding not in {"identity", "gzip", "deflate"}: + raise WorkflowDecodeError("unsupported workflow content encoding") + return encoding + + +async def stream_workflow_bytes(response, *, max_bytes, encoding, length=None): + """原始和解码字节分别计数;每次zlib输出至多64KiB,无无界flush。""" + if type(max_bytes) is not int or max_bytes <= 0: + raise WorkflowDecodeError("invalid workflow byte budget") + if length is not None and (type(length) is not int or not 0 < length <= max_bytes): + raise WorkflowDecodeError("invalid workflow expected length") + encoding = workflow_encoding(encoding) + raw_total = decoded_total = 0 + inflater = None + members = 0 + prefix = b"" + try: + async for raw_chunk in response.aiter_raw(chunk_size=CHUNK_BYTES): + raw_total += len(raw_chunk) + if raw_total > max_bytes: + raise WorkflowDecodeError("proxy download exceeded size limit") + if length is not None and raw_total > length: + raise WorkflowDecodeError("workflow response length mismatch") + view = memoryview(raw_chunk) + for offset in range(0, len(view), CHUNK_BYTES): + pending = view[offset:offset + CHUNK_BYTES] + if encoding == "identity": + decoded_total += len(pending) + if decoded_total > max_bytes: + raise WorkflowDecodeError("proxy download exceeded size limit") + await asyncio.sleep(0) + yield bytes(pending) + continue + if encoding == "gzip" and inflater is None: + inflater = zlib.decompressobj(31) + members = 1 + if encoding == "deflate" and inflater is None: + pending = prefix + bytes(pending) + if len(pending) < 2: + prefix = pending + continue + prefix = b"" + first, second = pending[0], pending[1] + wrapped = (first & 15 == 8 and first >> 4 <= 7 and (first * 256 + second) % 31 == 0) + inflater = zlib.decompressobj(15 if wrapped else -15) + while True: + if inflater.eof: + if not pending: + break + if encoding != "gzip" or members >= MAX_GZIP_MEMBERS: + raise WorkflowDecodeError("unexpected compressed workflow data") + members += 1 + inflater = zlib.decompressobj(31) + limit = min(CHUNK_BYTES, max_bytes - decoded_total + 1) + output = inflater.decompress(pending, limit) + decoded_total += len(output) + if decoded_total > max_bytes: + raise WorkflowDecodeError("proxy download exceeded size limit") + if output: + await asyncio.sleep(0) + yield output + pending = inflater.unused_data if inflater.eof else inflater.unconsumed_tail + if pending: + continue + if len(output) == limit and not inflater.eof: + pending = b"" + continue + break + if length is not None and raw_total != length: + raise WorkflowDecodeError("workflow response length mismatch") + if encoding != "identity" and (inflater is None or not inflater.eof): + raise WorkflowDecodeError("incomplete compressed workflow data") + except zlib.error: + raise WorkflowDecodeError("invalid compressed workflow data") from None diff --git a/workflow_download_service.py b/workflow_download_service.py new file mode 100644 index 0000000000000000000000000000000000000000..d7af0949263c9481597274068cce8892418c53b7 --- /dev/null +++ b/workflow_download_service.py @@ -0,0 +1,111 @@ +"""已授权工作流的可信来源、逐跳请求、有界解码与所属交付服务。""" +import asyncio +import os +from urllib.parse import urlparse, urljoin +from workflow_decode import workflow_encoding, stream_workflow_bytes, workflow_response_length, WorkflowSizeError +from workflow_stream_response import OwnedWorkflowResponse +from proxy_resources import ProxyResources + + +async def prepare_workflow_download(item, item_id, *, store, source_authorizer, + source_error, credential_target, validate_target, + client_factory, transport_factory, timeout_error, + http_error, error_response, max_bytes): + # URL 必须来自服务端资源记录,不能接受客户端任意指定代理目标。 + target_url = str(item.get("link") or "").strip() + if not target_url: + return error_response(content={"error": "该资源没有可用下载地址"}, status_code=400) + + try: + workflow_path = await asyncio.to_thread(source_authorizer, store, item, target_url) + except source_error as error: + return error_response(content={"error": error.message}, status_code=error.status_code) + original_target = target_url + credential_chain = bool(workflow_path) + hf_token = os.environ.get("HF_TOKEN") if workflow_path else None + if workflow_path and not hf_token: + return error_response(content={"error": "工作流存储暂不可用"}, status_code=503) + + # 🚀 核心修复:使用异步 httpx 替代同步 urllib,避免阻塞事件循环 + # 云端下载代理始终校验系统 TLS 证书;不能由环境变量关闭校验。 + client = client_factory( + follow_redirects=False, + verify=True, + trust_env=False, + timeout=120.0, + transport=transport_factory(), + ) + response = None + resources = ProxyResources(client) + close_owned_resources = resources.close + + try: + print(f"🔍 开始下载资源 [{item_id}]") + for redirect_count in range(6): + host = await validate_target(target_url) + headers = {"User-Agent": "ComfyUI-Ranking-SaaS", "Accept-Encoding": "identity"} + # 同文件的HF缓存跳转可保留认证;一旦离开该范围,后续跳转不重新获得。 + credential_chain = credential_chain and credential_target(store, workflow_path, original_target, target_url) + parsed_target = urlparse(target_url) + if ( + hf_token + and credential_chain + and host == "huggingface.co" + and parsed_target.scheme.lower() == "https" + and parsed_target.port in (None, 443) + ): + headers["Authorization"] = f"Bearer {hf_token}" + request = client.build_request("GET", target_url, headers=headers) + response = resources.track(await client.send(request, stream=True, follow_redirects=False)) + + if response.status_code in (301, 302, 303, 307, 308): + location = response.headers.get("location") + await resources.close_response(response) + response = None + if not location or redirect_count >= 5: + raise http_error(status_code=502, detail="下载源重定向无效或次数过多") + target_url = urljoin(target_url, location) + continue + break + + if response is None: + raise http_error(status_code=502, detail="下载源重定向失败") + if response.status_code != 200: + status_code = response.status_code + await close_owned_resources() + response = None + return error_response(content={"error": f"源文件拉取失败,HTTP 状态码:{status_code}"}, status_code=502) + + try: + length = workflow_response_length(response.headers, max_bytes=max_bytes) + except WorkflowSizeError: + raise http_error(status_code=413, detail="下载文件超过大小限制") from None + + encoding = workflow_encoding(response.headers.get("content-encoding")) + + async def stream_download(): + try: + async for chunk in stream_workflow_bytes(response, max_bytes=max_bytes, encoding=encoding, length=length): + yield chunk + finally: + await close_owned_resources() + + return OwnedWorkflowResponse( + stream_download(), + close_callback=close_owned_resources, + media_type="application/json", + headers={"Content-Disposition": f"attachment; filename={item_id}.json"}, + ) + except asyncio.CancelledError: + await close_owned_resources() + raise + except http_error: + await close_owned_resources() + raise + except timeout_error: + await close_owned_resources() + return error_response(content={"error": "下载超时,请稍后重试"}, status_code=504) + except Exception as e: + await close_owned_resources() + print(f"❌ 代理下载异常 [{item_id}]: {type(e).__name__}") + return error_response(content={"error": "代理下载失败,请检查资源链接后重试"}, status_code=502) diff --git a/workflow_source.py b/workflow_source.py new file mode 100644 index 0000000000000000000000000000000000000000..965adfcb4b665772f68cdc196eea4c63229a33fe --- /dev/null +++ b/workflow_source.py @@ -0,0 +1,78 @@ +"""服务端HF凭据仅用于本插件注册且属于资源作者的原始工作流文件。""" +import copy +import re +from urllib.parse import urlsplit, unquote, parse_qsl + +REGISTRY_FILE = 'workflow_uploads.json' +PATH_PATTERN = re.compile(r'workflows/(app|tool|recommend)/(app|tool|recommend)_[0-9a-f]{32}\.json') + +class WorkflowSourceError(Exception): + def __init__(self, status_code, message): + self.status_code = status_code; self.message = message + super().__init__(message) + +def _path_valid(path): + match = PATH_PATTERN.fullmatch(path) if isinstance(path, str) else None + return bool(match and match[1] == match[2]) + +def _registry(source): + if not isinstance(source, dict): raise ValueError('invalid workflow registry') + for path, receipt in source.items(): + if (not _path_valid(path) or not isinstance(receipt, dict) or receipt.get('path') != path + or type(receipt.get('schema')) is not int or receipt['schema'] != 1 + or not isinstance(receipt.get('author'), str) or not receipt['author'].strip() + or not isinstance(receipt.get('repo'), str) or not receipt['repo']): + raise ValueError('invalid workflow receipt') + return source + +def register_workflow_upload(store, path, author, repo): + receipt = dict(schema=1, path=path, author=author, repo=repo) + _registry({path:receipt}) + def update(source): + registry = _registry(source) + if path in registry and registry[path] != receipt: + raise ValueError('conflicting workflow receipt') + registry[path] = copy.deepcopy(receipt) + store.atomic_update(REGISTRY_FILE, update, default_data={}, force_sync=True) + +def authorized_workflow_path(store, item, url): + """非本插件Dataset返回None(仅匿名下载);本插件源必须具有作者匹配的上传证据。""" + try: + parsed = urlsplit(url) + if parsed.hostname != 'huggingface.co': return None + raw = parsed.path + prefix = f'/datasets/{store.DATASET_REPO_ID}/' + # 解码后也核查,不能通过编码路径绕过本插件源限制。 + decoded = unquote(raw) + if not decoded.startswith(prefix): return None + if (parsed.scheme != 'https' or parsed.username or parsed.password or parsed.port not in (None,443) + or parsed.query or parsed.fragment or raw != decoded or not raw.startswith(prefix+'resolve/main/')): + raise WorkflowSourceError(403, '工作流来源不允许使用服务凭据') + path = raw[len(prefix+'resolve/main/'):] + if not _path_valid(path): raise WorkflowSourceError(403, '工作流来源未通过授权') + registry = _registry(store.load_data(REGISTRY_FILE, default_data={}, strict=True, skip_cache=True)) + receipt = registry.get(path) + if not receipt or receipt['repo'] != store.DATASET_REPO_ID or receipt['author'] != item.get('author'): + raise WorkflowSourceError(403, '工作流上传来源与资源作者不匹配') + return path + except WorkflowSourceError: raise + except Exception: + raise WorkflowSourceError(503, '工作流上传来源暂时无法核实') from None + + +def workflow_credential_target(store, path, original_url, target_url): + """允许原URL或同仓库同文件的完整commit缓存地址;不扩大文件授权。""" + if target_url == original_url: return True + try: + target = urlsplit(target_url) + if (target.scheme != 'https' or target.hostname != 'huggingface.co' or target.username or target.password + or target.port not in (None,443) or target.fragment or target.path != unquote(target.path)): + return False + if any(key not in ('etag','download') for key,_ in parse_qsl(target.query,keep_blank_values=True)): + return False + prefix = '/api/resolve-cache/datasets/' + store.DATASET_REPO_ID + '/' + if not target.path.startswith(prefix): return False + suffix = target.path[len(prefix):] + commit, separator, filename = suffix.partition('/') + return bool(separator and re.fullmatch(r'[0-9a-f]{40}',commit) and filename == path) + except Exception: return False diff --git a/workflow_stream_response.py b/workflow_stream_response.py new file mode 100644 index 0000000000000000000000000000000000000000..4553af1d7e79f75525c6414811d72dbec0a48c51 --- /dev/null +++ b/workflow_stream_response.py @@ -0,0 +1,22 @@ +"""代理流式响应的交付收尾;即使发送头/正文失败也归还所属连接。""" +from fastapi.responses import StreamingResponse + + +class OwnedProxyResponse(StreamingResponse): + def __init__(self, content, *, close_callback, **kwargs): + super().__init__(content, **kwargs) + self._close_callback = close_callback + + async def __call__(self, scope, receive, send): + try: + await super().__call__(scope, receive, send) + finally: + try: + close_iterator = getattr(self.body_iterator, "aclose", None) + if close_iterator is not None: + await close_iterator() + finally: + await self._close_callback() + +# 保留既有工作流调用方的导出名称。 +OwnedWorkflowResponse = OwnedProxyResponse diff --git a/workflow_upload.py b/workflow_upload.py new file mode 100644 index 0000000000000000000000000000000000000000..7521f604f5277f100f415cb2ac364244bab256d3 --- /dev/null +++ b/workflow_upload.py @@ -0,0 +1,29 @@ +"""工作流上传的严格JSON对象验证,不执行工作流或访问网络。""" +import json +import math + +WORKFLOW_UPLOAD_TYPES = frozenset(('app', 'tool', 'recommend')) + +def validate_workflow_file(path): + def pairs(entries): + result = {} + for key, value in entries: + if key in result: + raise ValueError('duplicate workflow key') + result[key] = value + return result + def number(value): + result = float(value) + if not math.isfinite(result): + raise ValueError('nonfinite workflow number') + return result + def constant(_): + raise ValueError('nonfinite workflow value') + try: + with open(path, 'r', encoding='utf-8-sig') as source: + value = json.load(source, object_pairs_hook=pairs, parse_constant=constant, parse_float=number) + if not isinstance(value, dict): + raise ValueError('workflow root must be an object') + return True + except (ValueError, UnicodeError, RecursionError): + return False diff --git a/zip_decode.py b/zip_decode.py new file mode 100644 index 0000000000000000000000000000000000000000..2693ae490b27e05ce20159dc87c347f709b506cb --- /dev/null +++ b/zip_decode.py @@ -0,0 +1,16 @@ +"""ZIP预缓存HTTP解码:原始长度预算在前,复用有界解码器输出归档字节。""" +from zip_stream import zip_response_length, stream_zip_bytes +from workflow_decode import workflow_encoding, stream_workflow_bytes + + +async def stream_zip_archive_bytes(response, *, max_bytes): + length = zip_response_length(response.headers, max_bytes=max_bytes) + encoding = workflow_encoding(response.headers.get('content-encoding')) + + class RawArchive: + async def aiter_raw(self, chunk_size=None): + async for chunk in stream_zip_bytes(response, max_bytes=max_bytes, length=length): + yield chunk + + async for chunk in stream_workflow_bytes(RawArchive(), max_bytes=max_bytes, encoding=encoding): + yield chunk diff --git a/zip_stream.py b/zip_stream.py new file mode 100644 index 0000000000000000000000000000000000000000..819319c36aa406bc8e71555314e34567b38a0cf0 --- /dev/null +++ b/zip_stream.py @@ -0,0 +1,52 @@ +"""ZIP原始传输预算与长度一致性;不解码HTTP编码或展开归档。""" +import asyncio +import re + +ZIP_DOWNLOAD_MAX_BYTES = 2 * 1024 * 1024 * 1024 +ZIP_CHUNK_BYTES = 64 * 1024 + + +class ZipStreamError(RuntimeError): + pass + + +def _budget(max_bytes): + if type(max_bytes) is not int or max_bytes <= 0: + raise ZipStreamError("invalid zip byte budget") + + +def zip_response_length(headers, *, max_bytes, expected_size=None): + _budget(max_bytes) + if expected_size is not None and (type(expected_size) is not int or not 0 < expected_size <= max_bytes): + raise ZipStreamError("invalid zip expected size") + value = headers.get('content-length') + if value is None: + return None + if not isinstance(value, str) or not re.fullmatch(r'[0-9]{1,20}', value): + raise ZipStreamError("invalid zip content length") + length = int(value) + if not 0 < length <= max_bytes: + raise ZipStreamError("zip response exceeds byte budget") + if expected_size is not None and length != expected_size: + raise ZipStreamError("zip response size mismatch") + return length + + +async def stream_zip_bytes(response, *, max_bytes, length=None, expected_size=None): + _budget(max_bytes) + for value in (length, expected_size): + if value is not None and (type(value) is not int or not 0 < value <= max_bytes): + raise ZipStreamError("invalid zip expected size") + total = 0 + async for chunk in response.aiter_raw(chunk_size=ZIP_CHUNK_BYTES): + total += len(chunk) + if total > max_bytes: + raise ZipStreamError("zip response exceeds byte budget") + if (length is not None and total > length) or (expected_size is not None and total > expected_size): + raise ZipStreamError("zip response size mismatch") + view = memoryview(chunk) + for offset in range(0, len(view), ZIP_CHUNK_BYTES): + await asyncio.sleep(0) + yield bytes(view[offset:offset + ZIP_CHUNK_BYTES]) + if not total or (length is not None and total != length) or (expected_size is not None and total != expected_size): + raise ZipStreamError("zip response size mismatch") diff --git a/zip_validation.py b/zip_validation.py new file mode 100644 index 0000000000000000000000000000000000000000..f8c2c1e2e04fc99005ef24d51c8f2599727f4350 --- /dev/null +++ b/zip_validation.py @@ -0,0 +1,154 @@ +"""ZIP预缓存资源预检与有界CRC扫描;不提取文件、不调用ZipExtFile解码。""" +import os +import struct +import time +import zipfile +import zlib + +MAX_DIRECTORY_BYTES = 16 * 1024 * 1024 +MAX_ENTRIES = 100000 +MAX_EXPANDED_BYTES = 4 * 1024 * 1024 * 1024 +MAX_MEMBER_BYTES = 1024 * 1024 * 1024 +SCAN_SECONDS = 120 +CHUNK_BYTES = 64 * 1024 + + +def _fail(): + raise ValueError('invalid or excessive zip archive') + + +def _directory(source, size, check, max_directory_bytes, max_entries): + # EOCD+最大注释读取至多65557字节;不允许前缀/跨盘/额外ZIP64扩展记录。 + tail_size = min(size, 65557) + source.seek(size - tail_size) + tail = source.read(tail_size) + offset = len(tail) + while True: + check() + offset = tail.rfind(b'PK\x05\x06', 0, offset) + if offset < 0: _fail() + if offset + 22 <= len(tail): + record = struct.unpack('<4s4H2LH', tail[offset:offset + 22]) + if offset + 22 + record[7] == len(tail): break + end = size - tail_size + offset + _, disk, directory_disk, disk_entries, entries, directory_size, start, _ = record + if disk or directory_disk or disk_entries != entries: _fail() + boundary = end + if end >= 20: + source.seek(end - 20) + locator = source.read(20) + if locator[:4] == b'PK\x06\x07': + _, zip_disk, zip_offset, disks = struct.unpack('<4sLQL', locator) + if zip_disk or disks != 1 or zip_offset != end - 76: _fail() + source.seek(zip_offset) + raw = source.read(56) + if len(raw) != 56: _fail() + fields = struct.unpack('<4sQ2H2L4Q', raw) + if fields[0] != b'PK\x06\x06' or fields[1] != 44 or fields[4] or fields[5] or fields[6] != fields[7]: _fail() + entries, directory_size, start = fields[7:10] + boundary = zip_offset + if not 0 < entries <= max_entries or not 0 < directory_size <= max_directory_bytes: _fail() + if start < 4 or start + directory_size != boundary: _fail() + source.seek(0) + if source.read(4) != b'PK\x03\x04': _fail() + # 逐记录确认实际数量,不能让伪造entries绕过ZipInfo对象分配上限。 + position, count = start, 0 + while position < boundary: + check();source.seek(position) + header = source.read(46) + if len(header) != 46 or header[:4] != b'PK\x01\x02': _fail() + name, extra, comment = struct.unpack_from('<3H', header, 28) + if not name: _fail() + position += 46 + name + extra + comment + count += 1 + if position > boundary or count > max_entries: _fail() + if count != entries: _fail() + return start, count, end + 22 + + +class _MetadataReader: + """限制标准库元数据读取;屏蔽注释中的伪EOCD,保留实际文件内容。""" + def __init__(self, source, size, comment_start, max_read): + self.source, self.size, self.comment_start, self.max_read = source, size, comment_start, max_read + + def seek(self, *args): return self.source.seek(*args) + def tell(self): return self.source.tell() + + def read(self, count=-1): + start = self.source.tell() + requested = self.size - start if count < 0 else count + if requested > max(65557, self.max_read): _fail() + data = self.source.read(count) + offset = max(0, self.comment_start - start) + if offset < len(data): + data = data[:offset] + b'\x00' * (len(data) - offset) + return data + + +def validate_zip_archive(path, size, *, max_directory_bytes=MAX_DIRECTORY_BYTES, + max_entries=MAX_ENTRIES, max_expanded_bytes=MAX_EXPANDED_BYTES, + max_member_bytes=MAX_MEMBER_BYTES, max_seconds=SCAN_SECONDS): + for value in (size, max_directory_bytes, max_entries, max_expanded_bytes, max_member_bytes): + if type(value) is not int or value <= 0: _fail() + if type(max_seconds) not in (int, float) or not 0 < max_seconds <= SCAN_SECONDS: _fail() + deadline = time.monotonic() + max_seconds + def check(): + if time.monotonic() >= deadline: _fail() + check() + with open(path, 'rb') as source: + if os.fstat(source.fileno()).st_size != size: _fail() + directory_start, count, comment_start = _directory(source, size, check, max_directory_bytes, max_entries) + check() + with zipfile.ZipFile(_MetadataReader(source, size, comment_start, max_directory_bytes), 'r') as archive: + check();entries = archive.infolist() + if len(entries) != count or archive.start_dir != directory_start: _fail() + expanded = 0 + for entry in entries: + check() + if (entry.flag_bits & 1 or entry.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED) + or not 0 <= entry.file_size <= max_member_bytes or entry.compress_size < 0): _fail() + expanded += entry.file_size + if expanded > min(max_expanded_bytes, size * 10): _fail() + if entry.header_offset < 0 or entry.header_offset + 30 > directory_start: _fail() + actual_total = 0 + for entry in entries: + check();source.seek(entry.header_offset) + header = source.read(30) + if len(header) != 30 or header[:4] != b'PK\x03\x04': _fail() + flags, method = struct.unpack_from('<2H', header, 6) + name_size, extra_size = struct.unpack_from('<2H', header, 26) + if flags != entry.flag_bits or method != entry.compress_type: _fail() + name = source.read(name_size) + if name.decode('utf-8' if flags & 2048 else 'cp437') != entry.orig_filename: _fail() + data_start = entry.header_offset + 30 + name_size + extra_size + data_end = data_start + entry.compress_size + if data_end > min(directory_start, entry._end_offset): _fail() + source.seek(data_start) + remaining, actual, crc = entry.compress_size, 0, 0 + decoder = zlib.decompressobj(-15) if method == zipfile.ZIP_DEFLATED else None + def output(data): + nonlocal actual, actual_total, crc + check();actual += len(data);actual_total += len(data) + if actual > entry.file_size or actual_total > max_expanded_bytes: _fail() + crc = zlib.crc32(data, crc) + while remaining: + check();chunk = source.read(min(CHUNK_BYTES, remaining)) + if not chunk: _fail() + remaining -= len(chunk) + if decoder is None: + output(chunk);continue + pending = chunk + while True: + check();limit = min(CHUNK_BYTES, entry.file_size - actual + 1) + data = decoder.decompress(pending, limit) + output(data) + if decoder.unused_data or (decoder.eof and remaining): _fail() + pending = decoder.unconsumed_tail + if pending: continue + if len(data) == limit and not decoder.eof: + pending = b'';continue + break + if decoder is not None and not decoder.eof: _fail() + if actual != entry.file_size or crc != entry.CRC: _fail() + check() + return True diff --git "a/\344\272\221\347\253\257_\345\256\232\346\227\266\347\211\210\346\234\254\346\243\200\346\265\213\345\274\225\346\223\216.py" "b/\344\272\221\347\253\257_\345\256\232\346\227\266\347\211\210\346\234\254\346\243\200\346\265\213\345\274\225\346\223\216.py" index 5898cfcda7c7da9ebd255402bb3fee3b54896cb4..36171893248ca820e548826db3d71b380365ec0a 100644 --- "a/\344\272\221\347\253\257_\345\256\232\346\227\266\347\211\210\346\234\254\346\243\200\346\265\213\345\274\225\346\223\216.py" +++ "b/\344\272\221\347\253\257_\345\256\232\346\227\266\347\211\210\346\234\254\346\243\200\346\265\213\345\274\225\346\223\216.py" @@ -1,3 +1,11 @@ +from github_source import repository_parts, select_github_token +from github_archive import commit_sha, commit_ref, source_signature, archive_path, publish_archive_metadata, cached_archive, archive_repository, ARCHIVE_SCHEMA +from safe_http_transport import ValidatedAsyncHTTPTransport +from github_versions import refresh_resource_version +from zip_decode import stream_zip_archive_bytes +from owned_thread import run_owned_thread +from zip_validation import validate_zip_archive +from proxy_resources import ProxyResources # 云端_定时版本检测引擎.py import asyncio import datetime @@ -15,7 +23,6 @@ logger = logging.getLogger("ComfyUI-Ranking.VersionCheck") # ========================================== # 预缓存配置常量 # ========================================== -HF_REPO_ID = "ZHIWEI666/ComfyUI-Ranking" ZIP_MAX_SIZE = 500 * 1024 * 1024 # 500MB CACHE_EXPIRE_DAYS = 30 PRECACHE_BATCH_SIZE = 5 @@ -41,276 +48,191 @@ def _normalize_version_entry(versions_db: dict, item_id: str): versions_db[item_id] = {"hash": ""} -async def fetch_latest_github_hash(repo_url, token): +async def fetch_github_commit(repo_url, token, ref=None): """ 请求 GitHub API 获取最新版的 Commit Hash 🛡️ 稳定性优化:超时保护 + 异常捕获 + 重试机制 """ try: - parts = repo_url.rstrip("/").split("/") - if len(parts) < 2: return None - owner, repo = parts[-2], parts[-1].replace(".git", "") + owner, repo = repository_parts(repo_url) - api_url = f"https://api.github.com/repos/{owner}/{repo}/commits" + api_url = f"https://api.github.com/repos/{owner}/{repo}/commits/" + commit_ref(ref) if ref is not None else f"https://api.github.com/repos/{owner}/{repo}/commits?per_page=1" headers = {"Accept": "application/vnd.github.v3+json", "User-Agent": "ComfyUI-Hub"} if token: headers["Authorization"] = f"Bearer {token}" # 🛡️ 稳定性优化:超时 + 重试 - async with httpx.AsyncClient(follow_redirects=True, timeout=httpx.Timeout(15.0, connect=5.0)) as client: + async with httpx.AsyncClient(follow_redirects=False, trust_env=False, transport=ValidatedAsyncHTTPTransport(), timeout=httpx.Timeout(15.0, connect=5.0)) as client: for attempt in range(3): # 最多重试 3 次 try: resp = await client.get(api_url, headers=headers) if resp.status_code == 200: data = resp.json() - if isinstance(data, list) and len(data) > 0: - latest_commit = data[0] - if isinstance(latest_commit, dict) and "sha" in latest_commit: - return latest_commit["sha"][:7] # 返回最新的 Commit Hash + latest_commit = data[0] if ref is None and isinstance(data, list) and data else data + if isinstance(latest_commit, dict): + sha = commit_sha(latest_commit.get("sha")) + if ref is not None and not sha.startswith(commit_ref(ref)): + return None + return sha elif resp.status_code == 403: # Rate limit - logger.warning(f"GitHub API 速率限制: {repo_url}") + logger.warning("GitHub API 速率限制: status=403") return None break except (httpx.TimeoutException, httpx.ConnectError) as e: if attempt < 2: await asyncio.sleep(2 ** attempt) # 指数退避 continue - logger.warning(f"版本检测超时 {repo_url}: {e}") + logger.warning(f"版本检测请求失败: error_type={type(e).__name__}") except Exception as e: - logger.error(f"检测版本失败 {repo_url}: {e}") + logger.error(f"检测版本失败: error_type={type(e).__name__}") return None +async def fetch_latest_github_hash(repo_url, token): + sha = await fetch_github_commit(repo_url, token) + return sha[:7] if sha else None + + async def precache_github_zip(repo_url: str, token: Optional[str], item_id: str, version_hash: str): - """预下载 GitHub ZIP 并上传到 HF Dataset 缓存""" + """按明确commit下载,上传成功后才发布来源匹配的不可变HF缓存。""" + temp_path = None + client = None + response = None + resources = None try: - parts = repo_url.rstrip("/").split("/") - if len(parts) < 2: - logger.warning(f"[预缓存] 无效的仓库地址: {repo_url}") + storage_repo = archive_repository(db.DATASET_REPO_ID) + from proxy_access import read_proxy_item + item = await run_owned_thread(read_proxy_item, db, item_id) + current_token = select_github_token(item) + if repository_parts(repo_url) != repository_parts(item['link']) or token != current_token: return False - owner, repo = parts[-2], parts[-1].replace(".git", "") - zip_url = f"https://api.github.com/repos/{owner}/{repo}/zipball" - - headers = { - "Accept": "application/vnd.github.v3+json", - "User-Agent": "ComfyUI-Hub" - } - if token: - headers["Authorization"] = f"Bearer {token}" - + owner, repo = repository_parts(repo_url) + ref = commit_ref(version_hash) + sha = commit_sha(ref) if len(ref) == 40 else await fetch_github_commit(repo_url, token, ref) + if not sha: + return False + signature = source_signature(item, token) + path_in_repo = archive_path(item_id, sha, signature) hf_token = os.environ.get("HF_TOKEN") if not hf_token: - logger.warning("[预缓存] HF_TOKEN 环境变量未设置,跳过预缓存") return False - - # 下载到临时文件(stream 模式,超时 300s) - temp_path = None - async with httpx.AsyncClient(follow_redirects=True, timeout=httpx.Timeout(300.0, connect=30.0)) as client: - logger.info(f"[预缓存] 开始下载 {item_id} ZIP: {zip_url}") - async with client.stream("GET", zip_url, headers=headers) as resp: - if resp.status_code != 200: - logger.warning(f"[预缓存] 下载 ZIP 失败,状态码: {resp.status_code}, URL: {zip_url}") - return False - - fd, temp_path = tempfile.mkstemp(suffix=".zip", prefix=f"precache_{item_id}_") - try: - total_size = 0 - oversized = False - with os.fdopen(fd, "wb") as f: - async for chunk in resp.aiter_bytes(chunk_size=8192): - f.write(chunk) - total_size += len(chunk) - if total_size > ZIP_MAX_SIZE: - logger.warning( - f"[预缓存] ZIP 超过 {ZIP_MAX_SIZE // (1024*1024)}MB 限制,跳过: " - f"{item_id} (已下载 {total_size} 字节)" - ) - oversized = True - break - - if oversized: - if temp_path and os.path.exists(temp_path): - try: - os.remove(temp_path) - except Exception: - pass - return False - except Exception: - # 清理临时文件 - if temp_path and os.path.exists(temp_path): - try: - os.remove(temp_path) - except Exception: - pass - raise - - if not temp_path or not os.path.exists(temp_path): - logger.warning(f"[预缓存] 临时文件未生成: {item_id}") + storage = await run_owned_thread(HfApi(token=hf_token).repo_info, repo_id=storage_repo, repo_type="dataset") + if getattr(storage, 'private', None) is not True: + logger.warning("ZIP预缓存要求私有Dataset,跳过发布") return False - - # 验证 ZIP 完整性 - if not zipfile.is_zipfile(temp_path): - logger.warning(f"[预缓存] ZIP 文件验证失败(非有效 ZIP): {item_id}") - os.remove(temp_path) + headers = {"Accept": "application/vnd.github.v3+json", "User-Agent": "ComfyUI-Hub", "Accept-Encoding": "identity"} + if token: + headers["Authorization"] = f"Bearer {token}" + client = httpx.AsyncClient(follow_redirects=False, trust_env=False, transport=ValidatedAsyncHTTPTransport()) + resources = ProxyResources(client) + # 共用逐跳公网/DNS/TLS与跨origin凭据隔离;归档重定向仍需验证。 + from router_proxy import _send_with_validated_redirects + response = resources.track(await _send_with_validated_redirects(client, "GET", + f"https://api.github.com/repos/{owner}/{repo}/zipball/{sha}", headers=headers, timeout=300)) + if response.status_code != 200: return False - - # 🔒 ZIP 膨胀比检查(防止 zip bomb) - try: - with zipfile.ZipFile(temp_path, 'r') as zf: - uncompressed_size = sum(f.file_size for f in zf.infolist()) - zip_size_check = os.path.getsize(temp_path) - if uncompressed_size > zip_size_check * 10: - logger.warning( - f"[预缓存] ZIP 膨胀比过高,疑似 zip bomb: {item_id}, " - f"压缩={zip_size_check}字节, 未压缩={uncompressed_size}字节" - ) - os.remove(temp_path) + fd, temp_path = tempfile.mkstemp(suffix=".zip", prefix="ranking-precache-") + size = 0 + with os.fdopen(fd, 'wb') as output: + async for chunk in stream_zip_archive_bytes(response, max_bytes=ZIP_MAX_SIZE): + size += len(chunk) + if size > ZIP_MAX_SIZE: return False - except zipfile.BadZipFile: - logger.warning(f"[预缓存] ZIP 文件损坏: {item_id}") - os.remove(temp_path) + output.write(chunk) + await resources.close() + await run_owned_thread(validate_zip_archive, temp_path, size) + uploaded = await run_owned_thread(upload_file, path_or_fileobj=temp_path, path_in_repo=path_in_repo, + repo_id=storage_repo, repo_type="dataset", token=hf_token) + hf_commit = commit_sha(getattr(uploaded, 'oid', None)) + latest_item = await run_owned_thread(read_proxy_item, db, item_id) + if source_signature(latest_item, select_github_token(latest_item)) != signature: return False - - zip_size = os.path.getsize(temp_path) - logger.info(f"[预缓存] ZIP 下载完成: {item_id}, 大小 {zip_size} 字节") - - # 上传到 HF Dataset - path_in_repo = f"zips/{item_id}_{version_hash}.zip" + metadata = dict(archive_schema=ARCHIVE_SCHEMA, archive_commit=sha, archive_source=signature, + archive_path=path_in_repo, archive_hf_commit=hf_commit, archive_storage_private=True, archive_repo_id=storage_repo, zip_size=size, + cached_at=datetime.datetime.now(datetime.timezone.utc).isoformat()) + def update(versions): + if db.DATASET_REPO_ID != storage_repo: + return False + return publish_archive_metadata(versions, item_id, ref, metadata) + return bool(await run_owned_thread(db.atomic_update, "versions.json", update, default_data={})) + except Exception as error: + logger.warning("预缓存失败: error_type=%s", type(error).__name__) + return False + finally: try: - upload_file( - path_or_fileobj=temp_path, - path_in_repo=path_in_repo, - repo_id=HF_REPO_ID, - repo_type="dataset", - token=hf_token, - ) - logger.info(f"[预缓存] 上传成功: {path_in_repo}") - except Exception as e: - logger.warning(f"[预缓存] 上传 HF 失败 ({item_id}): {e}") - os.remove(temp_path) - return False - - # 更新 versions.json 缓存元信息 - versions_db = db.load_data("versions.json", default_data={}) - entry = versions_db.get(item_id, {}) - if isinstance(entry, str): - entry = {"hash": entry} - entry["cached_at"] = datetime.datetime.utcnow().isoformat() + "Z" - entry["zip_size"] = zip_size - versions_db[item_id] = entry - db.save_data("versions.json", versions_db) - logger.info(f"[预缓存] 缓存元信息已更新: {item_id}") + if resources is not None: + await resources.close() + finally: + if temp_path is not None: + try: + os.remove(temp_path) + except OSError: + logger.warning("预缓存临时文件清理失败") - # 清理临时文件 - os.remove(temp_path) - return True - except Exception as e: - logger.warning(f"[预缓存] 预缓存异常 ({item_id}): {e}") - return False +def _stored_archive_path(item_id, old_hash, entry=None, *, repo_id): + # 旧记录没有仓库证明,不能猜测到当前或默认仓库执行删除。 + repository = archive_repository(repo_id) + if (not isinstance(entry, dict) or type(entry.get('archive_schema')) is not int + or entry['archive_schema'] != ARCHIVE_SCHEMA + or entry.get('archive_repo_id') != repository + or entry.get('archive_storage_private') is not True): + raise ValueError('unverified archive storage') + path = archive_path(item_id, entry.get('archive_commit'), entry.get('archive_source')) + if entry.get('archive_path') != path: + raise ValueError('invalid stored archive path') + return path -async def _delete_old_zip_cache(item_id: str, old_hash: str): - """删除指定 item_id + old_hash 对应的 HF Dataset 缓存 ZIP""" +async def _delete_old_zip_cache(item_id: str, old_hash: str, entry=None): try: hf_token = os.environ.get("HF_TOKEN") if not hf_token: return + storage_repo = archive_repository(db.DATASET_REPO_ID) + path = _stored_archive_path(item_id, old_hash, entry, repo_id=storage_repo) hf_api = HfApi(token=hf_token) - path_in_repo = f"zips/{item_id}_{old_hash}.zip" - try: - hf_api.delete_file( - path_in_repo=path_in_repo, - repo_id=HF_REPO_ID, - repo_type="dataset", - ) - logger.info(f"[预缓存] 已删除旧版本缓存: {path_in_repo}") - except Exception as e: - error_msg = str(e) - if "404" in error_msg or "Not Found" in error_msg: - logger.info(f"[预缓存] 旧缓存文件已不存在: {path_in_repo}") - else: - logger.warning(f"[预缓存] 删除旧缓存失败: {path_in_repo}, {e}") - except Exception as e: - logger.warning(f"[预缓存] 删除旧缓存异常 ({item_id}): {e}") + await asyncio.to_thread(hf_api.delete_file, path_in_repo=path, repo_id=storage_repo, repo_type="dataset") + except Exception as error: + logger.warning("旧缓存清理失败: error_type=%s", type(error).__name__) async def cleanup_expired_cache(versions_db: dict): - """清理过期的 ZIP 缓存文件""" + """清理过期归档;仅撤销仍对应本次快照的元数据,不覆盖并发新缓存。""" try: - hf_token = os.environ.get("HF_TOKEN") - if not hf_token: - logger.warning("[预缓存] HF_TOKEN 未设置,跳过缓存清理") - return - - hf_api = HfApi(token=hf_token) - now = datetime.datetime.utcnow() - expired_items = [] - - for item_id, val in list(versions_db.items()): - if isinstance(val, str): + now = datetime.datetime.now(datetime.timezone.utc) + for item_id, entry in list(versions_db.items()): + if not isinstance(entry, dict) or not entry.get('cached_at'): continue - if not isinstance(val, dict): + try: + cached_at = datetime.datetime.fromisoformat(entry['cached_at'].replace('Z', '+00:00')) + if cached_at.tzinfo is None: + cached_at = cached_at.replace(tzinfo=datetime.timezone.utc) + expired = now - cached_at > datetime.timedelta(days=CACHE_EXPIRE_DAYS) + except (ValueError, TypeError, AttributeError): + expired = True + if not expired: continue - - old_hash = val.get("hash") - cached_at_str = val.get("cached_at") - - # 规则1: cached_at 超过 30 天的条目 - if cached_at_str: - try: - cached_at = datetime.datetime.fromisoformat(cached_at_str.replace("Z", "+00:00")) - if (now - cached_at).days > CACHE_EXPIRE_DAYS: - expired_items.append((item_id, old_hash, "过期")) - continue - except ValueError: - pass - - # 规则2: 版本更新时(hash 变化)——在 daily_version_check_task 中单独处理 - # 本函数仅处理过期时间 - - if not expired_items: - return - - deleted_count = 0 - modified_local = False - for item_id, old_hash, reason in expired_items: - if old_hash: - path_in_repo = f"zips/{item_id}_{old_hash}.zip" - try: - hf_api.delete_file( - path_in_repo=path_in_repo, - repo_id=HF_REPO_ID, - repo_type="dataset", - ) - logger.info(f"[预缓存] 已删除过期缓存 ({reason}): {path_in_repo}") - deleted_count += 1 - except Exception as e: - error_msg = str(e) - if "404" in error_msg or "Not Found" in error_msg: - logger.info(f"[预缓存] 缓存文件已不存在: {path_in_repo}") - else: - logger.warning(f"[预缓存] 删除缓存失败: {path_in_repo}, {e}") - - # 清除本地 cached_at 和 zip_size(保留 hash) - entry = versions_db.get(item_id, {}) - if isinstance(entry, dict): - had_meta = "cached_at" in entry or "zip_size" in entry - entry.pop("cached_at", None) - entry.pop("zip_size", None) - versions_db[item_id] = entry - if had_meta: - modified_local = True - - if deleted_count > 0 or modified_local: - db.save_data("versions.json", versions_db) - logger.info(f"[预缓存] 过期缓存清理完成,共删除 {deleted_count} 个文件") - - except Exception as e: - logger.warning(f"[预缓存] 缓存清理异常: {e}") + expected = dict(entry) + await _delete_old_zip_cache(item_id, entry.get('hash'), entry) + def update(current): + if not isinstance(current, dict): + raise ValueError('invalid version source') + if current.get(item_id) != expected: + return False + current[item_id] = {key: value for key, value in expected.items() + if key not in ('cached_at', 'zip_size') and not key.startswith('archive_')} + return True + changed = await asyncio.to_thread(db.atomic_update, 'versions.json', update, default_data={}) + if changed: + versions_db[item_id] = {key: value for key, value in expected.items() + if key not in ('cached_at', 'zip_size') and not key.startswith('archive_')} + except Exception as error: + logger.warning("缓存过期清理失败: error_type=%s", type(error).__name__) async def daily_version_check_task(): """每日 02:00 定时执行的守护进程""" + cache_cursor = 0 while True: now = datetime.datetime.utcnow() + datetime.timedelta(hours=8) # 计算距离今天凌晨 02:00 的秒数,如果过了就定在明天 02:00 @@ -323,8 +245,8 @@ async def daily_version_check_task(): await asyncio.sleep(sleep_seconds) print("🚀 [02:00] 开始执行全站 GitHub 资源版本检测任务...") - items_db = db.load_data("items.json", default_data=[]) - versions_db = db.load_data("versions.json", default_data={}) + items_db = await asyncio.to_thread(db.load_data, "items.json", default_data=[], strict=True, skip_cache=True) + versions_db = await asyncio.to_thread(db.load_data, "versions.json", default_data={}, strict=True, skip_cache=True) # 先清理过期缓存(cached_at 超过 30 天) await cleanup_expired_cache(versions_db) @@ -335,39 +257,30 @@ async def daily_version_check_task(): for item in items_db: link = item.get("link", "") if "github.com" in link: - token = item.get("github_token") - latest_hash = await fetch_latest_github_hash(link, token) - old_hash = get_version_hash(versions_db, item["id"]) - if latest_hash and old_hash != latest_hash: - # 版本更新时,删除旧 hash 对应的缓存文件 - if old_hash: - await _delete_old_zip_cache(item["id"], old_hash) - - # 统一转换为新格式并写入新 hash - _normalize_version_entry(versions_db, item["id"]) - versions_db[item["id"]]["hash"] = latest_hash - updated_count += 1 - # 记录更新的插件信息(包含 link/token 供预缓存使用) - updated_items.append({ - "id": item["id"], - "title": item.get("title", "未知插件"), - "version_hash": latest_hash, - "link": link, - "github_token": token - }) - - if updated_count > 0: - db.save_data("versions.json", versions_db) - print(f"✅ 版本检测任务完成,共发现并更新了 {updated_count} 个资源的新版本。") - # 📦 更新提醒改由卡片徽章承载:前端比对本地版本戳与云端 versions.json 的 latest_version, - # 检测到差异即在卡片上显示「🔄 更新可用」,不再向用户推送 plugin_update 铃铛通知 - else: - print("✅ 版本检测任务完成,暂无任何新版本发现。") - + try: + refreshed = await refresh_resource_version(db, item['id']) + if not refreshed: + continue + current = refreshed['item'] + token = refreshed['token'] + latest_hash = refreshed['commit'] + if refreshed['changed']: + updated_count += 1 + # 每项刷新后再读最新缓存,不持有全站快照执行保存。 + current_versions = await asyncio.to_thread(db.load_data, "versions.json", default_data={}, strict=True, skip_cache=True) + if cached_archive(current, token, current_versions.get(item['id']), repo_id=db.DATASET_REPO_ID) is None: + updated_items.append(dict(id=item['id'], link=current['link'], github_token=token, version_hash=latest_hash)) + except Exception as error: + logger.warning("版本检测跳过失败项: error_type=%s", type(error).__name__) + logger.info("版本检测完成,更新资源数量=%s", updated_count) + # 对更新的 items 执行预缓存(最多 5 个,每个间隔 10 秒) if updated_items: precache_count = 0 - batch = updated_items[:PRECACHE_BATCH_SIZE] + start = cache_cursor % len(updated_items) + ordered = updated_items[start:] + updated_items[:start] + batch = ordered[:PRECACHE_BATCH_SIZE] + cache_cursor = (start + len(batch)) % len(updated_items) for idx, item in enumerate(batch): success = await precache_github_zip( item["link"], @@ -436,9 +349,9 @@ async def reset_daily_views_task(): if reset_count > 0: print(f" ✓ {file_name}: 重置了 {reset_count} 条记录的 daily_views") except Exception as e: - logger.error(f"重置 {file_name} 的 daily_views 失败: {e}") + logger.error(f"重置 {file_name} 的 daily_views 失败: error_type={type(e).__name__}") if total_reset_count > 0: print(f"✅ daily_views 重置任务完成,共重置 {total_reset_count} 条记录。") else: - print("✅ daily_views 重置任务完成,没有需要重置的记录。") \ No newline at end of file + print("✅ daily_views 重置任务完成,没有需要重置的记录。") diff --git "a/\345\256\211\345\205\250\350\256\244\350\257\201.py" "b/\345\256\211\345\205\250\350\256\244\350\257\201.py" index 245d47fa29f5511bb01e496209d76c2d9bebddd8..f02be15f3be353e8c932e2df5b208243fdf693c6 100644 --- "a/\345\256\211\345\205\250\350\256\244\350\257\201.py" +++ "b/\345\256\211\345\205\250\350\256\244\350\257\201.py" @@ -3,13 +3,15 @@ # 🔐 安全认证工具模块 # ========================================== # 作用:提供密码哈希和 JWT Token 的生成/验证功能 -# 特点:仅使用 Python 标准库,零外部依赖 +# 特点:bcrypt 密码哈希、标准库 JWT,以及用户文件密码版本撤销 # 关联文件: # - router_users_auth.py (登录/注册/重置密码调用此模块) # - 所有需要身份验证的路由模块 # ========================================== import os +import asyncio +import math import hashlib import hmac import base64 @@ -17,6 +19,7 @@ import json import time from typing import Optional, Tuple from fastapi import HTTPException, Header +from user_store import read_user, get_password_version # 🔒 P0安全增强:引入bcrypt进行密码哈希 import bcrypt @@ -34,9 +37,13 @@ _DEFAULT_PASSWORD_SALT = "ComfyUI-Ranking-Salt-v1" JWT_SECRET = os.environ.get("JWT_SECRET") or _DEFAULT_JWT_SECRET PASSWORD_SALT = os.environ.get("PASSWORD_SALT") or _DEFAULT_PASSWORD_SALT -# 🔒 P0安全加固:未配置 JWT_SECRET 时启动日志告警(默认密钥已公开于源码,必须轮换) +# 🔒 生产环境必须使用部署密钥:公开默认值只允许本地开发使用。 +# HF Spaces 或显式生产部署缺少密钥时拒绝启动,避免继续签发可伪造 Token。 +_IS_PRODUCTION = bool(os.environ.get("SPACE_ID")) or os.environ.get("APP_ENV", "").lower() == "production" +if _IS_PRODUCTION and not os.environ.get("JWT_SECRET"): + raise RuntimeError("生产环境必须配置随机 JWT_SECRET;拒绝使用源码内置默认密钥启动") if not os.environ.get("JWT_SECRET"): - print("⚠️ [安全告警] 未设置 JWT_SECRET 环境变量,正在使用内置默认密钥,请尽快配置并轮换!") + print("⚠️ [安全提示] 未设置 JWT_SECRET,当前使用仅供本地开发的默认密钥") # Token 有效期配置(单位:秒) TOKEN_EXPIRE_SECONDS = 7 * 24 * 60 * 60 # 默认7天(向后兼容) @@ -234,19 +241,39 @@ def verify_token(token: str) -> Tuple[bool, Optional[dict], str]: if not hmac.compare_digest(expected_signature, actual_signature): return False, None, "Token 签名无效" + header = json.loads(_base64url_decode(header_b64).decode("utf-8")) + if not isinstance(header, dict) or header.get("alg") != "HS256": + return False, None, "Token 格式错误" # ========== 3. 解析 Payload ========== payload_json = _base64url_decode(payload_b64).decode("utf-8") payload = json.loads(payload_json) # ========== 4. 检查过期时间 ========== + if not isinstance(payload, dict) or not isinstance(payload.get("sub"), str) or not payload['sub']: + return False, None, "Token 格式错误" exp = payload.get("exp", 0) - if time.time() > exp: + if type(exp) not in (int, float) or not math.isfinite(exp): + return False, None, "Token 格式错误" + if time.time() >= exp: return False, None, "Token 已过期,请重新登录" + token_version = payload.get("pwd_ver", 0) + if type(token_version) is not int or token_version < 0: + return False, None, "Token 格式错误" + try: + record = read_user(payload['sub']) + except HTTPException as error: + if error.status_code == 404: + return False, None, "用户凭证已失效,请重新登录" + raise + if token_version != get_password_version(record): + return False, None, "用户凭证已失效,请重新登录" return True, payload, "" - - except Exception as e: - return False, None, f"Token 解析失败: {str(e)}" + + except HTTPException: + raise + except Exception: + return False, None, "Token 解析失败" def get_account_from_token(token: str) -> Optional[str]: @@ -302,7 +329,7 @@ async def require_auth(authorization: str = Header(None, alias="Authorization")) token = parts[1] # 验证 Token(严格 JWT 验证,不再有降级信任) - is_valid, account, error_msg = verify_token_with_fallback(token) + is_valid, account, error_msg = await asyncio.to_thread(verify_token_with_fallback, token) if not is_valid: raise HTTPException(status_code=401, detail=error_msg) @@ -330,8 +357,10 @@ async def optional_auth(authorization: str = Header(None, alias="Authorization") try: return await require_auth(authorization) - except HTTPException: - return None + except HTTPException as error: + if error.status_code == 401: + return None + raise # ========================================== diff --git "a/\346\225\260\346\215\256\345\272\223\350\277\236\346\216\245.py" "b/\346\225\260\346\215\256\345\272\223\350\277\236\346\216\245.py" index d46ab4d13a1f5a3e9f78695e6a11f4f7f9683ad4..73edbf678b1f95cf6a461edbf3924ba81eb6b7b9 100644 --- "a/\346\225\260\346\215\256\345\272\223\350\277\236\346\216\245.py" +++ "b/\346\225\260\346\215\256\345\272\223\350\277\236\346\216\245.py" @@ -31,18 +31,56 @@ from huggingface_hub import HfApi, CommitOperationAdd, hf_hub_download logger = logging.getLogger("ComfyUI-Ranking.DB") +def _safe_error_details(error: Exception) -> str: + """Return diagnostic error metadata without logging URLs, SQL params, or response bodies.""" + status = getattr(getattr(error, "response", None), "status_code", None) + if not isinstance(status, int): + status = getattr(error, "code", None) + suffix = f" status={status}" if isinstance(status, int) and 100 <= status <= 599 else "" + return f"error_type={type(error).__name__}{suffix}" + + # ========================================== # 🔧 配置常量 # ========================================== HF_TOKEN = os.environ.get("HF_TOKEN") DATASET_REPO_ID = "ZHIWEI666/ComfyUI-Ranking" -# 数据目录:云端使用 /tmp,本地使用 cache 子目录 -if os.environ.get("SPACE_ID"): - LOCAL_DB_DIR = "/tmp/local_db_data" -else: - BASE_DIR = os.path.dirname(os.path.abspath(__file__)) - LOCAL_DB_DIR = os.path.join(BASE_DIR, "cache") +def _resolve_data_directory(environ,module_file): + """显式配置优先;原默认路径保持,不自动猜测挂载卷或搬移数据。""" + from pathlib import Path + configured=environ.get('RANKING_DATA_DIR') + if 'RANKING_DATA_DIR' in environ: + if (not isinstance(configured,str) or not configured or configured!=configured.strip() + or any(ord(char)<32 for char in configured)): + raise ValueError('RANKING_DATA_DIR must name an absolute data directory') + path=Path(os.path.normpath(configured)) + if not path.is_absolute() or path==Path(path.anchor): + raise ValueError('RANKING_DATA_DIR must name an absolute data directory') + return os.path.normpath(str(path)) + if environ.get('SPACE_ID'): return '/tmp/local_db_data' + return os.path.join(os.path.dirname(os.path.abspath(module_file)),'cache') + + +def _initialize_data_directories(directory,backup,configured=False): + """显式路径须由部署方预先创建,挂载消失不静默建立新的空数据目录。""" + try: + if configured and not os.path.isdir(directory): + raise OSError('configured data directory unavailable') + os.makedirs(directory,exist_ok=True) + os.makedirs(backup,exist_ok=True) + if configured: + for target in (directory,backup): + with tempfile.TemporaryFile(dir=target) as probe: + probe.write(b'1') + probe.flush() + os.fsync(probe.fileno()) + except (OSError,ValueError) as error: + logger.error('JSON storage initialization failed: %s',_safe_error_details(error)) + raise RuntimeError('JSON data directory unavailable') from None + + +LOCAL_DB_DIR = _resolve_data_directory(os.environ,__file__) # 备份目录:存放上一版本数据 BACKUP_DIR = os.path.join(LOCAL_DB_DIR, "_backups") @@ -53,15 +91,17 @@ api = HfApi() if HF_TOKEN else None # ===== HF 批量同步机制 ===== _dirty_files = set() # 记录自上次同步以来有变更的文件名 _dirty_files_lock = threading.Lock() -_BATCH_SYNC_INTERVAL = 30 # 批量同步间隔(秒),默认30秒(HF限制128 commits/hour,30秒间隔=最多120 commits/hour) -_batch_sync_timer = None # 定时器引用 +_dirty_versions = {} # 写入代次;旧上传成功不能确认后续写入。 +_BATCH_SYNC_INTERVAL = 30 # 项目默认批量窗口(秒);即时请求与多worker另计,实际限流以HF响应为准。 +_sync_scheduler = None # 单工作线程,合并即时请求与周期同步。 +_sync_scheduler_lock = threading.Lock() _sync_backoff_seconds = 0 # 当前退避秒数(0=正常) _sync_consecutive_failures = 0 # 连续失败次数 +_sync_retry_at = 0 # 当前进程的单调时钟重试截止。 _SYNC_MAX_BACKOFF = 300 # 最大退避:5分钟 # 确保目录存在 -os.makedirs(LOCAL_DB_DIR, exist_ok=True) -os.makedirs(BACKUP_DIR, exist_ok=True) +_initialize_data_directories(LOCAL_DB_DIR,BACKUP_DIR,'RANKING_DATA_DIR' in os.environ) # ========================================== @@ -175,11 +215,12 @@ _file_locks = {} _global_lock = threading.Lock() -def _get_file_lock(file_name: str) -> threading.Lock: - """获取指定文件的线程锁(懒加载)""" +def _get_file_lock(file_name: str): + """获取稳定的线程/跨进程协调锁,覆盖完整读改写(懒加载)。""" with _global_lock: if file_name not in _file_locks: - _file_locks[file_name] = threading.Lock() + from json_file_lock import DataFileLock + _file_locks[file_name] = DataFileLock(LOCAL_DB_DIR, file_name) return _file_locks[file_name] @@ -248,7 +289,7 @@ else: # - 文件不存在时从 HF 下载 # - JSON 解析失败时返回默认值 -def load_data(file_name: str, default_data: Optional[Union[Dict, List]] = None, skip_cache: bool = False) -> Union[Dict, List]: +def load_data(file_name: str, default_data: Optional[Union[Dict, List]] = None, skip_cache: bool = False, strict: bool = False) -> Union[Dict, List]: """ 从 JSON 文件加载数据 @@ -292,16 +333,18 @@ def load_data(file_name: str, default_data: Optional[Union[Dict, List]] = None, # 保存到本地缓存 with open(local_path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) - print(f"✅ 从 HF Dataset 下载 {file_name} 成功") + logger.info(f"从 HF Dataset 下载 {file_name} 成功") # 🚀 P1优化:存入内存缓存 _set_to_cache(file_name, data, local_path) return data except Exception as e: + if strict and getattr(getattr(e, "response", None), "status_code", None) != 404: + raise error_msg = str(e) if "404" in error_msg or "Not Found" in error_msg: - print(f"ℹ️ HF 中未找到 {file_name}(尚未创建),使用默认值") + logger.info(f"HF 中未找到 {file_name}(尚未创建),使用默认值") else: - print(f"⚠️ 从 HF 下载 {file_name} 失败: {e}") + logger.warning(f"从 HF 下载 {file_name} 失败: {_safe_error_details(e)}") # 缓存 default_data,避免每次请求都重试下载 _set_to_cache(file_name, default_data, local_path) return default_data @@ -321,11 +364,16 @@ def load_data(file_name: str, default_data: Optional[Union[Dict, List]] = None, _set_to_cache(file_name, data, local_path) return data except json.JSONDecodeError as e: - print(f"🚨 JSON 解析错误 {file_name}: {e}") + logger.error(f"JSON 解析错误 {file_name}: {_safe_error_details(e)}") + if strict: + with open(os.path.join(BACKUP_DIR, f"{file_name}.bak"), "r", encoding="utf-8") as f: + return json.load(f) # 尝试从备份恢复 return _recover_from_backup(file_name, default_data) except Exception as e: - print(f"⚠️ 读取 {file_name} 失败: {e}") + logger.warning(f"读取 {file_name} 失败: {_safe_error_details(e)}") + if strict: + raise return default_data @@ -344,7 +392,7 @@ def _recover_from_backup(file_name: str, default_data: Union[Dict, List]) -> Uni json.dump(data, f, ensure_ascii=False, indent=2) return data except Exception as e: - print(f"🚨 备份恢复失败 {file_name}: {e}") + print(f"🚨 备份恢复失败 {file_name}: {_safe_error_details(e)}") return default_data @@ -388,20 +436,26 @@ def save_data(file_name: str, data: Union[Dict, List], force_sync: bool = False) try: shutil.copy2(local_path, backup_path) except Exception as e: - print(f"⚠️ 备份 {file_name} 失败: {e}") + logger.warning(f"备份 {file_name} 失败: {_safe_error_details(e)}") # ========== 第二步:原子写入 ========== # 使用临时文件 + 重命名,确保写入原子性 - temp_fd, temp_path = tempfile.mkstemp( - suffix=".tmp", - prefix=f"{file_name}_", - dir=LOCAL_DB_DIR - ) + try: + temp_fd, temp_path = tempfile.mkstemp( + suffix=".tmp", + prefix=f"{file_name}_", + dir=LOCAL_DB_DIR + ) + except OSError as e: + logger.error(f"暂存 {file_name} 失败: {_safe_error_details(e)}") + return False try: # 写入临时文件 with os.fdopen(temp_fd, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) + f.flush() + os.fsync(f.fileno()) # ========== 第三步:验证写入成功 ========== with open(temp_path, "r", encoding="utf-8") as f: @@ -414,10 +468,10 @@ def save_data(file_name: str, data: Union[Dict, List], force_sync: bool = False) raise ValueError(f"数据长度不一致: {len(verified_data)} vs {len(data)}") # ========== 第四步:原子重命名 ========== - # Windows 需要先删除目标文件 - if sys.platform == "win32" and os.path.exists(local_path): - os.remove(local_path) - os.rename(temp_path, local_path) + # 同目录原子替换;失败时保留现有文件,Windows 同样不先删除。 + if HF_TOKEN: + _mark_dirty(file_name) + os.replace(temp_path, local_path) # ========== 🚀 P1优化:更新内存缓存(在锁内,确保写后立即可见) ========== _set_to_cache(file_name, data, local_path) @@ -429,14 +483,12 @@ def save_data(file_name: str, data: Union[Dict, List], force_sync: bool = False) os.remove(temp_path) except OSError: pass - print(f"🚨 保存 {file_name} 失败: {e}") + logger.error(f"保存 {file_name} 失败: {_safe_error_details(e)}") # 🔧 回归核查修复:按契约返回 False(旧版 raise 与 -> bool 声明不符,且无调用方依赖异常) return False - # ========== 第五步:标记文件脏,等待批量同步 ========== + # 发布锁内已标记代次;锁外可选触发即时同步。 if HF_TOKEN: - _mark_dirty(file_name) - # ⚡ force_sync=True 时立即同步到 HF Dataset(不等待批量窗口) if force_sync: _immediate_sync_file(file_name) @@ -449,117 +501,210 @@ def save_data(file_name: str, data: Union[Dict, List], force_sync: bool = False) # ========================================== def _immediate_sync_file(file_name: str): - """立即同步单个文件到 HF Dataset(后台线程,不阻塞请求)""" - def _do_sync(): - try: - local_path = os.path.join(LOCAL_DB_DIR, file_name) - if not os.path.exists(local_path): - return - hf_api = HfApi(token=HF_TOKEN) - hf_api.upload_file( - path_or_fileobj=local_path, - path_in_repo=file_name, - repo_id=DATASET_REPO_ID, - repo_type="dataset", - commit_message=f"force sync: {file_name}" - ) - logger.info(f"⚡ 即时同步成功: {file_name}") - # 同步成功后从脏文件集合中移除,避免批量同步重复上传 - with _dirty_files_lock: - _dirty_files.discard(file_name) - except Exception as e: - logger.warning(f"⚡ 即时同步失败(将退回批量同步): {file_name} - {e}") - - thread = threading.Thread(target=_do_sync, daemon=True) - thread.start() + """合并到单一后台工作线程;关闭后保留磁盘意图,不再启动线程。""" + if not HF_TOKEN: + return + try: + _get_sync_scheduler().request(file_name) + except Exception as error: + logger.warning('HF immediate scheduling failed: %s', _safe_error_details(error)) + + +def _run_scheduled_sync(file_names): + _sync_hf_attempt(file_names) + + +def _get_sync_scheduler(): + global _sync_scheduler + from hf_sync_scheduler import SyncScheduler + with _sync_scheduler_lock: + if _sync_scheduler is None: + _sync_scheduler = SyncScheduler( + _run_scheduled_sync, + lambda: _sync_backoff_seconds or _BATCH_SYNC_INTERVAL, + lambda error: logger.warning('HF scheduled sync failed: %s', _safe_error_details(error)), + cooldown=lambda: max(0, _sync_retry_at - time.monotonic())) + return _sync_scheduler def _mark_dirty(file_name: str): """标记文件为脏,等待下次批量同步""" + from hf_sync_journal import SyncJournal + # 先持久写入意图,再发布业务JSON;崩溃可多上传旧内容,但不丢新内容。 + SyncJournal(LOCAL_DB_DIR).mark(file_name) with _dirty_files_lock: + _dirty_versions[file_name] = _dirty_versions.get(file_name, 0) + 1 _dirty_files.add(file_name) -def _batch_sync_to_hf(schedule_next=True): - """批量同步所有脏文件到 HF Dataset(单次 commit)""" - global _sync_consecutive_failures, _sync_backoff_seconds +def _copy_hf_snapshot(source,destination): + """只复制已核对身份的普通文件;不跟随叶节点链接或重新按路径读取。""" + import stat + def identity(info): + if (not stat.S_ISREG(info.st_mode) or not info.st_ino + or getattr(info,'st_file_attributes',0)&1024): + raise ValueError('invalid sync source file') + return (info.st_dev,info.st_ino) + before=identity(os.lstat(source)) + flags=os.O_RDONLY|getattr(os,'O_NOFOLLOW',0)|getattr(os,'O_NONBLOCK',0)|getattr(os,'O_BINARY',0) + descriptor=os.open(source,flags) + try: + opened=identity(os.fstat(descriptor)) + if opened!=before or identity(os.lstat(source))!=opened: + raise ValueError('sync source identity changed') + stream=os.fdopen(descriptor,'rb') + descriptor=None + with stream,open(destination,'xb') as target: + shutil.copyfileobj(stream,target,1024*1024) + finally: + if descriptor is not None: os.close(descriptor) + + +def _sync_files_to_hf(file_names=None): + """锁内快照、串行提交,仅确认本次代次;不持有业务文件锁等待网络。""" + from hf_sync_journal import SyncJournal + from hf_snapshot_store import SnapshotStore + journal=SyncJournal(LOCAL_DB_DIR) + snapshot_store=SnapshotStore(LOCAL_DB_DIR) + local_failure=False + def retain_invalid(error): + nonlocal local_failure + local_failure=True + # 所有同目录新版worker共用提交顺序;取得锁后才创建快照。 + with _get_file_lock('__hf_dataset_sync__'): + _,retained=snapshot_store.cleanup_stale() + if retained:logger.warning('HF unidentified snapshots retained for inspection') + if not HF_TOKEN:return 0 + with _dirty_files_lock: + candidates=set(_dirty_files) + candidates.update(journal.pending(on_invalid=retain_invalid)) + names=sorted(candidates if file_names is None else candidates.intersection(file_names)) + if not names: + if local_failure: logger.warning('HF local sync records unavailable; retained for recovery') + return 0 + with snapshot_store.temporary() as snapshots: + operations=[] + versions={} + for index,file_name in enumerate(names): + try: + with _get_file_lock(file_name): + generation=journal.read(file_name) + with _dirty_files_lock: + if generation is None and file_name not in _dirty_files: continue + version=_dirty_versions.get(file_name,0) + if generation is None: generation=journal.mark(file_name) + local_path=os.path.join(LOCAL_DB_DIR,file_name) + snapshot=os.path.join(snapshots,str(index)+'.json') + _copy_hf_snapshot(local_path,snapshot) + with open(snapshot,'r',encoding='utf-8') as stream: json.load(stream) + operation=CommitOperationAdd(path_in_repo=file_name,path_or_fileobj=snapshot) + operations.append(operation) + versions[file_name]=(version,generation) + except (OSError,ValueError) as error: + retain_invalid(error) + if local_failure: logger.warning('HF local sync records unavailable; retained for recovery') + if not operations: return 0 + HfApi(token=HF_TOKEN).create_commit(repo_id=DATASET_REPO_ID,repo_type='dataset', + operations=operations,commit_message=f"sync: {', '.join(versions)}") + confirmation_failure=False + for file_name,(version,generation) in versions.items(): + try: + with _get_file_lock(file_name): + if journal.acknowledge(file_name,generation): + with _dirty_files_lock: + if _dirty_versions.get(file_name,0)==version: + _dirty_files.discard(file_name) + except (OSError,ValueError): + confirmation_failure=True + if confirmation_failure: logger.warning('HF local confirmation unavailable; retained for recovery') + return len(operations) + + +def _hf_retry_after_seconds(error, status): + """仅从429/503结构化响应头读取等待时间,不读取正文或打印头值。""" + if type(status) is not int or status not in (429, 503): + return 0 + from email.utils import parsedate_to_datetime + from datetime import timezone + import math + try: + headers = getattr(getattr(error, 'response', None), 'headers', None) + if headers is None: + return 0 + value = headers.get('Retry-After') + if value is None: + value = headers.get('retry-after') + if not isinstance(value, str) or len(value) > 128: + return 0 + if any(ord(char) < 32 and char != '\t' for char in value): + return 0 + value = value.strip(' \t') + if value and all('0' <= char <= '9' for char in value): + return int(value) + date = parsedate_to_datetime(value) + if date.tzinfo is None: + date = date.replace(tzinfo=timezone.utc) + seconds = date.timestamp() - time.time() + return max(0, math.ceil(seconds)) if math.isfinite(seconds) else 0 + except Exception: + # 坏日期/头类型/异常header容器不打断本地保存或原退避。 + return 0 - # 取出脏文件列表并清空 - with _dirty_files_lock: - files_to_sync = list(_dirty_files) - _dirty_files.clear() - - if not files_to_sync: - if schedule_next: - _schedule_next_sync() - return +def _sync_hf_attempt(file_names=None): + """即时/周期共用结果与有界退避;关闭前显式尝试不等待冷却。""" + global _sync_consecutive_failures, _sync_backoff_seconds, _sync_retry_at try: - operations = [] - for file_name in files_to_sync: - local_path = os.path.join(LOCAL_DB_DIR, file_name) - if os.path.exists(local_path): - operations.append( - CommitOperationAdd( - path_in_repo=file_name, - path_or_fileobj=local_path - ) - ) - - if operations: - hf_api = HfApi(token=HF_TOKEN) - hf_api.create_commit( - repo_id=DATASET_REPO_ID, - repo_type="dataset", - operations=operations, - commit_message=f"batch sync: {', '.join(files_to_sync)}" - ) - logger.info(f"✅ 批量同步成功: {len(operations)} 个文件 ({', '.join(files_to_sync)})") - # 同步成功:重置退避状态 + count = _sync_files_to_hf(file_names) + if count: + logger.info(f"✅ HF同步成功: {count} 个文件") _sync_consecutive_failures = 0 _sync_backoff_seconds = 0 - else: - logger.info("⏭️ 批量同步: 脏文件均不存在,跳过") - except Exception as e: - logger.error(f"🚨 批量同步失败: {e}") - # 失败的文件重新标记为脏,下次重试 - with _dirty_files_lock: - _dirty_files.update(files_to_sync) - # 429 限流:启用指数退避 - if "429" in str(e) or "Too Many Requests" in str(e): - _sync_consecutive_failures += 1 - _sync_backoff_seconds = min( - _BATCH_SYNC_INTERVAL * (2 ** _sync_consecutive_failures), - _SYNC_MAX_BACKOFF - ) - logger.warning(f"⚠️ HF 限流,退避 {_sync_backoff_seconds} 秒后重试") + _sync_retry_at = 0 + return count + except Exception as error: + # 按结构化状态区分限流,不搜索可能含URL/凭据的异常正文。 + status = getattr(getattr(error, 'response', None), 'status_code', None) + if type(status) is not int: + status = getattr(error, 'code', None) + limited = type(status) is int and status == 429 + _sync_consecutive_failures = min(_sync_consecutive_failures + 1, 32) + _sync_backoff_seconds = min( + max(_BATCH_SYNC_INTERVAL, _sync_backoff_seconds) * 2, _SYNC_MAX_BACKOFF) + retry_seconds = max(_sync_backoff_seconds, _hf_retry_after_seconds(error, status)) + _sync_retry_at = time.monotonic() + retry_seconds + logger.error('HF sync failed: %s', _safe_error_details(error)) + logger.warning('HF %s, retry in %s seconds', + 'rate limited' if limited else 'sync unavailable', retry_seconds) + return 0 + + +def _batch_sync_to_hf(schedule_next=True): + """批量同步所有脏文件;失败保留意图,退避由统一尝试管理。""" + try: + _sync_hf_attempt() finally: if schedule_next: _schedule_next_sync() def _schedule_next_sync(): - """调度下一轮批量同步""" - global _batch_sync_timer, _sync_backoff_seconds - interval = _sync_backoff_seconds if _sync_backoff_seconds > 0 else _BATCH_SYNC_INTERVAL - _batch_sync_timer = threading.Timer(interval, _batch_sync_to_hf) - _batch_sync_timer.daemon = True - _batch_sync_timer.start() + """兼容单次批量调用;幂等确保工作线程,关闭后不自动重启。""" + if HF_TOKEN: + _get_sync_scheduler().start() def start_batch_sync(): - """启动批量同步定时器(在 app 启动时调用)""" + """启动同步工作线程;仅显式启动可重新打开已关闭的调度器。""" if HF_TOKEN: - _schedule_next_sync() - logger.info(f"📡 HF 批量同步已启动,间隔 {_BATCH_SYNC_INTERVAL} 秒") + if _get_sync_scheduler().start(restart=True): + logger.info(f"📡 HF 批量同步已启动,间隔 {_BATCH_SYNC_INTERVAL} 秒") def flush_sync(): """立即同步所有脏文件(用于服务关闭前)""" - global _batch_sync_timer - if _batch_sync_timer: - _batch_sync_timer.cancel() - _batch_sync_timer = None + if HF_TOKEN: + _get_sync_scheduler().close() logger.info("🔄 正在执行关闭前同步...") _batch_sync_to_hf(schedule_next=False) @@ -626,7 +771,7 @@ def force_sync_from_hf(file_name: str) -> bool: return True except Exception as e: - print(f"🚨 强制同步 {file_name} 失败: {e}") + print(f"🚨 强制同步 {file_name} 失败: {_safe_error_details(e)}") return False @@ -670,6 +815,7 @@ def atomic_update(file_name: str, updater, default_data=None, force_sync: bool = if default_data is None: default_data = {} if file_name == "users.json" else [] + recovered_from_backup = False local_path = os.path.join(LOCAL_DB_DIR, file_name) file_lock = _get_file_lock(file_name) @@ -688,17 +834,12 @@ def atomic_update(file_name: str, updater, default_data=None, force_sync: bool = ) with open(downloaded_path, "r", encoding="utf-8") as f: data = json.load(f) - # 保存到本地 - with open(local_path, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) except Exception as e: - error_msg = str(e) - if "404" in error_msg or "Not Found" in error_msg: - logger.info(f"HF 中未找到 {file_name}(尚未创建),使用默认值") - else: - logger.warning(f"从 HF 下载 {file_name} 失败: {e}") - # 缓存 default_data,避免每次请求都重试下载 - _set_to_cache(file_name, default_data, local_path) + status = getattr(getattr(e, "response", None), "status_code", None) + if status != 404: + logger.warning(f"从 HF 下载 {file_name} 失败: {_safe_error_details(e)}") + raise + logger.info(f"HF 中未找到 {file_name}(尚未创建),使用默认值") data = default_data else: data = default_data @@ -712,13 +853,15 @@ def atomic_update(file_name: str, updater, default_data=None, force_sync: bool = finally: _unlock_file(f) except json.JSONDecodeError as e: - logger.error(f"JSON 解析错误 {file_name}: {e}") - # 尝试从备份恢复 - data = _recover_from_backup(file_name, default_data) + logger.error(f"JSON 解析错误 {file_name}: {_safe_error_details(e)}") + recovered_from_backup = True + # 锁内读取有效备份;没有可读备份时停止更新,不能拿默认值覆盖历史。 + with open(os.path.join(BACKUP_DIR, f"{file_name}.bak"), "r", encoding="utf-8") as f: + data = json.load(f) except Exception as e: - logger.warning(f"读取 {file_name} 失败: {e}") - data = default_data - + logger.warning(f"读取 {file_name} 失败: {_safe_error_details(e)}") + raise + # ========== 第二步:在锁内执行更新操作 ========== result = updater(data) @@ -726,11 +869,11 @@ def atomic_update(file_name: str, updater, default_data=None, force_sync: bool = backup_path = os.path.join(BACKUP_DIR, f"{file_name}.bak") # 备份现有文件 - if os.path.exists(local_path): + if os.path.exists(local_path) and not recovered_from_backup: try: shutil.copy2(local_path, backup_path) except Exception as e: - logger.warning(f"备份 {file_name} 失败: {e}") + logger.warning(f"备份 {file_name} 失败: {_safe_error_details(e)}") # 原子写入:临时文件 + 重命名 temp_fd, temp_path = tempfile.mkstemp( @@ -742,6 +885,8 @@ def atomic_update(file_name: str, updater, default_data=None, force_sync: bool = try: with os.fdopen(temp_fd, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) + f.flush() + os.fsync(f.fileno()) # 验证写入成功 with open(temp_path, "r", encoding="utf-8") as f: @@ -753,23 +898,24 @@ def atomic_update(file_name: str, updater, default_data=None, force_sync: bool = raise ValueError(f"数据长度不一致: {len(verified_data)} vs {len(data)}") # 原子重命名 - if sys.platform == "win32" and os.path.exists(local_path): - os.remove(local_path) - os.rename(temp_path, local_path) + if HF_TOKEN: + _mark_dirty(file_name) + os.replace(temp_path, local_path) # ========== 更新内存缓存(在锁内,确保写后立即可见) ========== _set_to_cache(file_name, data, local_path) except Exception as e: if os.path.exists(temp_path): - os.remove(temp_path) - logger.error(f"保存 {file_name} 失败: {e}") + try: + os.remove(temp_path) + except OSError: + pass + logger.error(f"保存 {file_name} 失败: {_safe_error_details(e)}") raise - # ========== 第五步:标记文件脏,等待批量同步 ========== + # 发布锁内已标记代次;锁外可选触发即时同步。 if HF_TOKEN: - _mark_dirty(file_name) - # ⚡ force_sync=True 时立即同步到 HF Dataset(不等待批量窗口) if force_sync: _immediate_sync_file(file_name) diff --git "a/\346\265\213\350\257\225\350\204\232\346\234\254.py" "b/\346\265\213\350\257\225\350\204\232\346\234\254.py" index d6f1454935efc692e6ec1a2b016c6a031d4cfb01..107e50dc70090adac67c66090c3f876bcb55986a 100644 --- "a/\346\265\213\350\257\225\350\204\232\346\234\254.py" +++ "b/\346\265\213\350\257\225\350\204\232\346\234\254.py" @@ -33,6 +33,11 @@ class TestAuthSecurity(unittest.TestCase): self.verify_password = verify_password self.create_token = create_token self.verify_token = verify_token + self.password_version = 0 + reader = patch("安全认证.read_user", side_effect=lambda account: { + "account": account, "password_version": self.password_version}) + reader.start() + self.addCleanup(reader.stop) def test_password_hash_unique_and_verifiable(self): """测试:bcrypt 每次加盐不同,哈希唯一且可验证""" @@ -104,6 +109,15 @@ class TestAuthSecurity(unittest.TestCase): self.assertEqual(payload.get("sub"), account, "验证后的账号应正确") self.assertEqual(payload.get("role"), "admin", "额外数据应保留在 payload 中") + def test_password_version_revokes_previous_token(self): + """密码版本改变后,旧签名 Token 也必须失效。""" + old = self.create_token("test_user", extra_data={"pwd_ver": 0}) + self.assertTrue(self.verify_token(old)[0]) + self.password_version = 1 + self.assertFalse(self.verify_token(old)[0]) + current = self.create_token("test_user", extra_data={"pwd_ver": 1}) + self.assertTrue(self.verify_token(current)[0]) + def test_mock_token_rejected(self): """测试:旧版 mock_token 必须被拒绝(P0安全后门已移除)""" from 安全认证 import verify_token_with_fallback