Spaces:
Running
Running
公测更新,大量优化
Browse files- .dockerignore +6 -1
- Dockerfile +7 -0
- app.py +18 -13
- database_sql.py +48 -23
- image_moderation.py +6 -6
- models.py +22 -0
- notifications.py +14 -5
- rate_limiter.py +30 -0
- router_comments.py +27 -5
- router_items.py +13 -4
- router_messages.py +21 -7
- router_posts.py +3 -0
- router_proxy.py +26 -5
- router_tasks.py +11 -10
- router_users_auth.py +16 -5
- router_users_profile.py +7 -2
- router_users_social.py +11 -3
- router_wallet.py +50 -30
- 云端_定时版本检测引擎.py +75 -59
- 安全认证.py +12 -5
- 数据库连接.py +28 -16
.dockerignore
CHANGED
|
@@ -4,4 +4,9 @@ __pycache__
|
|
| 4 |
/cache
|
| 5 |
/tmp
|
| 6 |
*.zip
|
| 7 |
-
*.log
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
/cache
|
| 5 |
/tmp
|
| 6 |
*.zip
|
| 7 |
+
*.log
|
| 8 |
+
data/
|
| 9 |
+
.env
|
| 10 |
+
*.bak
|
| 11 |
+
backups/
|
| 12 |
+
一次性文件已完成/
|
Dockerfile
CHANGED
|
@@ -18,5 +18,12 @@ RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
|
|
| 18 |
# 复制应用代码
|
| 19 |
COPY . .
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
# 启动命令
|
| 22 |
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
|
|
|
| 18 |
# 复制应用代码
|
| 19 |
COPY . .
|
| 20 |
|
| 21 |
+
# 安全加固:以非root用户运行
|
| 22 |
+
RUN useradd -m -s /bin/bash appuser && chown -R appuser:appuser /code
|
| 23 |
+
USER appuser
|
| 24 |
+
|
| 25 |
+
# 健康检查
|
| 26 |
+
HEALTHCHECK --interval=30s --timeout=5s --retries=3 CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:7860/health')" || exit 1
|
| 27 |
+
|
| 28 |
# 启动命令
|
| 29 |
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
app.py
CHANGED
|
@@ -69,7 +69,7 @@ limiter = Limiter(key_func=get_remote_address)
|
|
| 69 |
app = FastAPI(
|
| 70 |
title="ComfyUI Ranking API",
|
| 71 |
description="ComfyUI 社区排名系统 API",
|
| 72 |
-
version="
|
| 73 |
docs_url="/api/docs",
|
| 74 |
redoc_url="/api/redoc"
|
| 75 |
)
|
|
@@ -246,9 +246,13 @@ async def on_shutdown():
|
|
| 246 |
# 这里可以添加其他清理逻辑(如关闭连接池等)
|
| 247 |
logger.info("✅ 关闭完成")
|
| 248 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
app.add_middleware(
|
| 250 |
CORSMiddleware,
|
| 251 |
-
allow_origins=
|
| 252 |
allow_credentials=False,
|
| 253 |
allow_methods=["*"],
|
| 254 |
allow_headers=["*"],
|
|
@@ -289,6 +293,10 @@ def proxy_hf_image(url: str = None, path: str = None):
|
|
| 289 |
|
| 290 |
if not path:
|
| 291 |
raise HTTPException(status_code=400, detail="缺少路径参数")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
|
| 293 |
# 🛡️ 绝对的安全红线:限制只能代理下载图片目录,严禁黑客通过此接口下载 users.json 或账本数据!
|
| 294 |
allowed_dirs = ["uploads/", "avatars/", "covers/"]
|
|
@@ -341,6 +349,9 @@ def upload_file(file: UploadFile = File(...), file_type: str = Form(...), curren
|
|
| 341 |
if ext not in allowed_exts:
|
| 342 |
raise HTTPException(status_code=400, detail=f"不支持的文件格式,{file_type} 类型仅支持 {', '.join(allowed_exts)}")
|
| 343 |
|
|
|
|
|
|
|
|
|
|
| 344 |
# 🔒 P0安全优化:图片内容审核
|
| 345 |
if ext in ["jpg", "jpeg", "png", "gif", "webp"]:
|
| 346 |
moderation_result = moderate_image_sync(content, ext)
|
|
@@ -467,18 +478,12 @@ def validate_resource(req_data: ValidateResourceRequest, sql_db: Session = Depen
|
|
| 467 |
# ==========================================
|
| 468 |
# 🔒 管理员:系统配置 API
|
| 469 |
# ==========================================
|
| 470 |
-
from 安全认证 import require_auth
|
| 471 |
-
|
| 472 |
-
# 🔒 管理员账号列表(从环境变量读取)
|
| 473 |
-
ADMIN_ACCOUNTS = set(
|
| 474 |
-
acc.strip()
|
| 475 |
-
for acc in os.environ.get("ADMIN_ACCOUNTS", "").split(",")
|
| 476 |
-
if acc.strip()
|
| 477 |
-
)
|
| 478 |
|
| 479 |
def _is_admin(account: str) -> bool:
|
| 480 |
"""检查账号是否为管理员"""
|
| 481 |
-
|
|
|
|
| 482 |
|
| 483 |
# 配置存储文件
|
| 484 |
SYSTEM_CONFIG_FILE = "/tmp/system_config.json"
|
|
@@ -551,8 +556,8 @@ async def get_system_config(config_key: str, current_user: str = Depends(require
|
|
| 551 |
|
| 552 |
# 🏷️ 版本配置默认值
|
| 553 |
DEFAULT_VERSION_CONFIG = {
|
| 554 |
-
"stage": "
|
| 555 |
-
"major":
|
| 556 |
"minor": 0,
|
| 557 |
"patch": 0
|
| 558 |
}
|
|
|
|
| 69 |
app = FastAPI(
|
| 70 |
title="ComfyUI Ranking API",
|
| 71 |
description="ComfyUI 社区排名系统 API",
|
| 72 |
+
version="2.0.0",
|
| 73 |
docs_url="/api/docs",
|
| 74 |
redoc_url="/api/redoc"
|
| 75 |
)
|
|
|
|
| 246 |
# 这里可以添加其他清理逻辑(如关闭连接池等)
|
| 247 |
logger.info("✅ 关闭完成")
|
| 248 |
|
| 249 |
+
# 🛡️ CORS 安全配置:从环境变量读取允许的域名,收紧通配符
|
| 250 |
+
ALLOWED_ORIGINS = os.environ.get("ALLOWED_ORIGINS", "https://zhiwei666-comfyui-ranking-api.hf.space").split(",")
|
| 251 |
+
logger.info(f"CORS 允许域名: {ALLOWED_ORIGINS}")
|
| 252 |
+
|
| 253 |
app.add_middleware(
|
| 254 |
CORSMiddleware,
|
| 255 |
+
allow_origins=ALLOWED_ORIGINS,
|
| 256 |
allow_credentials=False,
|
| 257 |
allow_methods=["*"],
|
| 258 |
allow_headers=["*"],
|
|
|
|
| 293 |
|
| 294 |
if not path:
|
| 295 |
raise HTTPException(status_code=400, detail="缺少路径参数")
|
| 296 |
+
|
| 297 |
+
# 🛡️ 路径穿越防护:禁止包含 .. 和绝对路径
|
| 298 |
+
if ".." in path or path.startswith("/"):
|
| 299 |
+
raise HTTPException(status_code=400, detail="非法路径")
|
| 300 |
|
| 301 |
# 🛡️ 绝对的安全红线:限制只能代理下载图片目录,严禁黑客通过此接口下载 users.json 或账本数据!
|
| 302 |
allowed_dirs = ["uploads/", "avatars/", "covers/"]
|
|
|
|
| 349 |
if ext not in allowed_exts:
|
| 350 |
raise HTTPException(status_code=400, detail=f"不支持的文件格式,{file_type} 类型仅支持 {', '.join(allowed_exts)}")
|
| 351 |
|
| 352 |
+
# 📝 审计日志:记录上传操作
|
| 353 |
+
logger.info(f"UPLOAD | account={current_user} | filename={file.filename} | file_type={file_type}")
|
| 354 |
+
|
| 355 |
# 🔒 P0安全优化:图片内容审核
|
| 356 |
if ext in ["jpg", "jpeg", "png", "gif", "webp"]:
|
| 357 |
moderation_result = moderate_image_sync(content, ext)
|
|
|
|
| 478 |
# ==========================================
|
| 479 |
# 🔒 管理员:系统配置 API
|
| 480 |
# ==========================================
|
| 481 |
+
from 安全认证 import require_auth, ADMIN_ACCOUNTS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 482 |
|
| 483 |
def _is_admin(account: str) -> bool:
|
| 484 |
"""检查账号是否为管理员"""
|
| 485 |
+
from 安全认证 import is_admin as _check_admin
|
| 486 |
+
return _check_admin(account)
|
| 487 |
|
| 488 |
# 配置存储文件
|
| 489 |
SYSTEM_CONFIG_FILE = "/tmp/system_config.json"
|
|
|
|
| 556 |
|
| 557 |
# 🏷️ 版本配置默认值
|
| 558 |
DEFAULT_VERSION_CONFIG = {
|
| 559 |
+
"stage": "beta",
|
| 560 |
+
"major": 2,
|
| 561 |
"minor": 0,
|
| 562 |
"patch": 0
|
| 563 |
}
|
database_sql.py
CHANGED
|
@@ -110,6 +110,7 @@ def _auto_migrate_p7_fields():
|
|
| 110 |
"""
|
| 111 |
🔄 P7后悔模式:自动迁移新增字段
|
| 112 |
检查并添加 ownerships 表和 transactions 表的新字段
|
|
|
|
| 113 |
"""
|
| 114 |
from sqlalchemy import inspect
|
| 115 |
|
|
@@ -122,22 +123,34 @@ def _auto_migrate_p7_fields():
|
|
| 122 |
|
| 123 |
with engine.connect() as conn:
|
| 124 |
if 'price_paid' not in columns:
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
if 'is_refunded' not in columns:
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
if 'refunded_at' not in columns:
|
| 139 |
-
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
|
| 142 |
conn.commit()
|
| 143 |
|
|
@@ -147,11 +160,14 @@ def _auto_migrate_p7_fields():
|
|
| 147 |
|
| 148 |
with engine.connect() as conn:
|
| 149 |
if 'task_balance' not in columns:
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
| 155 |
|
| 156 |
conn.commit()
|
| 157 |
|
|
@@ -175,12 +191,21 @@ def _auto_migrate_p7_fields():
|
|
| 175 |
|
| 176 |
for col_name, col_type in new_columns.items():
|
| 177 |
if col_name not in columns:
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
|
| 185 |
conn.commit()
|
| 186 |
|
|
|
|
| 110 |
"""
|
| 111 |
🔄 P7后悔模式:自动迁移新增字段
|
| 112 |
检查并添加 ownerships 表和 transactions 表的新字段
|
| 113 |
+
所有 ALTER TABLE ADD COLUMN 均使用 IF NOT EXISTS 或 inspector 预检,避免重复执行报错
|
| 114 |
"""
|
| 115 |
from sqlalchemy import inspect
|
| 116 |
|
|
|
|
| 123 |
|
| 124 |
with engine.connect() as conn:
|
| 125 |
if 'price_paid' not in columns:
|
| 126 |
+
try:
|
| 127 |
+
if 'sqlite' in SQLALCHEMY_DATABASE_URL:
|
| 128 |
+
conn.execute(text("ALTER TABLE ownerships ADD COLUMN price_paid INTEGER DEFAULT 0"))
|
| 129 |
+
else:
|
| 130 |
+
conn.execute(text("ALTER TABLE ownerships ADD COLUMN IF NOT EXISTS price_paid INTEGER DEFAULT 0"))
|
| 131 |
+
logger.info("迁移完成: 添加 ownerships.price_paid 字段")
|
| 132 |
+
except Exception as e:
|
| 133 |
+
logger.debug(f"跳过 ownerships.price_paid (可能已存在): {e}")
|
| 134 |
|
| 135 |
if 'is_refunded' not in columns:
|
| 136 |
+
try:
|
| 137 |
+
if 'sqlite' in SQLALCHEMY_DATABASE_URL:
|
| 138 |
+
conn.execute(text("ALTER TABLE ownerships ADD COLUMN is_refunded BOOLEAN DEFAULT 0"))
|
| 139 |
+
else:
|
| 140 |
+
conn.execute(text("ALTER TABLE ownerships ADD COLUMN IF NOT EXISTS is_refunded BOOLEAN DEFAULT FALSE"))
|
| 141 |
+
logger.info("迁移完成: 添加 ownerships.is_refunded 字段")
|
| 142 |
+
except Exception as e:
|
| 143 |
+
logger.debug(f"跳过 ownerships.is_refunded (可能已存在): {e}")
|
| 144 |
|
| 145 |
if 'refunded_at' not in columns:
|
| 146 |
+
try:
|
| 147 |
+
if 'sqlite' in SQLALCHEMY_DATABASE_URL:
|
| 148 |
+
conn.execute(text("ALTER TABLE ownerships ADD COLUMN refunded_at TIMESTAMP"))
|
| 149 |
+
else:
|
| 150 |
+
conn.execute(text("ALTER TABLE ownerships ADD COLUMN IF NOT EXISTS refunded_at TIMESTAMP"))
|
| 151 |
+
logger.info("迁移完成: 添加 ownerships.refunded_at 字段")
|
| 152 |
+
except Exception as e:
|
| 153 |
+
logger.debug(f"跳过 ownerships.refunded_at (可能已存在): {e}")
|
| 154 |
|
| 155 |
conn.commit()
|
| 156 |
|
|
|
|
| 160 |
|
| 161 |
with engine.connect() as conn:
|
| 162 |
if 'task_balance' not in columns:
|
| 163 |
+
try:
|
| 164 |
+
if 'sqlite' in SQLALCHEMY_DATABASE_URL:
|
| 165 |
+
conn.execute(text("ALTER TABLE wallets ADD COLUMN task_balance INTEGER DEFAULT 0"))
|
| 166 |
+
else:
|
| 167 |
+
conn.execute(text("ALTER TABLE wallets ADD COLUMN IF NOT EXISTS task_balance INTEGER DEFAULT 0"))
|
| 168 |
+
logger.info("[DB Migration] 添加列 wallets.task_balance")
|
| 169 |
+
except Exception as e:
|
| 170 |
+
logger.debug(f"跳过 wallets.task_balance (可能已存在): {e}")
|
| 171 |
|
| 172 |
conn.commit()
|
| 173 |
|
|
|
|
| 191 |
|
| 192 |
for col_name, col_type in new_columns.items():
|
| 193 |
if col_name not in columns:
|
| 194 |
+
try:
|
| 195 |
+
# VARCHAR 类型添加 DEFAULT NULL 避免 PostgreSQL NOT NULL 冲突
|
| 196 |
+
if col_type == 'VARCHAR':
|
| 197 |
+
if 'sqlite' in SQLALCHEMY_DATABASE_URL:
|
| 198 |
+
conn.execute(text(f"ALTER TABLE transactions ADD COLUMN {col_name} {col_type} DEFAULT NULL"))
|
| 199 |
+
else:
|
| 200 |
+
conn.execute(text(f"ALTER TABLE transactions ADD COLUMN IF NOT EXISTS {col_name} {col_type} DEFAULT NULL"))
|
| 201 |
+
else:
|
| 202 |
+
if 'sqlite' in SQLALCHEMY_DATABASE_URL:
|
| 203 |
+
conn.execute(text(f"ALTER TABLE transactions ADD COLUMN {col_name} {col_type}"))
|
| 204 |
+
else:
|
| 205 |
+
conn.execute(text(f"ALTER TABLE transactions ADD COLUMN IF NOT EXISTS {col_name} {col_type}"))
|
| 206 |
+
logger.info(f"[DB Migration] 添加列 transactions.{col_name}")
|
| 207 |
+
except Exception as e:
|
| 208 |
+
logger.debug(f"跳过 transactions.{col_name} (可能已存在): {e}")
|
| 209 |
|
| 210 |
conn.commit()
|
| 211 |
|
image_moderation.py
CHANGED
|
@@ -221,9 +221,9 @@ async def moderate_image_tencent(image_content: bytes) -> ModerationResult:
|
|
| 221 |
|
| 222 |
except Exception as e:
|
| 223 |
logger.error(f"腾讯云审核异常: {str(e)}")
|
| 224 |
-
# 审核服务异常时
|
| 225 |
-
return ModerationResult(passed=
|
| 226 |
-
details={"error":
|
| 227 |
|
| 228 |
# ==========================================
|
| 229 |
# 🟠 阿里云图片审核
|
|
@@ -367,9 +367,9 @@ async def moderate_image_aliyun(image_content: bytes) -> ModerationResult:
|
|
| 367 |
|
| 368 |
except Exception as e:
|
| 369 |
logger.error(f"阿里云审核异常: {str(e)}")
|
| 370 |
-
# 审核服务异常时
|
| 371 |
-
return ModerationResult(passed=
|
| 372 |
-
details={"error":
|
| 373 |
|
| 374 |
# ==========================================
|
| 375 |
# 🎯 统一审核入口(智能额度管理)
|
|
|
|
| 221 |
|
| 222 |
except Exception as e:
|
| 223 |
logger.error(f"腾讯云审核异常: {str(e)}")
|
| 224 |
+
# 审核服务异常时拒绝,需要人工审核
|
| 225 |
+
return ModerationResult(passed=False, suggestion="review",
|
| 226 |
+
details={"error": "审核服务暂时不可用,需人工审核"})
|
| 227 |
|
| 228 |
# ==========================================
|
| 229 |
# 🟠 阿里云图片审核
|
|
|
|
| 367 |
|
| 368 |
except Exception as e:
|
| 369 |
logger.error(f"阿里云审核异常: {str(e)}")
|
| 370 |
+
# 审核服务异常时拒绝,需要人工审核
|
| 371 |
+
return ModerationResult(passed=False, suggestion="review",
|
| 372 |
+
details={"error": "审核服务暂时不可用,需人工审核"})
|
| 373 |
|
| 374 |
# ==========================================
|
| 375 |
# 🎯 统一审核入口(智能额度管理)
|
models.py
CHANGED
|
@@ -1,7 +1,22 @@
|
|
| 1 |
# models.py
|
|
|
|
| 2 |
from pydantic import BaseModel, Field, validator
|
| 3 |
from typing import Optional, List
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
class SendCodeRequest(BaseModel):
|
| 6 |
contact: str
|
| 7 |
contact_type: str
|
|
@@ -21,11 +36,16 @@ class UserRegister(BaseModel):
|
|
| 21 |
country: Optional[str] = None
|
| 22 |
region: Optional[str] = None
|
| 23 |
intro: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
class UserLogin(BaseModel):
|
| 26 |
account: str
|
| 27 |
password: str
|
| 28 |
remember: Optional[bool] = True # 保持登录选项:True=30天, False=24小时
|
|
|
|
|
|
|
| 29 |
|
| 30 |
class UserUpdate(BaseModel):
|
| 31 |
name: Optional[str] = None
|
|
@@ -45,6 +65,8 @@ class PasswordReset(BaseModel):
|
|
| 45 |
verifyType: str
|
| 46 |
code: str
|
| 47 |
account: str
|
|
|
|
|
|
|
| 48 |
|
| 49 |
class CommentCreate(BaseModel):
|
| 50 |
item_id: str
|
|
|
|
| 1 |
# models.py
|
| 2 |
+
import re
|
| 3 |
from pydantic import BaseModel, Field, validator
|
| 4 |
from typing import Optional, List
|
| 5 |
|
| 6 |
+
def _validate_email_format(email: str) -> str:
|
| 7 |
+
"""邮箱格式校验"""
|
| 8 |
+
if not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', email):
|
| 9 |
+
raise ValueError('邮箱格式不正确')
|
| 10 |
+
return email
|
| 11 |
+
|
| 12 |
+
def _validate_password_length(password: str) -> str:
|
| 13 |
+
"""密码长度校验:8-64字符"""
|
| 14 |
+
if len(password) < 8:
|
| 15 |
+
raise ValueError('密码长度不能少于8个字符')
|
| 16 |
+
if len(password) > 64:
|
| 17 |
+
raise ValueError('密码长度不能超过64个字符')
|
| 18 |
+
return password
|
| 19 |
+
|
| 20 |
class SendCodeRequest(BaseModel):
|
| 21 |
contact: str
|
| 22 |
contact_type: str
|
|
|
|
| 36 |
country: Optional[str] = None
|
| 37 |
region: Optional[str] = None
|
| 38 |
intro: Optional[str] = None
|
| 39 |
+
|
| 40 |
+
_validate_password = validator('password', allow_reuse=True)(_validate_password_length)
|
| 41 |
+
_validate_email = validator('email', allow_reuse=True)(_validate_email_format)
|
| 42 |
|
| 43 |
class UserLogin(BaseModel):
|
| 44 |
account: str
|
| 45 |
password: str
|
| 46 |
remember: Optional[bool] = True # 保持登录选项:True=30天, False=24小时
|
| 47 |
+
|
| 48 |
+
_validate_password = validator('password', allow_reuse=True)(_validate_password_length)
|
| 49 |
|
| 50 |
class UserUpdate(BaseModel):
|
| 51 |
name: Optional[str] = None
|
|
|
|
| 65 |
verifyType: str
|
| 66 |
code: str
|
| 67 |
account: str
|
| 68 |
+
|
| 69 |
+
_validate_new_password = validator('new_password', allow_reuse=True)(_validate_password_length)
|
| 70 |
|
| 71 |
class CommentCreate(BaseModel):
|
| 72 |
item_id: str
|
notifications.py
CHANGED
|
@@ -5,18 +5,27 @@ import 数据库连接 as db
|
|
| 5 |
def add_notification(target_account: str, notif_data: dict):
|
| 6 |
try:
|
| 7 |
from_user = notif_data.get("from_user")
|
| 8 |
-
if
|
| 9 |
-
return
|
|
|
|
|
|
|
| 10 |
|
| 11 |
users_db = db.load_data("users.json", default_data={})
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
notif = {
|
| 15 |
"id": f"msg_{int(time.time())}_{uuid.uuid4().hex[:6]}",
|
| 16 |
"type": notif_data.get("type"),
|
| 17 |
"from_user": from_user,
|
| 18 |
-
"from_name":
|
| 19 |
-
"from_avatar":
|
| 20 |
"target_item_id": notif_data.get("target_item_id", ""),
|
| 21 |
"target_item_title": notif_data.get("target_item_title", ""),
|
| 22 |
"content": notif_data.get("content", ""),
|
|
|
|
| 5 |
def add_notification(target_account: str, notif_data: dict):
|
| 6 |
try:
|
| 7 |
from_user = notif_data.get("from_user")
|
| 8 |
+
if not target_account:
|
| 9 |
+
return
|
| 10 |
+
if from_user and target_account == from_user:
|
| 11 |
+
return # 不给自己发通知
|
| 12 |
|
| 13 |
users_db = db.load_data("users.json", default_data={})
|
| 14 |
+
|
| 15 |
+
if from_user == "system" or not from_user:
|
| 16 |
+
from_name = "系统通知"
|
| 17 |
+
from_avatar = ""
|
| 18 |
+
else:
|
| 19 |
+
user_info = users_db.get(from_user, {})
|
| 20 |
+
from_name = user_info.get("name", from_user)
|
| 21 |
+
from_avatar = user_info.get("avatarDataUrl", "")
|
| 22 |
|
| 23 |
notif = {
|
| 24 |
"id": f"msg_{int(time.time())}_{uuid.uuid4().hex[:6]}",
|
| 25 |
"type": notif_data.get("type"),
|
| 26 |
"from_user": from_user,
|
| 27 |
+
"from_name": from_name,
|
| 28 |
+
"from_avatar": from_avatar,
|
| 29 |
"target_item_id": notif_data.get("target_item_id", ""),
|
| 30 |
"target_item_title": notif_data.get("target_item_title", ""),
|
| 31 |
"content": notif_data.get("content", ""),
|
rate_limiter.py
CHANGED
|
@@ -78,6 +78,28 @@ def get_limit(action: str) -> str:
|
|
| 78 |
user_request_counts = {}
|
| 79 |
USER_LIMIT_WINDOW = 60 # 时间窗口(秒)
|
| 80 |
USER_LIMIT_MAX = 100 # 每用户每分钟最大请求数
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
|
| 83 |
def check_user_rate_limit(account: str) -> bool:
|
|
@@ -91,6 +113,10 @@ def check_user_rate_limit(account: str) -> bool:
|
|
| 91 |
now = time.time()
|
| 92 |
key = f"user:{account}"
|
| 93 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
# 清理过期记录
|
| 95 |
if key in user_request_counts:
|
| 96 |
user_request_counts[key] = [
|
|
@@ -141,6 +167,10 @@ def record_request(ip: str, endpoint: str):
|
|
| 141 |
now = time.time()
|
| 142 |
key = f"{ip}:{endpoint}"
|
| 143 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
if key not in ip_stats:
|
| 145 |
ip_stats[key] = {"count": 0, "first_request": now}
|
| 146 |
|
|
|
|
| 78 |
user_request_counts = {}
|
| 79 |
USER_LIMIT_WINDOW = 60 # 时间窗口(秒)
|
| 80 |
USER_LIMIT_MAX = 100 # 每用户每分钟最大请求数
|
| 81 |
+
MAX_DICT_SIZE = 10000 # 字典最大条目数
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def cleanup_expired_entries():
|
| 85 |
+
"""清理超过1小时的过期记录,防止内存泄漏"""
|
| 86 |
+
now = time.time()
|
| 87 |
+
# 清理 user_request_counts
|
| 88 |
+
expired_user_keys = [
|
| 89 |
+
k for k, v in list(user_request_counts.items())
|
| 90 |
+
if all(now - t > 3600 for t in v)
|
| 91 |
+
]
|
| 92 |
+
for k in expired_user_keys:
|
| 93 |
+
del user_request_counts[k]
|
| 94 |
+
# 清理 ip_stats
|
| 95 |
+
expired_ip_keys = [
|
| 96 |
+
k for k, v in list(ip_stats.items())
|
| 97 |
+
if now - v.get("last_request", 0) > 3600
|
| 98 |
+
]
|
| 99 |
+
for k in expired_ip_keys:
|
| 100 |
+
del ip_stats[k]
|
| 101 |
+
if expired_user_keys or expired_ip_keys:
|
| 102 |
+
logger.debug(f"CLEANUP | user_keys={len(expired_user_keys)}, ip_keys={len(expired_ip_keys)}")
|
| 103 |
|
| 104 |
|
| 105 |
def check_user_rate_limit(account: str) -> bool:
|
|
|
|
| 113 |
now = time.time()
|
| 114 |
key = f"user:{account}"
|
| 115 |
|
| 116 |
+
# 超出字典上限时触发清理
|
| 117 |
+
if len(user_request_counts) >= MAX_DICT_SIZE:
|
| 118 |
+
cleanup_expired_entries()
|
| 119 |
+
|
| 120 |
# 清理过期记录
|
| 121 |
if key in user_request_counts:
|
| 122 |
user_request_counts[key] = [
|
|
|
|
| 167 |
now = time.time()
|
| 168 |
key = f"{ip}:{endpoint}"
|
| 169 |
|
| 170 |
+
# 超出字典上限时触发清理
|
| 171 |
+
if len(ip_stats) >= MAX_DICT_SIZE:
|
| 172 |
+
cleanup_expired_entries()
|
| 173 |
+
|
| 174 |
if key not in ip_stats:
|
| 175 |
ip_stats[key] = {"count": 0, "first_request": now}
|
| 176 |
|
router_comments.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
from fastapi import APIRouter, HTTPException, Depends
|
| 3 |
import time
|
| 4 |
import uuid
|
|
|
|
| 5 |
import 数据库连接 as db
|
| 6 |
from notifications import add_notification
|
| 7 |
from models import InteractionToggle, CommentCreate
|
|
@@ -10,7 +11,8 @@ from 安全认证 import require_auth, is_admin
|
|
| 10 |
router = APIRouter()
|
| 11 |
|
| 12 |
@router.post("/api/interactions/toggle")
|
| 13 |
-
async def toggle_interaction(interaction: InteractionToggle):
|
|
|
|
| 14 |
items_db = db.load_data("items.json", default_data=[])
|
| 15 |
target_item = next((item for item in items_db if item["id"] == interaction.item_id), None)
|
| 16 |
if not target_item: raise HTTPException(status_code=404, detail="目标存在问题")
|
|
@@ -33,7 +35,13 @@ async def toggle_interaction(interaction: InteractionToggle):
|
|
| 33 |
return {"status": "success", "new_count": target_item[count_key]}
|
| 34 |
|
| 35 |
@router.post("/api/comments")
|
| 36 |
-
async def post_comment(comment: CommentCreate):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
comments_db = db.load_data("comments.json", default_data={})
|
| 38 |
users_db = db.load_data("users.json", default_data={})
|
| 39 |
author_info = users_db.get(comment.author, {})
|
|
@@ -147,8 +155,22 @@ async def soft_delete_comment(item_id: str, comment_id: str, account: str = Depe
|
|
| 147 |
return True
|
| 148 |
return False
|
| 149 |
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
|
| 154 |
return {"status": "success"}
|
|
|
|
| 2 |
from fastapi import APIRouter, HTTPException, Depends
|
| 3 |
import time
|
| 4 |
import uuid
|
| 5 |
+
from html import escape
|
| 6 |
import 数据库连接 as db
|
| 7 |
from notifications import add_notification
|
| 8 |
from models import InteractionToggle, CommentCreate
|
|
|
|
| 11 |
router = APIRouter()
|
| 12 |
|
| 13 |
@router.post("/api/interactions/toggle")
|
| 14 |
+
async def toggle_interaction(interaction: InteractionToggle, current_user: str = Depends(require_auth)):
|
| 15 |
+
interaction.user_id = current_user # 强制使用当前用户身份,防止伪造
|
| 16 |
items_db = db.load_data("items.json", default_data=[])
|
| 17 |
target_item = next((item for item in items_db if item["id"] == interaction.item_id), None)
|
| 18 |
if not target_item: raise HTTPException(status_code=404, detail="目标存在问题")
|
|
|
|
| 35 |
return {"status": "success", "new_count": target_item[count_key]}
|
| 36 |
|
| 37 |
@router.post("/api/comments")
|
| 38 |
+
async def post_comment(comment: CommentCreate, current_user: str = Depends(require_auth)):
|
| 39 |
+
comment.author = current_user # 强制使用认证用户身份,防止伪造
|
| 40 |
+
# 评论内容长度校验
|
| 41 |
+
if not comment.content or len(comment.content) > 5000:
|
| 42 |
+
raise HTTPException(status_code=400, detail="评论长度必须在1-5000字符之间")
|
| 43 |
+
# HTML 转义,防止 XSS
|
| 44 |
+
comment.content = escape(comment.content)
|
| 45 |
comments_db = db.load_data("comments.json", default_data={})
|
| 46 |
users_db = db.load_data("users.json", default_data={})
|
| 47 |
author_info = users_db.get(comment.author, {})
|
|
|
|
| 155 |
return True
|
| 156 |
return False
|
| 157 |
|
| 158 |
+
# 原子操作软删除,防止并发覆盖
|
| 159 |
+
max_retries = 3
|
| 160 |
+
for attempt in range(max_retries):
|
| 161 |
+
try:
|
| 162 |
+
comments_db = db.load_data("comments.json", default_data={})
|
| 163 |
+
item_comments = comments_db.get(item_id, [])
|
| 164 |
+
if not find_comment(item_comments):
|
| 165 |
+
raise HTTPException(status_code=404, detail="找不到该评论")
|
| 166 |
+
mark_deleted(item_comments)
|
| 167 |
+
comments_db[item_id] = item_comments
|
| 168 |
+
db.save_data("comments.json", comments_db)
|
| 169 |
+
break
|
| 170 |
+
except HTTPException:
|
| 171 |
+
raise
|
| 172 |
+
except Exception:
|
| 173 |
+
if attempt == max_retries - 1:
|
| 174 |
+
raise
|
| 175 |
|
| 176 |
return {"status": "success"}
|
router_items.py
CHANGED
|
@@ -16,6 +16,12 @@ from db_utils import record_view, sort_cache
|
|
| 16 |
|
| 17 |
router = APIRouter()
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
def _get_version_str(versions_db: dict, item_id: str) -> str:
|
| 20 |
"""兼容新旧格式获取版本hash字符串"""
|
| 21 |
val = versions_db.get(item_id, "")
|
|
@@ -126,11 +132,14 @@ def _build_creator_trend_data(account: str, u_items: list, months: list) -> dict
|
|
| 126 |
itype = i.get("type", "")
|
| 127 |
history = i.get("use_history", {})
|
| 128 |
if itype == "tool" or itype == "recommend_tool":
|
| 129 |
-
for m in
|
|
|
|
| 130 |
elif itype == "app" or itype == "recommend_app":
|
| 131 |
-
for m in
|
|
|
|
| 132 |
elif itype.startswith("recommend"):
|
| 133 |
-
for m in
|
|
|
|
| 134 |
|
| 135 |
return {
|
| 136 |
"months": months,
|
|
@@ -171,7 +180,7 @@ async def search_creators(keyword: str, sort: str = "downloads", limit: int = 50
|
|
| 171 |
short_desc = u.get("shortDesc") or u.get("intro") or ""
|
| 172 |
|
| 173 |
# 不区分大小写的子串匹配(同时覆盖 shortDesc 和 intro 两个字段)
|
| 174 |
-
search_text = f"{name} {account} {short_desc} {u.get('intro')
|
| 175 |
if keyword_lower not in search_text:
|
| 176 |
continue
|
| 177 |
|
|
|
|
| 16 |
|
| 17 |
router = APIRouter()
|
| 18 |
|
| 19 |
+
def safe_str(val):
|
| 20 |
+
"""安全转换为字符串"""
|
| 21 |
+
if val is None:
|
| 22 |
+
return ""
|
| 23 |
+
return str(val).lower()
|
| 24 |
+
|
| 25 |
def _get_version_str(versions_db: dict, item_id: str) -> str:
|
| 26 |
"""兼容新旧格式获取版本hash字符串"""
|
| 27 |
val = versions_db.get(item_id, "")
|
|
|
|
| 132 |
itype = i.get("type", "")
|
| 133 |
history = i.get("use_history", {})
|
| 134 |
if itype == "tool" or itype == "recommend_tool":
|
| 135 |
+
for m, v in history.items():
|
| 136 |
+
if m in trend_tools: trend_tools[m] += v
|
| 137 |
elif itype == "app" or itype == "recommend_app":
|
| 138 |
+
for m, v in history.items():
|
| 139 |
+
if m in trend_apps: trend_apps[m] += v
|
| 140 |
elif itype.startswith("recommend"):
|
| 141 |
+
for m, v in history.items():
|
| 142 |
+
if m in trend_recommends: trend_recommends[m] += v
|
| 143 |
|
| 144 |
return {
|
| 145 |
"months": months,
|
|
|
|
| 180 |
short_desc = u.get("shortDesc") or u.get("intro") or ""
|
| 181 |
|
| 182 |
# 不区分大小写的子串匹配(同时覆盖 shortDesc 和 intro 两个字段)
|
| 183 |
+
search_text = f"{safe_str(name)} {safe_str(account)} {safe_str(short_desc)} {safe_str(u.get('intro'))} {safe_str(u.get('shortDesc'))}"
|
| 184 |
if keyword_lower not in search_text:
|
| 185 |
continue
|
| 186 |
|
router_messages.py
CHANGED
|
@@ -102,14 +102,19 @@ async def run_admin_script(req: AdminScriptRequest, current_user: str = Depends(
|
|
| 102 |
}
|
| 103 |
|
| 104 |
try:
|
| 105 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
result = subprocess.run(
|
| 107 |
["python", script_path],
|
| 108 |
capture_output=True,
|
| 109 |
text=True,
|
| 110 |
-
timeout=
|
| 111 |
cwd=current_dir,
|
| 112 |
-
encoding="utf-8"
|
|
|
|
| 113 |
)
|
| 114 |
|
| 115 |
output = ""
|
|
@@ -119,6 +124,10 @@ async def run_admin_script(req: AdminScriptRequest, current_user: str = Depends(
|
|
| 119 |
output += f"\n⚠️ 错误输出:\n{result.stderr}"
|
| 120 |
if not output:
|
| 121 |
output = "✅ 脚本执行完成,无输出"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
|
| 123 |
return {
|
| 124 |
"status": "success" if result.returncode == 0 else "error",
|
|
@@ -129,7 +138,7 @@ async def run_admin_script(req: AdminScriptRequest, current_user: str = Depends(
|
|
| 129 |
except subprocess.TimeoutExpired:
|
| 130 |
return {
|
| 131 |
"status": "error",
|
| 132 |
-
"output": "❌ 脚本执行超时 (
|
| 133 |
}
|
| 134 |
except Exception as e:
|
| 135 |
return {
|
|
@@ -141,7 +150,8 @@ async def run_admin_script(req: AdminScriptRequest, current_user: str = Depends(
|
|
| 141 |
# 原有功能:私信与聊天
|
| 142 |
# ==========================================
|
| 143 |
@router.post("/api/messages/private")
|
| 144 |
-
async def send_private_message(msg: PrivateMessage):
|
|
|
|
| 145 |
chats_db = db.load_data("chats.json", default_data={})
|
| 146 |
conv_id = f"{min(msg.sender, msg.receiver)}_{max(msg.sender, msg.receiver)}"
|
| 147 |
if conv_id not in chats_db: chats_db[conv_id] = []
|
|
@@ -154,7 +164,9 @@ async def send_private_message(msg: PrivateMessage):
|
|
| 154 |
return {"status": "success"}
|
| 155 |
|
| 156 |
@router.get("/api/chats/{account}")
|
| 157 |
-
async def get_chat_list(account: str):
|
|
|
|
|
|
|
| 158 |
chats_db = db.load_data("chats.json", default_data={})
|
| 159 |
users_db = db.load_data("users.json", default_data={})
|
| 160 |
|
|
@@ -179,7 +191,9 @@ async def get_chat_list(account: str):
|
|
| 179 |
return {"status": "success", "data": chat_list}
|
| 180 |
|
| 181 |
@router.get("/api/chats/{account}/{target_account}")
|
| 182 |
-
async def get_chat_history(account: str, target_account: str):
|
|
|
|
|
|
|
| 183 |
chats_db = db.load_data("chats.json", default_data={})
|
| 184 |
conv_id = f"{min(account, target_account)}_{max(account, target_account)}"
|
| 185 |
msgs = chats_db.get(conv_id, [])
|
|
|
|
| 102 |
}
|
| 103 |
|
| 104 |
try:
|
| 105 |
+
# 清理敏感环境变量,防止泄露到子进程
|
| 106 |
+
sensitive_prefixes = ('GITHUB', 'JWT', 'HF_', 'DATABASE', 'ALIPAY', 'PASSWORD')
|
| 107 |
+
clean_env = {k: v for k, v in os.environ.items()
|
| 108 |
+
if not any(k.startswith(prefix) for prefix in sensitive_prefixes)}
|
| 109 |
+
# 执行脚本,超时缩短至30秒
|
| 110 |
result = subprocess.run(
|
| 111 |
["python", script_path],
|
| 112 |
capture_output=True,
|
| 113 |
text=True,
|
| 114 |
+
timeout=30,
|
| 115 |
cwd=current_dir,
|
| 116 |
+
encoding="utf-8",
|
| 117 |
+
env=clean_env
|
| 118 |
)
|
| 119 |
|
| 120 |
output = ""
|
|
|
|
| 124 |
output += f"\n⚠️ 错误输出:\n{result.stderr}"
|
| 125 |
if not output:
|
| 126 |
output = "✅ 脚本执行完成,无输出"
|
| 127 |
+
|
| 128 |
+
# 限制输出大小防止返回大量数据
|
| 129 |
+
if len(output) > 1000:
|
| 130 |
+
output = output[:1000] + "\n...(输出已截断)"
|
| 131 |
|
| 132 |
return {
|
| 133 |
"status": "success" if result.returncode == 0 else "error",
|
|
|
|
| 138 |
except subprocess.TimeoutExpired:
|
| 139 |
return {
|
| 140 |
"status": "error",
|
| 141 |
+
"output": "❌ 脚本执行超时 (30秒)"
|
| 142 |
}
|
| 143 |
except Exception as e:
|
| 144 |
return {
|
|
|
|
| 150 |
# 原有功能:私信与聊天
|
| 151 |
# ==========================================
|
| 152 |
@router.post("/api/messages/private")
|
| 153 |
+
async def send_private_message(msg: PrivateMessage, current_user: str = Depends(require_auth)):
|
| 154 |
+
msg.sender = current_user # 强制发送者身份,防止伪造
|
| 155 |
chats_db = db.load_data("chats.json", default_data={})
|
| 156 |
conv_id = f"{min(msg.sender, msg.receiver)}_{max(msg.sender, msg.receiver)}"
|
| 157 |
if conv_id not in chats_db: chats_db[conv_id] = []
|
|
|
|
| 164 |
return {"status": "success"}
|
| 165 |
|
| 166 |
@router.get("/api/chats/{account}")
|
| 167 |
+
async def get_chat_list(account: str, current_user: str = Depends(require_auth)):
|
| 168 |
+
if account != current_user:
|
| 169 |
+
raise HTTPException(status_code=403, detail="无权访问他人私信")
|
| 170 |
chats_db = db.load_data("chats.json", default_data={})
|
| 171 |
users_db = db.load_data("users.json", default_data={})
|
| 172 |
|
|
|
|
| 191 |
return {"status": "success", "data": chat_list}
|
| 192 |
|
| 193 |
@router.get("/api/chats/{account}/{target_account}")
|
| 194 |
+
async def get_chat_history(account: str, target_account: str, current_user: str = Depends(require_auth)):
|
| 195 |
+
if account != current_user:
|
| 196 |
+
raise HTTPException(status_code=403, detail="无权访问他人聊天记录")
|
| 197 |
chats_db = db.load_data("chats.json", default_data={})
|
| 198 |
conv_id = f"{min(account, target_account)}_{max(account, target_account)}"
|
| 199 |
msgs = chats_db.get(conv_id, [])
|
router_posts.py
CHANGED
|
@@ -425,6 +425,9 @@ async def tip_post(post_id: str, amount: int, is_anon: bool = False, current_use
|
|
| 425 |
"""
|
| 426 |
if amount <= 0:
|
| 427 |
raise HTTPException(status_code=400, detail="打赏金额必须大于0")
|
|
|
|
|
|
|
|
|
|
| 428 |
|
| 429 |
result_container = [None]
|
| 430 |
author_account = [None] # 用于在原子操作外获取作者账号
|
|
|
|
| 425 |
"""
|
| 426 |
if amount <= 0:
|
| 427 |
raise HTTPException(status_code=400, detail="打赏金额必须大于0")
|
| 428 |
+
MAX_TIP_AMOUNT = 100000
|
| 429 |
+
if amount > MAX_TIP_AMOUNT:
|
| 430 |
+
raise HTTPException(status_code=400, detail=f"打赏金额必须在1-{MAX_TIP_AMOUNT}之间")
|
| 431 |
|
| 432 |
result_container = [None]
|
| 433 |
author_account = [None] # 用于在原子操作外获取作者账号
|
router_proxy.py
CHANGED
|
@@ -4,6 +4,8 @@ from fastapi.responses import StreamingResponse, JSONResponse, Response
|
|
| 4 |
from sqlalchemy.orm import Session
|
| 5 |
from pydantic import BaseModel, Field, field_validator, HttpUrl
|
| 6 |
from typing import Optional
|
|
|
|
|
|
|
| 7 |
import httpx
|
| 8 |
import os
|
| 9 |
import re
|
|
@@ -145,10 +147,14 @@ async def proxy_github_zip(req_data: ProxyGithubZipRequest, db: Session = Depend
|
|
| 145 |
# 3. 异步请求 GitHub API 并以流形式透传回客户端 (防内存打爆)
|
| 146 |
client = httpx.AsyncClient(follow_redirects=True)
|
| 147 |
try:
|
| 148 |
-
|
| 149 |
-
client.
|
| 150 |
-
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
|
| 153 |
# 非 200 直接返回错误,避免 Content-Length 不匹配导致 h11 协议错误
|
| 154 |
if response.status_code != 200:
|
|
@@ -215,9 +221,24 @@ class ProxyDownloadRequest(BaseModel):
|
|
| 215 |
@field_validator('url')
|
| 216 |
@classmethod
|
| 217 |
def validate_url(cls, v: str) -> str:
|
| 218 |
-
"""验证URL格式"""
|
| 219 |
if not v.startswith(('http://', 'https://')):
|
| 220 |
raise ValueError('url必须是有效的HTTP或HTTPS地址')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
return v
|
| 222 |
|
| 223 |
@router.post("/api/proxy_download")
|
|
|
|
| 4 |
from sqlalchemy.orm import Session
|
| 5 |
from pydantic import BaseModel, Field, field_validator, HttpUrl
|
| 6 |
from typing import Optional
|
| 7 |
+
from urllib.parse import urlparse
|
| 8 |
+
import ipaddress
|
| 9 |
import httpx
|
| 10 |
import os
|
| 11 |
import re
|
|
|
|
| 147 |
# 3. 异步请求 GitHub API 并以流形式透传回客户端 (防内存打爆)
|
| 148 |
client = httpx.AsyncClient(follow_redirects=True)
|
| 149 |
try:
|
| 150 |
+
try:
|
| 151 |
+
response = await client.send(
|
| 152 |
+
client.build_request("GET", github_zip_api, headers=headers),
|
| 153 |
+
stream=True
|
| 154 |
+
)
|
| 155 |
+
except Exception as send_err:
|
| 156 |
+
await client.aclose()
|
| 157 |
+
return JSONResponse(content={"error": f"代理下载时发生网络异常:{str(send_err)}"}, status_code=500)
|
| 158 |
|
| 159 |
# 非 200 直接返回错误,避免 Content-Length 不匹配导致 h11 协议错误
|
| 160 |
if response.status_code != 200:
|
|
|
|
| 221 |
@field_validator('url')
|
| 222 |
@classmethod
|
| 223 |
def validate_url(cls, v: str) -> str:
|
| 224 |
+
"""验证URL格式并阻止内网地址"""
|
| 225 |
if not v.startswith(('http://', 'https://')):
|
| 226 |
raise ValueError('url必须是有效的HTTP或HTTPS地址')
|
| 227 |
+
|
| 228 |
+
parsed = urlparse(v)
|
| 229 |
+
# 阻止内网地址
|
| 230 |
+
dangerous_hosts = ['localhost', '127.0.0.1', '0.0.0.0', '::1']
|
| 231 |
+
if parsed.hostname in dangerous_hosts:
|
| 232 |
+
raise ValueError('不允许访问本地地址')
|
| 233 |
+
|
| 234 |
+
# 检查是否是私有IP
|
| 235 |
+
try:
|
| 236 |
+
ip = ipaddress.ip_address(parsed.hostname)
|
| 237 |
+
if ip.is_private or ip.is_loopback or ip.is_reserved:
|
| 238 |
+
raise ValueError('不允许访问内网地址')
|
| 239 |
+
except ValueError:
|
| 240 |
+
pass # hostname不是IP,允许继续
|
| 241 |
+
|
| 242 |
return v
|
| 243 |
|
| 244 |
@router.post("/api/proxy_download")
|
router_tasks.py
CHANGED
|
@@ -20,6 +20,7 @@ from notifications import add_notification
|
|
| 20 |
from database_sql import get_db
|
| 21 |
from models_sql import Wallet, Transaction
|
| 22 |
from db_utils import record_view, sort_cache
|
|
|
|
| 23 |
import time
|
| 24 |
import uuid
|
| 25 |
import hashlib
|
|
@@ -78,7 +79,7 @@ def create_task_transaction(db_session: Session, account: str, tx_type: str, amo
|
|
| 78 |
)
|
| 79 |
db_session.add(new_tx)
|
| 80 |
|
| 81 |
-
logger.info(f"TASK_TX | type={tx_type} |
|
| 82 |
return tx_id
|
| 83 |
|
| 84 |
# ==========================================
|
|
@@ -218,7 +219,7 @@ def check_and_update_expired_tasks(tasks_db, db_session=None):
|
|
| 218 |
"target_item_title": item["title"],
|
| 219 |
"content": f"💰 任务《{item['title']}》已过期自动取消,{item['amount']}积分已退还"
|
| 220 |
})
|
| 221 |
-
logger.info(f"TASK_REFUND |
|
| 222 |
except Exception as e:
|
| 223 |
logger.warning(f"TASK_REFUND_NOTIFY_ERROR | task={item['task_id']} | error={str(e)}")
|
| 224 |
|
|
@@ -383,8 +384,8 @@ async def create_task(task: TaskCreate, current_user: str = Depends(require_auth
|
|
| 383 |
|
| 384 |
new_task = {
|
| 385 |
"id": task_id,
|
| 386 |
-
"title": task.title,
|
| 387 |
-
"description": task.description,
|
| 388 |
"reference_images": (task.referenceImages or [])[:6],
|
| 389 |
"reference_link": task.referenceLink,
|
| 390 |
"total_price": task.totalPrice,
|
|
@@ -435,7 +436,7 @@ async def create_task(task: TaskCreate, current_user: str = Depends(require_auth
|
|
| 435 |
)
|
| 436 |
db_session.commit()
|
| 437 |
|
| 438 |
-
logger.info(f"TASK_CREATE |
|
| 439 |
|
| 440 |
return {"status": "success", "data": new_task, "frozen_amount": task.totalPrice}
|
| 441 |
|
|
@@ -543,7 +544,7 @@ async def cancel_task(task_id: str, current_user: str = Depends(require_auth), d
|
|
| 543 |
)
|
| 544 |
db_session.commit()
|
| 545 |
refund_success = True
|
| 546 |
-
logger.info(f"TASK_CANCEL_REFUND |
|
| 547 |
except Exception as e:
|
| 548 |
db_session.rollback()
|
| 549 |
logger.error(f"TASK_CANCEL_REFUND_ERROR | task={task_id} | error={str(e)}")
|
|
@@ -708,7 +709,7 @@ async def assign_task(task_id: str, assignee: str, current_user: str = Depends(r
|
|
| 708 |
"content": f"您已被选为任务《{task.get('title', '')}》的接单者,订金{deposit}积分已缓冲"
|
| 709 |
})
|
| 710 |
|
| 711 |
-
logger.info(f"TASK_ASSIGN |
|
| 712 |
|
| 713 |
return {"status": "success", "message": f"已指派 {assignee} 接单,订金 {deposit} 积分已开始缓冲"}
|
| 714 |
|
|
@@ -843,7 +844,7 @@ async def accept_task(task_id: str, is_accepted: bool, feedback: str = None, cur
|
|
| 843 |
except Exception as e:
|
| 844 |
logger.warning(f"TASK_COMPLETE_JSON_SAVE_FAILED | 资金已结算但任务状态未更新,需手动修复: {str(e)}")
|
| 845 |
|
| 846 |
-
logger.info(f"TASK_COMPLETE |
|
| 847 |
# 🗂️ 清除排序缓存(任务状态变化)
|
| 848 |
sort_cache.invalidate("tasks:")
|
| 849 |
message = f"验收通过,已支付 {total_price} 积分给接单者"
|
|
@@ -1584,7 +1585,7 @@ async def tip_task(task_id: str, amount: int, is_anon: bool = False, current_use
|
|
| 1584 |
db_session.commit()
|
| 1585 |
|
| 1586 |
# 📝 审计日志
|
| 1587 |
-
logger.info(f"TASK_TIP |
|
| 1588 |
|
| 1589 |
# 🔔 打赏通知(考虑匿名)
|
| 1590 |
if not is_anon:
|
|
@@ -1612,7 +1613,7 @@ async def tip_task(task_id: str, amount: int, is_anon: bool = False, current_use
|
|
| 1612 |
raise
|
| 1613 |
except Exception as e:
|
| 1614 |
db_session.rollback()
|
| 1615 |
-
logger.error(f"TASK_TIP_ERROR |
|
| 1616 |
raise HTTPException(status_code=500, detail="打赏处理失败,请稍后重试")
|
| 1617 |
|
| 1618 |
return result
|
|
|
|
| 20 |
from database_sql import get_db
|
| 21 |
from models_sql import Wallet, Transaction
|
| 22 |
from db_utils import record_view, sort_cache
|
| 23 |
+
from html import escape
|
| 24 |
import time
|
| 25 |
import uuid
|
| 26 |
import hashlib
|
|
|
|
| 79 |
)
|
| 80 |
db_session.add(new_tx)
|
| 81 |
|
| 82 |
+
logger.info(f"TASK_TX | type={tx_type} | account_hash={hash(account)} | amount={amount} | task={task_id} | tx={tx_id}")
|
| 83 |
return tx_id
|
| 84 |
|
| 85 |
# ==========================================
|
|
|
|
| 219 |
"target_item_title": item["title"],
|
| 220 |
"content": f"💰 任务《{item['title']}》已过期自动取消,{item['amount']}积分已退还"
|
| 221 |
})
|
| 222 |
+
logger.info(f"TASK_REFUND | task={item['task_id']} | amount={item['amount']}")
|
| 223 |
except Exception as e:
|
| 224 |
logger.warning(f"TASK_REFUND_NOTIFY_ERROR | task={item['task_id']} | error={str(e)}")
|
| 225 |
|
|
|
|
| 384 |
|
| 385 |
new_task = {
|
| 386 |
"id": task_id,
|
| 387 |
+
"title": escape(task.title),
|
| 388 |
+
"description": escape(task.description),
|
| 389 |
"reference_images": (task.referenceImages or [])[:6],
|
| 390 |
"reference_link": task.referenceLink,
|
| 391 |
"total_price": task.totalPrice,
|
|
|
|
| 436 |
)
|
| 437 |
db_session.commit()
|
| 438 |
|
| 439 |
+
logger.info(f"TASK_CREATE | task={task_id} | price={task.totalPrice} | frozen={task.totalPrice}")
|
| 440 |
|
| 441 |
return {"status": "success", "data": new_task, "frozen_amount": task.totalPrice}
|
| 442 |
|
|
|
|
| 544 |
)
|
| 545 |
db_session.commit()
|
| 546 |
refund_success = True
|
| 547 |
+
logger.info(f"TASK_CANCEL_REFUND | task={task_id} | amount={frozen_amount}")
|
| 548 |
except Exception as e:
|
| 549 |
db_session.rollback()
|
| 550 |
logger.error(f"TASK_CANCEL_REFUND_ERROR | task={task_id} | error={str(e)}")
|
|
|
|
| 709 |
"content": f"您已被选为任务《{task.get('title', '')}》的接单者,订金{deposit}积分已缓冲"
|
| 710 |
})
|
| 711 |
|
| 712 |
+
logger.info(f"TASK_ASSIGN | task={task_id} | deposit={deposit}")
|
| 713 |
|
| 714 |
return {"status": "success", "message": f"已指派 {assignee} 接单,订金 {deposit} 积分已开始缓冲"}
|
| 715 |
|
|
|
|
| 844 |
except Exception as e:
|
| 845 |
logger.warning(f"TASK_COMPLETE_JSON_SAVE_FAILED | 资金已结算但任务状态未更新,需手动修复: {str(e)}")
|
| 846 |
|
| 847 |
+
logger.info(f"TASK_COMPLETE | task={task_id} | total={total_price}")
|
| 848 |
# 🗂️ 清除排序缓存(任务状态变化)
|
| 849 |
sort_cache.invalidate("tasks:")
|
| 850 |
message = f"验收通过,已支付 {total_price} 积分给接单者"
|
|
|
|
| 1585 |
db_session.commit()
|
| 1586 |
|
| 1587 |
# 📝 审计日志
|
| 1588 |
+
logger.info(f"TASK_TIP | task={task_id} | amount={amount} | anon={is_anon}")
|
| 1589 |
|
| 1590 |
# 🔔 打赏通知(考虑匿名)
|
| 1591 |
if not is_anon:
|
|
|
|
| 1613 |
raise
|
| 1614 |
except Exception as e:
|
| 1615 |
db_session.rollback()
|
| 1616 |
+
logger.error(f"TASK_TIP_ERROR | task={task_id} | amount={amount} | error={str(e)}")
|
| 1617 |
raise HTTPException(status_code=500, detail="打赏处理失败,请稍后重试")
|
| 1618 |
|
| 1619 |
return result
|
router_users_auth.py
CHANGED
|
@@ -11,6 +11,7 @@
|
|
| 11 |
# ==========================================
|
| 12 |
|
| 13 |
from fastapi import APIRouter, HTTPException, BackgroundTasks, Request
|
|
|
|
| 14 |
import time
|
| 15 |
import re
|
| 16 |
import random
|
|
@@ -22,6 +23,9 @@ from verify_code_engine import VERIFY_CODES, send_email_code, send_sms_code, cle
|
|
| 22 |
# 🔒 P0安全增强:导入密码哈希和 JWT 工具
|
| 23 |
from 安全认证 import hash_password, verify_password, create_token, require_password_match
|
| 24 |
|
|
|
|
|
|
|
|
|
|
| 25 |
# 🚀 P2优化:速率限制
|
| 26 |
from slowapi import Limiter
|
| 27 |
from slowapi.util import get_remote_address
|
|
@@ -160,6 +164,11 @@ async def register_user(request: Request, user: UserRegister):
|
|
| 160 |
if user.account in users_db:
|
| 161 |
raise HTTPException(status_code=400, detail="该账号已被注册,请更换一个")
|
| 162 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
# 检查邮箱和手机号是否已被其他用户绑定
|
| 164 |
for existing_user in users_db.values():
|
| 165 |
if user.email and existing_user.get("email") == user.email:
|
|
@@ -167,11 +176,12 @@ async def register_user(request: Request, user: UserRegister):
|
|
| 167 |
if user.phone and existing_user.get("phone") == user.phone:
|
| 168 |
raise HTTPException(status_code=400, detail="该手机号已被绑定")
|
| 169 |
|
| 170 |
-
# ========== 第二步:验证码校验(
|
| 171 |
# 根据注册方式构建缓存键
|
| 172 |
cache_key = f"{user.email}_register" if user.email else f"{user.phone}_register"
|
| 173 |
-
# 🔒 P0安全修复:验证码一次性使用,
|
| 174 |
-
|
|
|
|
| 175 |
|
| 176 |
# 兼容新老缓存格式(expires_at 或 expires)
|
| 177 |
expire_time = cached.get("expires_at", cached.get("expires", 0)) if cached else 0
|
|
@@ -243,14 +253,15 @@ async def login_user(request: Request, user: UserLogin):
|
|
| 243 |
raise HTTPException(status_code=404, detail="账号不存在")
|
| 244 |
|
| 245 |
user_data = users_db[user.account]
|
| 246 |
-
stored_password = user_data.get("password"
|
| 247 |
|
| 248 |
# 🔒 P0安全增强:密码哈希验证(使用统一验证函数)
|
| 249 |
if not require_password_match(stored_password, user.password):
|
| 250 |
raise HTTPException(status_code=401, detail="密码错误")
|
| 251 |
|
| 252 |
# 🔒 P0安全增强:登录成功后,检查是否需要迁移旧密码为bcrypt
|
| 253 |
-
|
|
|
|
| 254 |
# 旧版SHA256密码,自动迁移为bcrypt
|
| 255 |
user_data["password"] = hash_password(user.password)
|
| 256 |
db.save_data("users.json", users_db)
|
|
|
|
| 11 |
# ==========================================
|
| 12 |
|
| 13 |
from fastapi import APIRouter, HTTPException, BackgroundTasks, Request
|
| 14 |
+
import asyncio
|
| 15 |
import time
|
| 16 |
import re
|
| 17 |
import random
|
|
|
|
| 23 |
# 🔒 P0安全增强:导入密码哈希和 JWT 工具
|
| 24 |
from 安全认证 import hash_password, verify_password, create_token, require_password_match
|
| 25 |
|
| 26 |
+
# 🔒 验证码并发保护锁
|
| 27 |
+
_verify_code_lock = asyncio.Lock()
|
| 28 |
+
|
| 29 |
# 🚀 P2优化:速率限制
|
| 30 |
from slowapi import Limiter
|
| 31 |
from slowapi.util import get_remote_address
|
|
|
|
| 164 |
if user.account in users_db:
|
| 165 |
raise HTTPException(status_code=400, detail="该账号已被注册,请更换一个")
|
| 166 |
|
| 167 |
+
# 🔒 禁止保留账号名
|
| 168 |
+
RESERVED_ACCOUNTS = {"admin", "system", "root", "test", "anonymous", "administrator"}
|
| 169 |
+
if user.account.lower() in RESERVED_ACCOUNTS:
|
| 170 |
+
raise HTTPException(status_code=400, detail="该账号名不可用")
|
| 171 |
+
|
| 172 |
# 检查邮箱和手机号是否已被其他用户绑定
|
| 173 |
for existing_user in users_db.values():
|
| 174 |
if user.email and existing_user.get("email") == user.email:
|
|
|
|
| 176 |
if user.phone and existing_user.get("phone") == user.phone:
|
| 177 |
raise HTTPException(status_code=400, detail="该手机号已被绑定")
|
| 178 |
|
| 179 |
+
# ========== 第二步:验证码校验(并发安全) ==========
|
| 180 |
# 根据注册方式构建缓存键
|
| 181 |
cache_key = f"{user.email}_register" if user.email else f"{user.phone}_register"
|
| 182 |
+
# 🔒 P0安全修复:验证码一次性使用,并发安全键防止并发重用
|
| 183 |
+
async with _verify_code_lock:
|
| 184 |
+
cached = VERIFY_CODES.pop(cache_key, None)
|
| 185 |
|
| 186 |
# 兼容新老缓存格式(expires_at 或 expires)
|
| 187 |
expire_time = cached.get("expires_at", cached.get("expires", 0)) if cached else 0
|
|
|
|
| 253 |
raise HTTPException(status_code=404, detail="账号不存在")
|
| 254 |
|
| 255 |
user_data = users_db[user.account]
|
| 256 |
+
stored_password = user_data.get("password") or ""
|
| 257 |
|
| 258 |
# 🔒 P0安全增强:密码哈希验证(使用统一验证函数)
|
| 259 |
if not require_password_match(stored_password, user.password):
|
| 260 |
raise HTTPException(status_code=401, detail="密码错误")
|
| 261 |
|
| 262 |
# 🔒 P0安全增强:登录成功后,检查是否需要迁移旧密码为bcrypt
|
| 263 |
+
# 只在旧格式密码验证成功后才进行迁移(require_password_match已通过)
|
| 264 |
+
if stored_password and not stored_password.startswith('$2b$') and not stored_password.startswith('$2a$'):
|
| 265 |
# 旧版SHA256密码,自动迁移为bcrypt
|
| 266 |
user_data["password"] = hash_password(user.password)
|
| 267 |
db.save_data("users.json", users_db)
|
router_users_profile.py
CHANGED
|
@@ -12,9 +12,10 @@
|
|
| 12 |
# - 个人设置表单组件.js (更新用户资料)
|
| 13 |
# ==========================================
|
| 14 |
|
| 15 |
-
from fastapi import APIRouter, HTTPException
|
| 16 |
import 数据库连接 as db
|
| 17 |
from models import UserUpdate
|
|
|
|
| 18 |
|
| 19 |
# 创建子路由实例
|
| 20 |
router = APIRouter()
|
|
@@ -83,7 +84,7 @@ async def get_user_profile(account: str):
|
|
| 83 |
# 前端调用:
|
| 84 |
# - 个人设置表单组件.js 的 handleSaveProfile()
|
| 85 |
@router.put("/api/users/{account}")
|
| 86 |
-
async def update_user_profile(account: str, update_data: UserUpdate):
|
| 87 |
"""
|
| 88 |
更新用户资料接口
|
| 89 |
|
|
@@ -98,6 +99,10 @@ async def update_user_profile(account: str, update_data: UserUpdate):
|
|
| 98 |
- country: 国家
|
| 99 |
- region: 地区
|
| 100 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
# 加载用户数据库
|
| 102 |
users_db = db.load_data("users.json", default_data={})
|
| 103 |
|
|
|
|
| 12 |
# - 个人设置表单组件.js (更新用户资料)
|
| 13 |
# ==========================================
|
| 14 |
|
| 15 |
+
from fastapi import APIRouter, HTTPException, Depends
|
| 16 |
import 数据库连接 as db
|
| 17 |
from models import UserUpdate
|
| 18 |
+
from 安全认证 import require_auth
|
| 19 |
|
| 20 |
# 创建子路由实例
|
| 21 |
router = APIRouter()
|
|
|
|
| 84 |
# 前端调用:
|
| 85 |
# - 个人设置表单组件.js 的 handleSaveProfile()
|
| 86 |
@router.put("/api/users/{account}")
|
| 87 |
+
async def update_user_profile(account: str, update_data: UserUpdate, current_user: str = Depends(require_auth)):
|
| 88 |
"""
|
| 89 |
更新用户资料接口
|
| 90 |
|
|
|
|
| 99 |
- country: 国家
|
| 100 |
- region: 地区
|
| 101 |
"""
|
| 102 |
+
# 🔒 越权校验:只能更新自己的资料
|
| 103 |
+
if account != current_user:
|
| 104 |
+
raise HTTPException(status_code=403, detail="只能更新自己的资料")
|
| 105 |
+
|
| 106 |
# 加载用户数据库
|
| 107 |
users_db = db.load_data("users.json", default_data={})
|
| 108 |
|
router_users_social.py
CHANGED
|
@@ -13,10 +13,11 @@
|
|
| 13 |
# - 个人设置表单组件.js (隐私设置)
|
| 14 |
# ==========================================
|
| 15 |
|
| 16 |
-
from fastapi import APIRouter, HTTPException
|
| 17 |
import 数据库连接 as db
|
| 18 |
from notifications import add_notification
|
| 19 |
from models import FollowToggle, PrivacySettings
|
|
|
|
| 20 |
|
| 21 |
# 创建子路由实例
|
| 22 |
router = APIRouter()
|
|
@@ -35,7 +36,7 @@ router = APIRouter()
|
|
| 35 |
# 前端调用:
|
| 36 |
# - 个人中心视图.js 的 handleFollowToggle()
|
| 37 |
@router.post("/api/users/follow")
|
| 38 |
-
async def toggle_follow(follow: FollowToggle):
|
| 39 |
"""
|
| 40 |
关注/取消关注接口
|
| 41 |
|
|
@@ -44,6 +45,9 @@ async def toggle_follow(follow: FollowToggle):
|
|
| 44 |
- target_account: 被关注/取关的目标用户账号
|
| 45 |
- is_active: True=关注, False=取消关注
|
| 46 |
"""
|
|
|
|
|
|
|
|
|
|
| 47 |
# 加载用户数据库
|
| 48 |
users_db = db.load_data("users.json", default_data={})
|
| 49 |
|
|
@@ -103,7 +107,7 @@ async def toggle_follow(follow: FollowToggle):
|
|
| 103 |
# 前端调用:
|
| 104 |
# - 个人设置表单组件.js 的隐私设置区域
|
| 105 |
@router.put("/api/users/{account}/privacy")
|
| 106 |
-
async def update_privacy(account: str, privacy: PrivacySettings):
|
| 107 |
"""
|
| 108 |
更新隐私设置接口
|
| 109 |
|
|
@@ -115,6 +119,10 @@ async def update_privacy(account: str, privacy: PrivacySettings):
|
|
| 115 |
- favorites: 是否隐藏收藏记录 (True=隐藏)
|
| 116 |
- downloads: 是否隐藏下载记录 (True=隐藏)
|
| 117 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
# 加载用户数据库
|
| 119 |
users_db = db.load_data("users.json", default_data={})
|
| 120 |
|
|
|
|
| 13 |
# - 个人设置表单组件.js (隐私设置)
|
| 14 |
# ==========================================
|
| 15 |
|
| 16 |
+
from fastapi import APIRouter, HTTPException, Depends
|
| 17 |
import 数据库连接 as db
|
| 18 |
from notifications import add_notification
|
| 19 |
from models import FollowToggle, PrivacySettings
|
| 20 |
+
from 安全认证 import require_auth
|
| 21 |
|
| 22 |
# 创建子路由实例
|
| 23 |
router = APIRouter()
|
|
|
|
| 36 |
# 前端调用:
|
| 37 |
# - 个人中心视图.js 的 handleFollowToggle()
|
| 38 |
@router.post("/api/users/follow")
|
| 39 |
+
async def toggle_follow(follow: FollowToggle, current_user: str = Depends(require_auth)):
|
| 40 |
"""
|
| 41 |
关注/取消关注接口
|
| 42 |
|
|
|
|
| 45 |
- target_account: 被关注/取关的目标用户账号
|
| 46 |
- is_active: True=关注, False=取消关注
|
| 47 |
"""
|
| 48 |
+
# 🔒 安全防护:强制使用当前认证用户,防止伪造身份
|
| 49 |
+
follow.user_id = current_user
|
| 50 |
+
|
| 51 |
# 加载用户数据库
|
| 52 |
users_db = db.load_data("users.json", default_data={})
|
| 53 |
|
|
|
|
| 107 |
# 前端调用:
|
| 108 |
# - 个人设置表单组件.js 的隐私设置区域
|
| 109 |
@router.put("/api/users/{account}/privacy")
|
| 110 |
+
async def update_privacy(account: str, privacy: PrivacySettings, current_user: str = Depends(require_auth)):
|
| 111 |
"""
|
| 112 |
更新隐私设置接口
|
| 113 |
|
|
|
|
| 119 |
- favorites: 是否隐藏收藏记录 (True=隐藏)
|
| 120 |
- downloads: 是否隐藏下载记录 (True=隐藏)
|
| 121 |
"""
|
| 122 |
+
# 🔒 越权校验:只能修改自己的隐私设置
|
| 123 |
+
if account != current_user:
|
| 124 |
+
raise HTTPException(status_code=403, detail="只能修改自己的隐私设置")
|
| 125 |
+
|
| 126 |
# 加载用户数据库
|
| 127 |
users_db = db.load_data("users.json", default_data={})
|
| 128 |
|
router_wallet.py
CHANGED
|
@@ -107,7 +107,7 @@ try:
|
|
| 107 |
# 3. 完美加载
|
| 108 |
alipay = AliPay(
|
| 109 |
appid=raw_appid,
|
| 110 |
-
app_notify_url="https://zhiwei666-comfyui-ranking-api.hf.space/api/wallet/alipay_notify",
|
| 111 |
app_private_key_string=priv_key_formatted,
|
| 112 |
alipay_public_key_string=pub_key_formatted,
|
| 113 |
sign_type="RSA2",
|
|
@@ -127,11 +127,11 @@ def calculate_tx_hash(tx_id, account, tx_type, amount, prev_hash):
|
|
| 127 |
async def create_recharge_order(req: RechargeRequest, current_user: str = Depends(require_auth)):
|
| 128 |
if not alipay:
|
| 129 |
# 这里会将真实的错误原因直接弹窗发给前端!
|
| 130 |
-
raise HTTPException(status_code=500, detail=
|
| 131 |
|
| 132 |
# 🔒 使用已认证用户账号,忽略客户端传入的 account
|
| 133 |
authenticated_account = current_user
|
| 134 |
-
order_id = f"PAY_{
|
| 135 |
subject = f"ComfyUI Community Points - {authenticated_account}"
|
| 136 |
|
| 137 |
order_string = alipay.api_alipay_trade_precreate(
|
|
@@ -174,7 +174,7 @@ async def alipay_notify(request: Request, db: Session = Depends(get_db)):
|
|
| 174 |
wallet = Wallet(account=account, balance=0, earn_balance=0, tip_balance=0, frozen_balance=0)
|
| 175 |
db.add(wallet)
|
| 176 |
|
| 177 |
-
wallet.balance = (wallet.balance or 0) + amount
|
| 178 |
|
| 179 |
last_tx = db.query(Transaction).filter(Transaction.account == account).order_by(Transaction.created_at.desc()).first()
|
| 180 |
prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
|
|
@@ -198,7 +198,7 @@ async def alipay_notify(request: Request, db: Session = Depends(get_db)):
|
|
| 198 |
return Response(content="success", media_type="text/plain")
|
| 199 |
|
| 200 |
@router.get("/api/wallet/check_order/{order_id}")
|
| 201 |
-
async def check_order(order_id: str, account: str = None, db: Session = Depends(get_db)):
|
| 202 |
# 防线 1:先查本地数据库(如果 Webhook 成功了,这里就能查到)
|
| 203 |
tx = db.query(Transaction).filter(Transaction.tx_id == order_id).first()
|
| 204 |
if tx:
|
|
@@ -224,7 +224,7 @@ async def check_order(order_id: str, account: str = None, db: Session = Depends(
|
|
| 224 |
wallet = Wallet(account=account, balance=0, earn_balance=0, tip_balance=0, frozen_balance=0)
|
| 225 |
db.add(wallet)
|
| 226 |
|
| 227 |
-
wallet.balance = (wallet.balance or 0) + amount
|
| 228 |
|
| 229 |
last_tx = db.query(Transaction).filter(Transaction.account == account).order_by(Transaction.created_at.desc()).first()
|
| 230 |
prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
|
|
@@ -240,6 +240,7 @@ async def check_order(order_id: str, account: str = None, db: Session = Depends(
|
|
| 240 |
|
| 241 |
return {"status": "SUCCESS"}
|
| 242 |
except Exception as e:
|
|
|
|
| 243 |
print(f"主动查单发生异常: {e}")
|
| 244 |
|
| 245 |
# 如果没查到支付成功状态,继续让前端等
|
|
@@ -357,9 +358,9 @@ async def purchase_item(request: Request, req: PurchaseRequest, current_user: st
|
|
| 357 |
seller_wallet = Wallet(account=seller_account, balance=0, earn_balance=0, tip_balance=0, frozen_balance=0)
|
| 358 |
db.add(seller_wallet)
|
| 359 |
|
| 360 |
-
buyer_wallet.balance = (buyer_wallet.balance or 0) - price
|
| 361 |
-
seller_wallet.balance = (seller_wallet.balance or 0) + price # 实际收入进统一余额
|
| 362 |
-
seller_wallet.earn_balance = (seller_wallet.earn_balance or 0) + price # 累计销售收益统计(只增不减)
|
| 363 |
|
| 364 |
# 🔄 P7后悔模式:记录购买价格
|
| 365 |
new_ownership = Ownership(account=req.account, item_id=req.item_id, price_paid=price)
|
|
@@ -438,13 +439,14 @@ async def purchase_item(request: Request, req: PurchaseRequest, current_user: st
|
|
| 438 |
async def tip_user(request: Request, req: TipRequest, current_user: str = Depends(require_auth), db: Session = Depends(get_db)):
|
| 439 |
# 🔒 使用已认证用户账号,忽略客户端传入的 sender_account
|
| 440 |
req.sender_account = current_user
|
| 441 |
-
|
| 442 |
-
|
|
|
|
| 443 |
if req.sender_account == req.target_account:
|
| 444 |
raise HTTPException(status_code=400, detail="不能打赏给自己")
|
| 445 |
|
| 446 |
-
# 🔒 P1幂等性防护:检查最近
|
| 447 |
-
recent_cutoff = datetime.datetime.utcnow() - datetime.timedelta(seconds=
|
| 448 |
duplicate_tx = db.query(Transaction).filter(
|
| 449 |
Transaction.account == req.sender_account,
|
| 450 |
Transaction.tx_type == "TIP_OUT",
|
|
@@ -466,9 +468,9 @@ async def tip_user(request: Request, req: TipRequest, current_user: str = Depend
|
|
| 466 |
target_wallet = Wallet(account=req.target_account, balance=0, earn_balance=0, tip_balance=0, frozen_balance=0)
|
| 467 |
db.add(target_wallet)
|
| 468 |
|
| 469 |
-
sender_wallet.balance
|
| 470 |
-
target_wallet.balance
|
| 471 |
-
target_wallet.tip_balance
|
| 472 |
|
| 473 |
tx_id_sender = f"TIP_OUT_{int(time.time())}_{uuid.uuid4().hex[:6]}"
|
| 474 |
tx_id_target = f"TIP_IN_{int(time.time())}_{uuid.uuid4().hex[:6]}"
|
|
@@ -556,11 +558,19 @@ async def tip_user(request: Request, req: TipRequest, current_user: str = Depend
|
|
| 556 |
u["tip_history"][current_month] = u["tip_history"].get(current_month, 0) + req.amount
|
| 557 |
|
| 558 |
if "tip_board" not in u: u["tip_board"] = []
|
| 559 |
-
|
|
|
|
|
|
|
|
|
|
| 560 |
if sender_entry:
|
| 561 |
sender_entry["amount"] += req.amount
|
| 562 |
-
else:
|
| 563 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 564 |
u["tip_board"] = sorted(u["tip_board"], key=lambda x: x["amount"], reverse=True)
|
| 565 |
json_db.save_data("users.json", users_db)
|
| 566 |
|
|
@@ -572,11 +582,19 @@ async def tip_user(request: Request, req: TipRequest, current_user: str = Depend
|
|
| 572 |
item["tip_history"][current_month] = item["tip_history"].get(current_month, 0) + req.amount
|
| 573 |
|
| 574 |
if "tip_board" not in item: item["tip_board"] = []
|
| 575 |
-
|
|
|
|
|
|
|
|
|
|
| 576 |
if sender_entry:
|
| 577 |
sender_entry["amount"] += req.amount
|
| 578 |
-
else:
|
| 579 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 580 |
item["tip_board"] = sorted(item["tip_board"], key=lambda x: x["amount"], reverse=True)
|
| 581 |
json_db.save_data("items.json", items_db)
|
| 582 |
break
|
|
@@ -611,11 +629,13 @@ async def withdraw(request: Request, req: WithdrawRequest, current_user: str = D
|
|
| 611 |
raise HTTPException(status_code=400, detail="最小提现金额为1积分")
|
| 612 |
|
| 613 |
# 🔒 验证码缓存键格式:{contact}_{action_type},与 send_code 接口一致
|
| 614 |
-
# 先通过账号查询用户邮箱,再用
|
| 615 |
users_db = json_db.load_data("users.json", default_data={})
|
| 616 |
user_info = users_db.get(req.account, {})
|
| 617 |
-
user_email = user_info.get("email", "")
|
| 618 |
-
|
|
|
|
|
|
|
| 619 |
code_data = VERIFY_CODES.get(key)
|
| 620 |
# 🔒 P0安全修复:统一使用 expires_at 字段,兼容旧版 expires
|
| 621 |
expire_time = code_data.get("expires_at", code_data.get("expires", 0)) if code_data else 0
|
|
@@ -1057,14 +1077,14 @@ async def refund_purchase(request: Request, item_id: str, current_user: str = De
|
|
| 1057 |
seller_wallet = db.query(Wallet).filter(Wallet.account == seller_account).with_for_update().first()
|
| 1058 |
|
| 1059 |
if seller_wallet:
|
| 1060 |
-
#
|
| 1061 |
-
|
| 1062 |
-
|
| 1063 |
-
|
| 1064 |
# 不修改 earn_balance(保持"累计销售收益只增不减"语义)
|
| 1065 |
|
| 1066 |
if buyer_wallet:
|
| 1067 |
-
buyer_wallet.balance
|
| 1068 |
else:
|
| 1069 |
buyer_wallet = Wallet(account=account, balance=refund_amount)
|
| 1070 |
db.add(buyer_wallet)
|
|
|
|
| 107 |
# 3. 完美加载
|
| 108 |
alipay = AliPay(
|
| 109 |
appid=raw_appid,
|
| 110 |
+
app_notify_url=os.environ.get("ALIPAY_NOTIFY_URL", "https://zhiwei666-comfyui-ranking-api.hf.space/api/wallet/alipay_notify"),
|
| 111 |
app_private_key_string=priv_key_formatted,
|
| 112 |
alipay_public_key_string=pub_key_formatted,
|
| 113 |
sign_type="RSA2",
|
|
|
|
| 127 |
async def create_recharge_order(req: RechargeRequest, current_user: str = Depends(require_auth)):
|
| 128 |
if not alipay:
|
| 129 |
# 这里会将真实的错误原因直接弹窗发给前端!
|
| 130 |
+
raise HTTPException(status_code=500, detail="支付网关配置错误,请联系管理员")
|
| 131 |
|
| 132 |
# 🔒 使用已认证用户账号,忽略客户端传入的 account
|
| 133 |
authenticated_account = current_user
|
| 134 |
+
order_id = f"PAY_{uuid.uuid4().hex}"
|
| 135 |
subject = f"ComfyUI Community Points - {authenticated_account}"
|
| 136 |
|
| 137 |
order_string = alipay.api_alipay_trade_precreate(
|
|
|
|
| 174 |
wallet = Wallet(account=account, balance=0, earn_balance=0, tip_balance=0, frozen_balance=0)
|
| 175 |
db.add(wallet)
|
| 176 |
|
| 177 |
+
wallet.balance = int(wallet.balance or 0) + int(amount)
|
| 178 |
|
| 179 |
last_tx = db.query(Transaction).filter(Transaction.account == account).order_by(Transaction.created_at.desc()).first()
|
| 180 |
prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
|
|
|
|
| 198 |
return Response(content="success", media_type="text/plain")
|
| 199 |
|
| 200 |
@router.get("/api/wallet/check_order/{order_id}")
|
| 201 |
+
async def check_order(order_id: str, account: str = None, current_user: str = Depends(require_auth), db: Session = Depends(get_db)):
|
| 202 |
# 防线 1:先查本地数据库(如果 Webhook 成功了,这里就能查到)
|
| 203 |
tx = db.query(Transaction).filter(Transaction.tx_id == order_id).first()
|
| 204 |
if tx:
|
|
|
|
| 224 |
wallet = Wallet(account=account, balance=0, earn_balance=0, tip_balance=0, frozen_balance=0)
|
| 225 |
db.add(wallet)
|
| 226 |
|
| 227 |
+
wallet.balance = int(wallet.balance or 0) + int(amount)
|
| 228 |
|
| 229 |
last_tx = db.query(Transaction).filter(Transaction.account == account).order_by(Transaction.created_at.desc()).first()
|
| 230 |
prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
|
|
|
|
| 240 |
|
| 241 |
return {"status": "SUCCESS"}
|
| 242 |
except Exception as e:
|
| 243 |
+
db.rollback()
|
| 244 |
print(f"主动查单发生异常: {e}")
|
| 245 |
|
| 246 |
# 如果没查到支付成功状态,继续让前端等
|
|
|
|
| 358 |
seller_wallet = Wallet(account=seller_account, balance=0, earn_balance=0, tip_balance=0, frozen_balance=0)
|
| 359 |
db.add(seller_wallet)
|
| 360 |
|
| 361 |
+
buyer_wallet.balance = int(buyer_wallet.balance or 0) - int(price)
|
| 362 |
+
seller_wallet.balance = int(seller_wallet.balance or 0) + int(price) # 实际收入进统一余额
|
| 363 |
+
seller_wallet.earn_balance = int(seller_wallet.earn_balance or 0) + int(price) # 累计销售收益统计(只增不减)
|
| 364 |
|
| 365 |
# 🔄 P7后悔模式:记录购买价格
|
| 366 |
new_ownership = Ownership(account=req.account, item_id=req.item_id, price_paid=price)
|
|
|
|
| 439 |
async def tip_user(request: Request, req: TipRequest, current_user: str = Depends(require_auth), db: Session = Depends(get_db)):
|
| 440 |
# 🔒 使用已认证用户账号,忽略客户端传入的 sender_account
|
| 441 |
req.sender_account = current_user
|
| 442 |
+
MAX_TIP_AMOUNT = 100000
|
| 443 |
+
if req.amount <= 0 or req.amount > MAX_TIP_AMOUNT:
|
| 444 |
+
raise HTTPException(status_code=400, detail=f"打赏金额必须在1-{MAX_TIP_AMOUNT}之间")
|
| 445 |
if req.sender_account == req.target_account:
|
| 446 |
raise HTTPException(status_code=400, detail="不能打赏给自己")
|
| 447 |
|
| 448 |
+
# 🔒 P1幂等性防护:检查最近30秒内是否存在相同交易
|
| 449 |
+
recent_cutoff = datetime.datetime.utcnow() - datetime.timedelta(seconds=30)
|
| 450 |
duplicate_tx = db.query(Transaction).filter(
|
| 451 |
Transaction.account == req.sender_account,
|
| 452 |
Transaction.tx_type == "TIP_OUT",
|
|
|
|
| 468 |
target_wallet = Wallet(account=req.target_account, balance=0, earn_balance=0, tip_balance=0, frozen_balance=0)
|
| 469 |
db.add(target_wallet)
|
| 470 |
|
| 471 |
+
sender_wallet.balance = int(sender_wallet.balance or 0) - int(req.amount)
|
| 472 |
+
target_wallet.balance = int(target_wallet.balance or 0) + int(req.amount) # 实际收入进统一余额
|
| 473 |
+
target_wallet.tip_balance = int(target_wallet.tip_balance or 0) + int(req.amount) # 累计打赏收益统计(只增不减)
|
| 474 |
|
| 475 |
tx_id_sender = f"TIP_OUT_{int(time.time())}_{uuid.uuid4().hex[:6]}"
|
| 476 |
tx_id_target = f"TIP_IN_{int(time.time())}_{uuid.uuid4().hex[:6]}"
|
|
|
|
| 558 |
u["tip_history"][current_month] = u["tip_history"].get(current_month, 0) + req.amount
|
| 559 |
|
| 560 |
if "tip_board" not in u: u["tip_board"] = []
|
| 561 |
+
if req.is_anonymous:
|
| 562 |
+
sender_entry = next((x for x in u["tip_board"] if x.get("account") is None and x.get("is_anon")), None)
|
| 563 |
+
else:
|
| 564 |
+
sender_entry = next((x for x in u["tip_board"] if x.get("account") == req.sender_account), None)
|
| 565 |
if sender_entry:
|
| 566 |
sender_entry["amount"] += req.amount
|
| 567 |
+
else:
|
| 568 |
+
tip_entry = {"amount": req.amount, "is_anon": req.is_anonymous}
|
| 569 |
+
if not req.is_anonymous:
|
| 570 |
+
tip_entry["account"] = req.sender_account
|
| 571 |
+
else:
|
| 572 |
+
tip_entry["account"] = None
|
| 573 |
+
u["tip_board"].append(tip_entry)
|
| 574 |
u["tip_board"] = sorted(u["tip_board"], key=lambda x: x["amount"], reverse=True)
|
| 575 |
json_db.save_data("users.json", users_db)
|
| 576 |
|
|
|
|
| 582 |
item["tip_history"][current_month] = item["tip_history"].get(current_month, 0) + req.amount
|
| 583 |
|
| 584 |
if "tip_board" not in item: item["tip_board"] = []
|
| 585 |
+
if req.is_anonymous:
|
| 586 |
+
sender_entry = next((x for x in item["tip_board"] if x.get("account") is None and x.get("is_anon")), None)
|
| 587 |
+
else:
|
| 588 |
+
sender_entry = next((x for x in item["tip_board"] if x.get("account") == req.sender_account), None)
|
| 589 |
if sender_entry:
|
| 590 |
sender_entry["amount"] += req.amount
|
| 591 |
+
else:
|
| 592 |
+
tip_entry = {"amount": req.amount, "is_anon": req.is_anonymous}
|
| 593 |
+
if not req.is_anonymous:
|
| 594 |
+
tip_entry["account"] = req.sender_account
|
| 595 |
+
else:
|
| 596 |
+
tip_entry["account"] = None
|
| 597 |
+
item["tip_board"].append(tip_entry)
|
| 598 |
item["tip_board"] = sorted(item["tip_board"], key=lambda x: x["amount"], reverse=True)
|
| 599 |
json_db.save_data("items.json", items_db)
|
| 600 |
break
|
|
|
|
| 629 |
raise HTTPException(status_code=400, detail="最小提现金额为1积分")
|
| 630 |
|
| 631 |
# 🔒 验证码缓存键格式:{contact}_{action_type},与 send_code 接口一致
|
| 632 |
+
# 先通过账号查询用户邮箱或手机号,再用其构建缓存键(不 fallback 到 account)
|
| 633 |
users_db = json_db.load_data("users.json", default_data={})
|
| 634 |
user_info = users_db.get(req.account, {})
|
| 635 |
+
user_email = user_info.get("email") or user_info.get("phone", "")
|
| 636 |
+
if not user_email:
|
| 637 |
+
raise HTTPException(status_code=400, detail="请先绑定邮箱或手机号")
|
| 638 |
+
key = f"{user_email}_withdraw"
|
| 639 |
code_data = VERIFY_CODES.get(key)
|
| 640 |
# 🔒 P0安全修复:统一使用 expires_at 字段,兼容旧版 expires
|
| 641 |
expire_time = code_data.get("expires_at", code_data.get("expires", 0)) if code_data else 0
|
|
|
|
| 1077 |
seller_wallet = db.query(Wallet).filter(Wallet.account == seller_account).with_for_update().first()
|
| 1078 |
|
| 1079 |
if seller_wallet:
|
| 1080 |
+
# 直接扣减,允许余额为负(退款优先于卖家余额安全)
|
| 1081 |
+
seller_wallet.balance = int(seller_wallet.balance or 0) - int(refund_amount) # 卖家扣回
|
| 1082 |
+
if seller_wallet.balance < 0:
|
| 1083 |
+
logger.warning(f"NEGATIVE_BALANCE | seller balance went negative after refund | seller={seller_account}")
|
| 1084 |
# 不修改 earn_balance(保持"累计销售收益只增不减"语义)
|
| 1085 |
|
| 1086 |
if buyer_wallet:
|
| 1087 |
+
buyer_wallet.balance = int(buyer_wallet.balance or 0) + int(refund_amount) # 买家退款
|
| 1088 |
else:
|
| 1089 |
buyer_wallet = Wallet(account=account, balance=refund_amount)
|
| 1090 |
db.add(buyer_wallet)
|
云端_定时版本检测引擎.py
CHANGED
|
@@ -93,7 +93,6 @@ async def trigger_update_notifications(updated_items: list):
|
|
| 93 |
if not updated_items:
|
| 94 |
return
|
| 95 |
|
| 96 |
-
session = None
|
| 97 |
try:
|
| 98 |
# 加载已通知记录,防止重复通知
|
| 99 |
update_notifications_db = db.load_data("update_notifications.json", default_data={})
|
|
@@ -114,63 +113,67 @@ async def trigger_update_notifications(updated_items: list):
|
|
| 114 |
if not items_to_notify:
|
| 115 |
print("📢 所有插件更新已通知过,无需重复发送")
|
| 116 |
return
|
| 117 |
-
|
| 118 |
# 从 SQL 数据库查询购买记录
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
}
|
| 152 |
-
|
| 153 |
-
# 分批发送通知
|
| 154 |
-
notified_count = 0
|
| 155 |
-
for i in range(0, len(target_users), batch_size):
|
| 156 |
-
batch = target_users[i:i + batch_size]
|
| 157 |
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
|
| 166 |
-
#
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
# 记录该插件已通知的版本
|
| 171 |
-
update_notifications_db[item_id] = version_hash
|
| 172 |
-
total_notified += notified_count
|
| 173 |
-
print(f"📢 插件 [{title}] 已向 {notified_count} 位用户发送更新通知")
|
| 174 |
|
| 175 |
# 保存已通知记录
|
| 176 |
db.save_data("update_notifications.json", update_notifications_db)
|
|
@@ -179,10 +182,6 @@ async def trigger_update_notifications(updated_items: list):
|
|
| 179 |
except Exception as e:
|
| 180 |
logger.error(f"触发更新通知时发生错误: {e}")
|
| 181 |
# 不抛出异常,让主流程继续
|
| 182 |
-
finally:
|
| 183 |
-
# 确保 session 正确关闭
|
| 184 |
-
if session:
|
| 185 |
-
session.close()
|
| 186 |
|
| 187 |
|
| 188 |
async def precache_github_zip(repo_url: str, token: Optional[str], item_id: str, version_hash: str):
|
|
@@ -258,6 +257,23 @@ async def precache_github_zip(repo_url: str, token: Optional[str], item_id: str,
|
|
| 258 |
os.remove(temp_path)
|
| 259 |
return False
|
| 260 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
zip_size = os.path.getsize(temp_path)
|
| 262 |
logger.info(f"[预缓存] ZIP 下载完成: {item_id}, 大小 {zip_size} 字节")
|
| 263 |
|
|
|
|
| 93 |
if not updated_items:
|
| 94 |
return
|
| 95 |
|
|
|
|
| 96 |
try:
|
| 97 |
# 加载已通知记录,防止重复通知
|
| 98 |
update_notifications_db = db.load_data("update_notifications.json", default_data={})
|
|
|
|
| 113 |
if not items_to_notify:
|
| 114 |
print("📢 所有插件更新已通知过,无需重复发送")
|
| 115 |
return
|
| 116 |
+
|
| 117 |
# 从 SQL 数据库查询购买记录
|
| 118 |
+
with SessionLocal() as session:
|
| 119 |
+
# 为每个需要通知的插件,查找已购买用户并发送通知
|
| 120 |
+
total_notified = 0
|
| 121 |
+
batch_size = 50 # 分批发送,每扐50个用户
|
| 122 |
+
|
| 123 |
+
for item in items_to_notify:
|
| 124 |
+
item_id = item["id"]
|
| 125 |
+
title = item.get("title", "未知插件")
|
| 126 |
+
version_hash = item["version_hash"]
|
| 127 |
+
|
| 128 |
+
# 从 SQL ownerships 表查询已购买该插件的用户(排除已退款记录)
|
| 129 |
+
ownerships = session.query(Ownership).filter(
|
| 130 |
+
Ownership.item_id == item_id,
|
| 131 |
+
Ownership.is_refunded == False
|
| 132 |
+
).all()
|
| 133 |
+
|
| 134 |
+
# 使用 set 去重用户账号
|
| 135 |
+
target_users = list(set(record.account for record in ownerships if record.account))
|
| 136 |
+
|
| 137 |
+
if not target_users:
|
| 138 |
+
# 无人购买,但仍记录已通知版本
|
| 139 |
+
update_notifications_db[item_id] = version_hash
|
| 140 |
+
continue
|
| 141 |
+
|
| 142 |
+
# 准备通知数据
|
| 143 |
+
notif_data = {
|
| 144 |
+
"type": "plugin_update",
|
| 145 |
+
"from_user": "system",
|
| 146 |
+
"target_item_id": item_id,
|
| 147 |
+
"target_item_title": title,
|
| 148 |
+
"content": f"您已安装的插件 [{title}] 有新版本可用"
|
| 149 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
+
# 分批发送通知
|
| 152 |
+
notified_count = 0
|
| 153 |
+
failed_users = []
|
| 154 |
+
for i in range(0, len(target_users), batch_size):
|
| 155 |
+
batch = target_users[i:i + batch_size]
|
| 156 |
+
|
| 157 |
+
for account in batch:
|
| 158 |
+
try:
|
| 159 |
+
add_notification(account, notif_data)
|
| 160 |
+
notified_count += 1
|
| 161 |
+
except Exception as e:
|
| 162 |
+
failed_users.append(account)
|
| 163 |
+
logger.error(f"发送更新通知失败 (user={account}, item={item_id}): {e}")
|
| 164 |
+
# 单个失败不影响其他用户
|
| 165 |
+
|
| 166 |
+
if failed_users:
|
| 167 |
+
logger.warning(f"NOTIFY_FAILED | count={len(failed_users)} | users={failed_users[:10]}")
|
| 168 |
+
|
| 169 |
+
# 每批之间短暂休眠,避免通知风暴
|
| 170 |
+
if i + batch_size < len(target_users):
|
| 171 |
+
await asyncio.sleep(0.5)
|
| 172 |
|
| 173 |
+
# 记录该插件已通知的版本
|
| 174 |
+
update_notifications_db[item_id] = version_hash
|
| 175 |
+
total_notified += notified_count
|
| 176 |
+
print(f"📢 插件 [{title}] 已向 {notified_count} 位用户发送更新通知")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
|
| 178 |
# 保存已通知记录
|
| 179 |
db.save_data("update_notifications.json", update_notifications_db)
|
|
|
|
| 182 |
except Exception as e:
|
| 183 |
logger.error(f"触发更新通知时发生错误: {e}")
|
| 184 |
# 不抛出异常,让主流程继续
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
|
| 186 |
|
| 187 |
async def precache_github_zip(repo_url: str, token: Optional[str], item_id: str, version_hash: str):
|
|
|
|
| 257 |
os.remove(temp_path)
|
| 258 |
return False
|
| 259 |
|
| 260 |
+
# 🔒 ZIP 膨胀比检查(防止 zip bomb)
|
| 261 |
+
try:
|
| 262 |
+
with zipfile.ZipFile(temp_path, 'r') as zf:
|
| 263 |
+
uncompressed_size = sum(f.file_size for f in zf.infolist())
|
| 264 |
+
zip_size_check = os.path.getsize(temp_path)
|
| 265 |
+
if uncompressed_size > zip_size_check * 10:
|
| 266 |
+
logger.warning(
|
| 267 |
+
f"[预缓存] ZIP 膨胀比过高,疑似 zip bomb: {item_id}, "
|
| 268 |
+
f"压缩={zip_size_check}字节, 未压缩={uncompressed_size}字节"
|
| 269 |
+
)
|
| 270 |
+
os.remove(temp_path)
|
| 271 |
+
return False
|
| 272 |
+
except zipfile.BadZipFile:
|
| 273 |
+
logger.warning(f"[预缓存] ZIP 文件损坏: {item_id}")
|
| 274 |
+
os.remove(temp_path)
|
| 275 |
+
return False
|
| 276 |
+
|
| 277 |
zip_size = os.path.getsize(temp_path)
|
| 278 |
logger.info(f"[预缓存] ZIP 下载完成: {item_id}, 大小 {zip_size} 字节")
|
| 279 |
|
安全认证.py
CHANGED
|
@@ -27,8 +27,12 @@ import bcrypt
|
|
| 27 |
# ==========================================
|
| 28 |
# JWT_SECRET: 用于签名 Token,生产环境必须设置环境变量
|
| 29 |
# PASSWORD_SALT: 密码哈希加盐,增强安全性
|
| 30 |
-
JWT_SECRET = os.environ.get("JWT_SECRET"
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
# Token 有效期配置(单位:秒)
|
| 34 |
TOKEN_EXPIRE_SECONDS = 7 * 24 * 60 * 60 # 默认7天(向后兼容)
|
|
@@ -366,11 +370,14 @@ def verify_token_with_fallback(token: str) -> Tuple[bool, Optional[str], str]:
|
|
| 366 |
# 作用:提供所有者、管理员等权限检查功能
|
| 367 |
# 用法:减少路由中的重复权限检查代码
|
| 368 |
|
| 369 |
-
import os
|
| 370 |
from functools import wraps
|
| 371 |
|
| 372 |
-
# 管理员账号列表
|
| 373 |
-
ADMIN_ACCOUNTS =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 374 |
|
| 375 |
|
| 376 |
def is_admin(account: str) -> bool:
|
|
|
|
| 27 |
# ==========================================
|
| 28 |
# JWT_SECRET: 用于签名 Token,生产环境必须设置环境变量
|
| 29 |
# PASSWORD_SALT: 密码哈希加盐,增强安全性
|
| 30 |
+
JWT_SECRET = os.environ.get("JWT_SECRET")
|
| 31 |
+
if not JWT_SECRET:
|
| 32 |
+
raise RuntimeError("必须设置 JWT_SECRET 环境变量")
|
| 33 |
+
PASSWORD_SALT = os.environ.get("PASSWORD_SALT")
|
| 34 |
+
if not PASSWORD_SALT:
|
| 35 |
+
raise RuntimeError("必须设置 PASSWORD_SALT 环境变量")
|
| 36 |
|
| 37 |
# Token 有效期配置(单位:秒)
|
| 38 |
TOKEN_EXPIRE_SECONDS = 7 * 24 * 60 * 60 # 默认7天(向后兼容)
|
|
|
|
| 370 |
# 作用:提供所有者、管理员等权限检查功能
|
| 371 |
# 用法:减少路由中的重复权限检查代码
|
| 372 |
|
|
|
|
| 373 |
from functools import wraps
|
| 374 |
|
| 375 |
+
# 管理员账号列表(从环境变量读取,唯一统一定义)
|
| 376 |
+
ADMIN_ACCOUNTS = set(
|
| 377 |
+
acc.strip()
|
| 378 |
+
for acc in os.getenv("ADMIN_ACCOUNTS", "").split(",")
|
| 379 |
+
if acc.strip()
|
| 380 |
+
)
|
| 381 |
|
| 382 |
|
| 383 |
def is_admin(account: str) -> bool:
|
数据库连接.py
CHANGED
|
@@ -191,17 +191,29 @@ if sys.platform == "win32":
|
|
| 191 |
# Windows 文件锁
|
| 192 |
import msvcrt
|
| 193 |
|
|
|
|
|
|
|
|
|
|
| 194 |
def _lock_file(file_obj, exclusive=True):
|
| 195 |
-
"""Windows 文件锁:锁定整个文件"""
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
|
| 206 |
def _unlock_file(file_obj):
|
| 207 |
"""Windows 文件锁:释放锁"""
|
|
@@ -404,6 +416,9 @@ def save_data(file_name: str, data: Union[Dict, List]) -> bool:
|
|
| 404 |
os.remove(local_path)
|
| 405 |
os.rename(temp_path, local_path)
|
| 406 |
|
|
|
|
|
|
|
|
|
|
| 407 |
except Exception as e:
|
| 408 |
# 写入失败,清理临时文件
|
| 409 |
if os.path.exists(temp_path):
|
|
@@ -411,9 +426,6 @@ def save_data(file_name: str, data: Union[Dict, List]) -> bool:
|
|
| 411 |
print(f"🚨 保存 {file_name} 失败: {e}")
|
| 412 |
raise
|
| 413 |
|
| 414 |
-
# ========== 🚀 P1优化:更新内存缓存 ==========
|
| 415 |
-
_set_to_cache(file_name, data, local_path)
|
| 416 |
-
|
| 417 |
# ========== 第五步:标记文件脏,等待批量同步 ==========
|
| 418 |
if HF_TOKEN:
|
| 419 |
_mark_dirty(file_name)
|
|
@@ -706,15 +718,15 @@ def atomic_update(file_name: str, updater, default_data=None):
|
|
| 706 |
os.remove(local_path)
|
| 707 |
os.rename(temp_path, local_path)
|
| 708 |
|
|
|
|
|
|
|
|
|
|
| 709 |
except Exception as e:
|
| 710 |
if os.path.exists(temp_path):
|
| 711 |
os.remove(temp_path)
|
| 712 |
logger.error(f"保存 {file_name} 失败: {e}")
|
| 713 |
raise
|
| 714 |
|
| 715 |
-
# ========== 第四步:更新内存缓存 ==========
|
| 716 |
-
_set_to_cache(file_name, data, local_path)
|
| 717 |
-
|
| 718 |
# ========== 第五步:标记文件脏,等待批量同步 ==========
|
| 719 |
if HF_TOKEN:
|
| 720 |
_mark_dirty(file_name)
|
|
|
|
| 191 |
# Windows 文件锁
|
| 192 |
import msvcrt
|
| 193 |
|
| 194 |
+
_WIN_LOCK_MAX_RETRIES = 5 # 最大重试次数
|
| 195 |
+
_WIN_LOCK_BASE_DELAY = 0.1 # 基础等待秒数
|
| 196 |
+
|
| 197 |
def _lock_file(file_obj, exclusive=True):
|
| 198 |
+
"""Windows 文件锁:锁定整个文件,带重试逻辑"""
|
| 199 |
+
mode = msvcrt.LK_NBLCK if exclusive else msvcrt.LK_NBRLCK
|
| 200 |
+
file_obj.seek(0)
|
| 201 |
+
for attempt in range(_WIN_LOCK_MAX_RETRIES):
|
| 202 |
+
try:
|
| 203 |
+
msvcrt.locking(file_obj.fileno(), mode, 1)
|
| 204 |
+
return # 加锁成功
|
| 205 |
+
except IOError:
|
| 206 |
+
if attempt < _WIN_LOCK_MAX_RETRIES - 1:
|
| 207 |
+
wait = _WIN_LOCK_BASE_DELAY * (2 ** attempt) # 指数退避
|
| 208 |
+
time.sleep(wait)
|
| 209 |
+
else:
|
| 210 |
+
# 最后一次仍失败,独占锁抛出异常,共享锁降级为无锁读取
|
| 211 |
+
if exclusive:
|
| 212 |
+
raise IOError(f"Windows 文件锁获取失败(重试{_WIN_LOCK_MAX_RETRIES}次后放弃)")
|
| 213 |
+
else:
|
| 214 |
+
# 共享锁获取失败时,记录警告但不阻塞读取
|
| 215 |
+
logger.warning(f"Windows 共享锁获取失败,降级为无锁读取")
|
| 216 |
+
return
|
| 217 |
|
| 218 |
def _unlock_file(file_obj):
|
| 219 |
"""Windows 文件锁:释放锁"""
|
|
|
|
| 416 |
os.remove(local_path)
|
| 417 |
os.rename(temp_path, local_path)
|
| 418 |
|
| 419 |
+
# ========== 🚀 P1优化:更新内存缓存(在锁内,确保写后立即可见) ==========
|
| 420 |
+
_set_to_cache(file_name, data, local_path)
|
| 421 |
+
|
| 422 |
except Exception as e:
|
| 423 |
# 写入失败,清理临时文件
|
| 424 |
if os.path.exists(temp_path):
|
|
|
|
| 426 |
print(f"🚨 保存 {file_name} 失败: {e}")
|
| 427 |
raise
|
| 428 |
|
|
|
|
|
|
|
|
|
|
| 429 |
# ========== 第五步:标记文件脏,等待批量同步 ==========
|
| 430 |
if HF_TOKEN:
|
| 431 |
_mark_dirty(file_name)
|
|
|
|
| 718 |
os.remove(local_path)
|
| 719 |
os.rename(temp_path, local_path)
|
| 720 |
|
| 721 |
+
# ========== 更新内存缓存(在锁内,确保写后立即可见) ==========
|
| 722 |
+
_set_to_cache(file_name, data, local_path)
|
| 723 |
+
|
| 724 |
except Exception as e:
|
| 725 |
if os.path.exists(temp_path):
|
| 726 |
os.remove(temp_path)
|
| 727 |
logger.error(f"保存 {file_name} 失败: {e}")
|
| 728 |
raise
|
| 729 |
|
|
|
|
|
|
|
|
|
|
| 730 |
# ========== 第五步:标记文件脏,等待批量同步 ==========
|
| 731 |
if HF_TOKEN:
|
| 732 |
_mark_dirty(file_name)
|