Spaces:
Running
Running
优化安全问题
Browse files- app.py +1 -1
- models.py +27 -56
- models_sql.py +10 -0
- requirements.txt +3 -2
- router_admin_plugins.py +8 -4
- router_items.py +40 -25
- router_messages.py +18 -2
- router_posts.py +32 -52
- router_prompts.py +32 -52
- router_proxy.py +17 -31
- router_tasks.py +124 -107
- router_wallet.py +77 -19
- 安全认证.py +19 -41
- 数据库连接.py +8 -2
- 测试脚本.py +32 -19
app.py
CHANGED
|
@@ -60,7 +60,7 @@ from router_open_api import router as open_api_router # 🌐 通用开放API(
|
|
| 60 |
from router_admin_plugins import router as admin_plugins_router # 🔒 管理后台 - 第三方插件管理API
|
| 61 |
|
| 62 |
from database_sql import init_sql_db, get_db, SessionLocal
|
| 63 |
-
from models_sql import Ownership
|
| 64 |
|
| 65 |
# 🚀 P2优化:速率限制 (防止暴力攻击)
|
| 66 |
from slowapi import Limiter, _rate_limit_exceeded_handler
|
|
|
|
| 60 |
from router_admin_plugins import router as admin_plugins_router # 🔒 管理后台 - 第三方插件管理API
|
| 61 |
|
| 62 |
from database_sql import init_sql_db, get_db, SessionLocal
|
| 63 |
+
from models_sql import SystemConfig # 🧹 P2清理:Ownership 在本文件无引用
|
| 64 |
|
| 65 |
# 🚀 P2优化:速率限制 (防止暴力攻击)
|
| 66 |
from slowapi import Limiter, _rate_limit_exceeded_handler
|
models.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 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:
|
|
@@ -37,15 +37,26 @@ class UserRegister(BaseModel):
|
|
| 37 |
region: Optional[str] = None
|
| 38 |
intro: Optional[str] = None
|
| 39 |
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
class UserLogin(BaseModel):
|
| 44 |
account: str
|
| 45 |
password: str
|
| 46 |
remember: Optional[bool] = True # 保持登录选项:True=30天, False=24小时
|
| 47 |
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
class UserUpdate(BaseModel):
|
| 51 |
name: Optional[str] = None
|
|
@@ -58,15 +69,7 @@ class UserUpdate(BaseModel):
|
|
| 58 |
avatarDataUrl: Optional[str] = None
|
| 59 |
bannerUrl: Optional[str] = None # 🚀 个人资料卡背景图云端URL
|
| 60 |
|
| 61 |
-
|
| 62 |
-
old_password: Optional[str] = None
|
| 63 |
-
new_password: str
|
| 64 |
-
verifyContact: str
|
| 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
|
|
@@ -130,7 +133,8 @@ class InteractionToggle(BaseModel):
|
|
| 130 |
class RatingRequest(BaseModel):
|
| 131 |
score: int # 1-5
|
| 132 |
|
| 133 |
-
@
|
|
|
|
| 134 |
def validate_score(cls, v):
|
| 135 |
if not isinstance(v, int):
|
| 136 |
raise ValueError("score must be an integer between 1 and 5")
|
|
@@ -186,57 +190,24 @@ class TaskUpdate(BaseModel):
|
|
| 186 |
depositRatio: Optional[int] = None
|
| 187 |
deadline: Optional[str] = None
|
| 188 |
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
task_id: str
|
| 192 |
-
applicant: str # 申请者账号
|
| 193 |
-
message: Optional[str] = None # 申请留言
|
| 194 |
-
|
| 195 |
-
class TaskAssign(BaseModel):
|
| 196 |
-
""" 指派接单者 """
|
| 197 |
-
task_id: str
|
| 198 |
-
publisher: str # 发布者(校验权限)
|
| 199 |
-
assignee: str # 被指派的接单者
|
| 200 |
-
|
| 201 |
class TaskSubmit(BaseModel):
|
| 202 |
""" 接单者提交成果 """
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
deliverables: List[str] # 交付物图片URL列表
|
| 206 |
-
note: Optional[str] = None # 备注说明
|
| 207 |
|
| 208 |
-
class TaskAccept(BaseModel):
|
| 209 |
-
""" 发布者验收 """
|
| 210 |
-
task_id: str
|
| 211 |
-
publisher: str # 发布者(校验权限)
|
| 212 |
-
is_accepted: bool # True=验收通过, False=验收不通过
|
| 213 |
-
feedback: Optional[str] = None # 反馈意见
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
# ==========================================
|
| 217 |
-
# ⚖️ P3增强:任务申诉数据模型
|
| 218 |
-
# ==========================================
|
| 219 |
class TaskDispute(BaseModel):
|
| 220 |
""" 发起申诉 """
|
| 221 |
-
task_id: str # 任务ID
|
| 222 |
-
initiator: str # 申诉发起人(可以是发布者或接单者)
|
| 223 |
reason: str # 申诉理由
|
| 224 |
-
evidence: Optional[List[str]] =
|
| 225 |
|
| 226 |
class TaskDisputeResponse(BaseModel):
|
| 227 |
-
""" 被申诉方回应 """
|
| 228 |
-
dispute_id: str # 申诉ID
|
| 229 |
-
respondent: str # 回应人
|
| 230 |
response: str # 回应内容
|
| 231 |
-
evidence: Optional[List[str]] =
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
""" 管理员仲裁 """
|
| 235 |
-
dispute_id: str # 申诉ID
|
| 236 |
-
admin_account: str # 管理员账号
|
| 237 |
-
result: str # 仲裁结果: support_initiator / support_respondent / split
|
| 238 |
-
split_ratio: Optional[int] = 50 # 分成比例(申诉方获得的百分比,仅当result=split时有效)
|
| 239 |
-
admin_note: Optional[str] = None # 管理员备注
|
| 240 |
|
| 241 |
# ==========================================
|
| 242 |
# 💬 讨论区数据模型(小红书风格图文社区)
|
|
|
|
| 1 |
# models.py
|
| 2 |
import re
|
| 3 |
+
from pydantic import BaseModel, Field, field_validator # 🧹 P2修复:pydantic v2 已废弃 v1 风格 validator
|
| 4 |
from typing import Optional, List
|
| 5 |
|
| 6 |
def _validate_email_format(email: str) -> str:
|
|
|
|
| 37 |
region: Optional[str] = None
|
| 38 |
intro: Optional[str] = None
|
| 39 |
|
| 40 |
+
# 🧹 P2修复:v2 中下划线前缀属性会被当作私有属性而非校验器,改用 field_validator 显式挂载(校验逻辑不变)
|
| 41 |
+
@field_validator('password')
|
| 42 |
+
@classmethod
|
| 43 |
+
def _check_password(cls, v):
|
| 44 |
+
return _validate_password_length(v)
|
| 45 |
+
|
| 46 |
+
@field_validator('email')
|
| 47 |
+
@classmethod
|
| 48 |
+
def _check_email(cls, v):
|
| 49 |
+
return _validate_email_format(v)
|
| 50 |
|
| 51 |
class UserLogin(BaseModel):
|
| 52 |
account: str
|
| 53 |
password: str
|
| 54 |
remember: Optional[bool] = True # 保持登录选项:True=30天, False=24小时
|
| 55 |
|
| 56 |
+
@field_validator('password')
|
| 57 |
+
@classmethod
|
| 58 |
+
def _check_password(cls, v):
|
| 59 |
+
return _validate_password_length(v)
|
| 60 |
|
| 61 |
class UserUpdate(BaseModel):
|
| 62 |
name: Optional[str] = None
|
|
|
|
| 69 |
avatarDataUrl: Optional[str] = None
|
| 70 |
bannerUrl: Optional[str] = None # 🚀 个人资料卡背景图云端URL
|
| 71 |
|
| 72 |
+
# 🧹 P2清理:移除零引用的 PasswordReset 模型(重置密码实际走 UserUpdate/专用端点)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
class CommentCreate(BaseModel):
|
| 75 |
item_id: str
|
|
|
|
| 133 |
class RatingRequest(BaseModel):
|
| 134 |
score: int # 1-5
|
| 135 |
|
| 136 |
+
@field_validator("score")
|
| 137 |
+
@classmethod
|
| 138 |
def validate_score(cls, v):
|
| 139 |
if not isinstance(v, int):
|
| 140 |
raise ValueError("score must be an integer between 1 and 5")
|
|
|
|
| 190 |
depositRatio: Optional[int] = None
|
| 191 |
deadline: Optional[str] = None
|
| 192 |
|
| 193 |
+
# 🔧 P2修复:以下三个 body 模型修复前后端参数通道不匹配
|
| 194 |
+
# (前端以 JSON body 提交,旧版端点用裸标量参数会被 FastAPI 解析为 query 导致 422)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
class TaskSubmit(BaseModel):
|
| 196 |
""" 接单者提交成果 """
|
| 197 |
+
deliverables: List[str] # 交付成果列表
|
| 198 |
+
note: Optional[str] = None # 提交说明
|
|
|
|
|
|
|
| 199 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
class TaskDispute(BaseModel):
|
| 201 |
""" 发起申诉 """
|
|
|
|
|
|
|
| 202 |
reason: str # 申诉理由
|
| 203 |
+
evidence: Optional[List[str]] = None # 证据图片URL列表(最多6张)
|
| 204 |
|
| 205 |
class TaskDisputeResponse(BaseModel):
|
| 206 |
+
""" 被申诉方回应申诉 """
|
|
|
|
|
|
|
| 207 |
response: str # 回应内容
|
| 208 |
+
evidence: Optional[List[str]] = None # 证据图片URL列表(最多6张)
|
| 209 |
+
|
| 210 |
+
# 🧹 P2清理:移除零引用的 TaskApply/TaskAssign/TaskAccept 模型(对应端点使用 query 参数且前端匹配)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
|
| 212 |
# ==========================================
|
| 213 |
# 💬 讨论区数据模型(小红书风格图文社区)
|
models_sql.py
CHANGED
|
@@ -55,6 +55,16 @@ class Refund(Base):
|
|
| 55 |
refunded_at = Column(DateTime, default=datetime.datetime.utcnow)
|
| 56 |
ban_until = Column(DateTime) # 禁止购买截止时间(30天后)
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
class Transaction(Base):
|
| 59 |
"""交易流水表"""
|
| 60 |
__tablename__ = "transactions"
|
|
|
|
| 55 |
refunded_at = Column(DateTime, default=datetime.datetime.utcnow)
|
| 56 |
ban_until = Column(DateTime) # 禁止购买截止时间(30天后)
|
| 57 |
|
| 58 |
+
class RechargeOrder(Base):
|
| 59 |
+
"""💰 充值订单预存表(订单号→账号映射持久化,防止实例重启导致回调找不到订单)"""
|
| 60 |
+
__tablename__ = "recharge_orders"
|
| 61 |
+
|
| 62 |
+
order_id = Column(String, primary_key=True) # 商户订单号 PAY_xxx
|
| 63 |
+
account = Column(String, index=True) # 充值用户
|
| 64 |
+
amount = Column(Integer) # 充值金额
|
| 65 |
+
status = Column(String, default="pending") # pending/paid
|
| 66 |
+
created_at = Column(DateTime, default=datetime.datetime.utcnow)
|
| 67 |
+
|
| 68 |
class Transaction(Base):
|
| 69 |
"""交易流水表"""
|
| 70 |
__tablename__ = "transactions"
|
requirements.txt
CHANGED
|
@@ -3,13 +3,14 @@ fastapi==0.104.1
|
|
| 3 |
uvicorn==0.24.0
|
| 4 |
pydantic==2.5.2
|
| 5 |
huggingface_hub==0.19.4
|
| 6 |
-
datasets
|
| 7 |
python-multipart==0.0.6
|
| 8 |
alibabacloud_dysmsapi20170525==2.0.24
|
|
|
|
|
|
|
| 9 |
sqlalchemy==2.0.23
|
| 10 |
psycopg2-binary==2.9.9
|
| 11 |
httpx==0.25.2
|
| 12 |
-
aiofiles==23.2.1
|
| 13 |
# 🚀 P2优化:速率限制
|
| 14 |
slowapi==0.1.9
|
| 15 |
# 🔒 P0安全增强:bcrypt密码哈希
|
|
|
|
| 3 |
uvicorn==0.24.0
|
| 4 |
pydantic==2.5.2
|
| 5 |
huggingface_hub==0.19.4
|
| 6 |
+
# 🧹 P2清理:datasets/aiofiles 全库零引用,已移除
|
| 7 |
python-multipart==0.0.6
|
| 8 |
alibabacloud_dysmsapi20170525==2.0.24
|
| 9 |
+
# 🧹 P2修复:verify_code_engine.py 直接导入 alibabacloud_tea_openapi,显式声明避免隐式依赖漂移
|
| 10 |
+
alibabacloud_tea_openapi==0.3.8
|
| 11 |
sqlalchemy==2.0.23
|
| 12 |
psycopg2-binary==2.9.9
|
| 13 |
httpx==0.25.2
|
|
|
|
| 14 |
# 🚀 P2优化:速率限制
|
| 15 |
slowapi==0.1.9
|
| 16 |
# 🔒 P0安全增强:bcrypt密码哈希
|
router_admin_plugins.py
CHANGED
|
@@ -21,7 +21,7 @@ from typing import Optional, List
|
|
| 21 |
from fastapi import APIRouter, Depends, HTTPException
|
| 22 |
from fastapi.responses import JSONResponse
|
| 23 |
from sqlalchemy.orm import Session
|
| 24 |
-
from pydantic import BaseModel, Field, validator
|
| 25 |
|
| 26 |
from database_sql import get_db
|
| 27 |
from models_sql import RegisteredPlugin
|
|
@@ -120,13 +120,16 @@ class PluginCreateRequest(BaseModel):
|
|
| 120 |
max_deduct_per_day: Optional[int] = Field(1000, ge=1, le=100000)
|
| 121 |
permissions: Optional[List[str]] = Field(default=None)
|
| 122 |
|
| 123 |
-
@
|
|
|
|
| 124 |
def _validate_plugin_id(cls, v):
|
| 125 |
if not _PLUGIN_ID_PATTERN.match(v):
|
| 126 |
raise ValueError("plugin_id 仅允许 [a-z0-9_],长度 3-50")
|
| 127 |
return v
|
| 128 |
|
| 129 |
-
|
|
|
|
|
|
|
| 130 |
def _validate_permissions(cls, v):
|
| 131 |
if v is None:
|
| 132 |
return _DEFAULT_PERMISSIONS
|
|
@@ -144,7 +147,8 @@ class PluginUpdateRequest(BaseModel):
|
|
| 144 |
max_deduct_per_day: Optional[int] = Field(None, ge=1, le=100000)
|
| 145 |
permissions: Optional[List[str]] = None
|
| 146 |
|
| 147 |
-
@
|
|
|
|
| 148 |
def _validate_permissions(cls, v):
|
| 149 |
if v is None:
|
| 150 |
return v
|
|
|
|
| 21 |
from fastapi import APIRouter, Depends, HTTPException
|
| 22 |
from fastapi.responses import JSONResponse
|
| 23 |
from sqlalchemy.orm import Session
|
| 24 |
+
from pydantic import BaseModel, Field, field_validator # 🧹 P2修复:pydantic v2 已废弃 v1 风格 validator
|
| 25 |
|
| 26 |
from database_sql import get_db
|
| 27 |
from models_sql import RegisteredPlugin
|
|
|
|
| 120 |
max_deduct_per_day: Optional[int] = Field(1000, ge=1, le=100000)
|
| 121 |
permissions: Optional[List[str]] = Field(default=None)
|
| 122 |
|
| 123 |
+
@field_validator("plugin_id")
|
| 124 |
+
@classmethod
|
| 125 |
def _validate_plugin_id(cls, v):
|
| 126 |
if not _PLUGIN_ID_PATTERN.match(v):
|
| 127 |
raise ValueError("plugin_id 仅允许 [a-z0-9_],长度 3-50")
|
| 128 |
return v
|
| 129 |
|
| 130 |
+
# 🔧 pydantic v2:field_validator 默认对默认值也生效,无需 v1 的 always=True
|
| 131 |
+
@field_validator("permissions")
|
| 132 |
+
@classmethod
|
| 133 |
def _validate_permissions(cls, v):
|
| 134 |
if v is None:
|
| 135 |
return _DEFAULT_PERMISSIONS
|
|
|
|
| 147 |
max_deduct_per_day: Optional[int] = Field(None, ge=1, le=100000)
|
| 148 |
permissions: Optional[List[str]] = None
|
| 149 |
|
| 150 |
+
@field_validator("permissions")
|
| 151 |
+
@classmethod
|
| 152 |
def _validate_permissions(cls, v):
|
| 153 |
if v is None:
|
| 154 |
return v
|
router_items.py
CHANGED
|
@@ -95,29 +95,33 @@ async def get_items(type: str = "tool", sort: str = "time", limit: int = 50): #
|
|
| 95 |
filtered_items = sort_cache.get_sorted(cache_key, filtered_items, sort_fn)
|
| 96 |
|
| 97 |
price_updated = False
|
|
|
|
| 98 |
for item in filtered_items:
|
| 99 |
-
|
| 100 |
-
item["comments"] = len(item["commentsData"])
|
| 101 |
-
item["latest_version"] = _get_version_str(versions_db, item["id"])
|
| 102 |
-
|
| 103 |
-
# 💰 检查延迟价格是否已生效
|
| 104 |
old_price = item.get("price", 0)
|
| 105 |
_apply_effective_price(item)
|
| 106 |
if item.get("price", 0) != old_price:
|
| 107 |
price_updated = True
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
# 🔴 【绝对核心防线】:在下发给前端前,强行在内存中抹除敏感信息!
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
|
|
|
| 114 |
|
| 115 |
# 如果有价格生效了,持久化回 items.json
|
| 116 |
if price_updated:
|
| 117 |
db.save_data("items.json", items_db)
|
| 118 |
invalidate_cache("items.json")
|
| 119 |
|
| 120 |
-
return {"status": "success", "data":
|
| 121 |
|
| 122 |
def _build_creator_trend_data(account: str, u_items: list, months: list) -> dict:
|
| 123 |
"""
|
|
@@ -559,9 +563,17 @@ async def check_github_updates():
|
|
| 559 |
latest_sha = commits[0].get("sha", "")[:7] # 取前7位作为版本标识
|
| 560 |
|
| 561 |
# 如果版本有变化,更新 versions.json
|
| 562 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 563 |
if latest_sha and latest_sha != old_version:
|
| 564 |
-
|
|
|
|
| 565 |
updated_count += 1
|
| 566 |
print(f"[版本更新] {item['title']}: {old_version} -> {latest_sha}")
|
| 567 |
|
|
@@ -588,25 +600,28 @@ async def get_item_by_id(item_id: str):
|
|
| 588 |
|
| 589 |
for item in items_db:
|
| 590 |
if item["id"] == item_id:
|
| 591 |
-
#
|
| 592 |
-
item["commentsData"] = comments_db.get(item_id, [])
|
| 593 |
-
item["comments"] = len(item["commentsData"])
|
| 594 |
-
item["latest_version"] = _get_version_str(versions_db, item_id)
|
| 595 |
-
|
| 596 |
-
# 💰 检查延迟价格是否已生效
|
| 597 |
old_price = item.get("price", 0)
|
| 598 |
_apply_effective_price(item)
|
| 599 |
if item.get("price", 0) != old_price:
|
| 600 |
db.save_data("items.json", items_db)
|
| 601 |
invalidate_cache("items.json")
|
| 602 |
|
| 603 |
-
#
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 608 |
|
| 609 |
-
return {"status": "success", "data":
|
| 610 |
|
| 611 |
raise HTTPException(status_code=404, detail="资源不存在")
|
| 612 |
|
|
|
|
| 95 |
filtered_items = sort_cache.get_sorted(cache_key, filtered_items, sort_fn)
|
| 96 |
|
| 97 |
price_updated = False
|
| 98 |
+
result_items = []
|
| 99 |
for item in filtered_items:
|
| 100 |
+
# 💰 检查延迟价格是否已生效(在原始字典上执行,确保生效后可持久化)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
old_price = item.get("price", 0)
|
| 102 |
_apply_effective_price(item)
|
| 103 |
if item.get("price", 0) != old_price:
|
| 104 |
price_updated = True
|
| 105 |
+
|
| 106 |
+
# 🔒 P0数据修复:浅拷贝后再做下发加工,避免 pop/注入字段污染 items.json 源数据
|
| 107 |
+
item_data = {**item}
|
| 108 |
+
item_data["commentsData"] = comments_db.get(item_data["id"], [])
|
| 109 |
+
item_data["comments"] = len(item_data["commentsData"])
|
| 110 |
+
item_data["latest_version"] = _get_version_str(versions_db, item_data["id"])
|
| 111 |
+
|
| 112 |
# 🔴 【绝对核心防线】:在下发给前端前,强行在内存中抹除敏感信息!
|
| 113 |
+
item_data["has_private_token"] = bool(item_data.get("github_token"))
|
| 114 |
+
item_data.pop("github_token", None)
|
| 115 |
+
item_data.pop("netdisk_password", None) # ☁️ 网盘密码不在列表中显示
|
| 116 |
+
item_data.pop("viewed_by", None) # 👁️ 访问者列表不暴露给前端
|
| 117 |
+
result_items.append(item_data)
|
| 118 |
|
| 119 |
# 如果有价格生效了,持久化回 items.json
|
| 120 |
if price_updated:
|
| 121 |
db.save_data("items.json", items_db)
|
| 122 |
invalidate_cache("items.json")
|
| 123 |
|
| 124 |
+
return {"status": "success", "data": result_items[:limit]}
|
| 125 |
|
| 126 |
def _build_creator_trend_data(account: str, u_items: list, months: list) -> dict:
|
| 127 |
"""
|
|
|
|
| 563 |
latest_sha = commits[0].get("sha", "")[:7] # 取前7位作为版本标识
|
| 564 |
|
| 565 |
# 如果版本有变化,更新 versions.json
|
| 566 |
+
# 🧹 P2修复(格式统一):统一以新格式 dict 读写,保留 cached_at/zip_size 等缓存元字段;
|
| 567 |
+
# 旧逻辑拿字符串与 dict 比较会把 dict 条目永远判为"有更新"并覆盖成字符串丢失元信息
|
| 568 |
+
entry = versions_db.get(item_id)
|
| 569 |
+
if isinstance(entry, str):
|
| 570 |
+
entry = {"hash": entry}
|
| 571 |
+
elif not isinstance(entry, dict):
|
| 572 |
+
entry = {}
|
| 573 |
+
old_version = entry.get("hash", "")
|
| 574 |
if latest_sha and latest_sha != old_version:
|
| 575 |
+
entry["hash"] = latest_sha
|
| 576 |
+
versions_db[item_id] = entry
|
| 577 |
updated_count += 1
|
| 578 |
print(f"[版本更新] {item['title']}: {old_version} -> {latest_sha}")
|
| 579 |
|
|
|
|
| 600 |
|
| 601 |
for item in items_db:
|
| 602 |
if item["id"] == item_id:
|
| 603 |
+
# 💰 检查延迟价格是否已生效(在原字典上操作,确保生效结果可持久化)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 604 |
old_price = item.get("price", 0)
|
| 605 |
_apply_effective_price(item)
|
| 606 |
if item.get("price", 0) != old_price:
|
| 607 |
db.save_data("items.json", items_db)
|
| 608 |
invalidate_cache("items.json")
|
| 609 |
|
| 610 |
+
# 🔒 P2修复(与 get_items 同款):浅拷贝后再做下发加工,
|
| 611 |
+
# 避免 pop 敏感字段污染 items.json 源数据/共享缓存(导致 github_token 永久丢失)
|
| 612 |
+
item_data = {**item}
|
| 613 |
+
# 添加关联数据
|
| 614 |
+
item_data["commentsData"] = comments_db.get(item_id, [])
|
| 615 |
+
item_data["comments"] = len(item_data["commentsData"])
|
| 616 |
+
item_data["latest_version"] = _get_version_str(versions_db, item_id)
|
| 617 |
+
|
| 618 |
+
# 🔴 【安全防线】:抹除敏感信息(在拷贝上操作)
|
| 619 |
+
item_data["has_private_token"] = bool(item_data.get("github_token"))
|
| 620 |
+
item_data.pop("github_token", None)
|
| 621 |
+
item_data.pop("netdisk_password", None)
|
| 622 |
+
item_data.pop("viewed_by", None)
|
| 623 |
|
| 624 |
+
return {"status": "success", "data": item_data}
|
| 625 |
|
| 626 |
raise HTTPException(status_code=404, detail="资源不存在")
|
| 627 |
|
router_messages.py
CHANGED
|
@@ -179,8 +179,18 @@ async def get_chat_list(account: str, current_user: str = Depends(require_auth))
|
|
| 179 |
chat_list = []
|
| 180 |
for conv_id, msgs in chats_db.items():
|
| 181 |
if account in conv_id:
|
| 182 |
-
other_account = conv_id.replace(account, "").replace("_", "")
|
| 183 |
if not msgs: continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
last_msg = msgs[-1]
|
| 185 |
unread_count = sum(1 for m in msgs if m["receiver"] == account and not m.get("is_read"))
|
| 186 |
other_user = users_db.get(other_account, {})
|
|
@@ -233,6 +243,9 @@ async def get_chat_history(account: str, target_account: str, current_user: str
|
|
| 233 |
# ==========================================
|
| 234 |
@router.get("/api/messages/{account}")
|
| 235 |
async def get_messages(account: str, count_only: bool = False, current_user: str = Depends(require_auth)):
|
|
|
|
|
|
|
|
|
|
| 236 |
# 🔥 count_only 模式:轻量级轮询,只返回未读数,不标记已读
|
| 237 |
if count_only:
|
| 238 |
messages_db = db.load_data("messages.json", default_data={})
|
|
@@ -344,10 +357,13 @@ async def get_messages(account: str, count_only: bool = False, current_user: str
|
|
| 344 |
return result_container[0]
|
| 345 |
|
| 346 |
@router.post("/api/messages/{account}/read")
|
| 347 |
-
async def mark_messages_read(account: str):
|
| 348 |
"""
|
| 349 |
标记消息为已读(原子操作,并发安全)
|
| 350 |
"""
|
|
|
|
|
|
|
|
|
|
| 351 |
def updater(data):
|
| 352 |
user_msgs = data.get(account, [])
|
| 353 |
modified = False
|
|
|
|
| 179 |
chat_list = []
|
| 180 |
for conv_id, msgs in chats_db.items():
|
| 181 |
if account in conv_id:
|
|
|
|
| 182 |
if not msgs: continue
|
| 183 |
+
# 🔧 P1修复:对方账户从消息的 sender/receiver 字段推导,而非对 conv_id 做 replace
|
| 184 |
+
# (replace 会替换所有子串,若账户名互为子串或含下划线会解析出错误账户)
|
| 185 |
+
other_account = None
|
| 186 |
+
for m in reversed(msgs):
|
| 187 |
+
if m.get("sender") != account:
|
| 188 |
+
other_account = m.get("sender")
|
| 189 |
+
break
|
| 190 |
+
if m.get("receiver") != account:
|
| 191 |
+
other_account = m.get("receiver")
|
| 192 |
+
break
|
| 193 |
+
if not other_account: continue
|
| 194 |
last_msg = msgs[-1]
|
| 195 |
unread_count = sum(1 for m in msgs if m["receiver"] == account and not m.get("is_read"))
|
| 196 |
other_user = users_db.get(other_account, {})
|
|
|
|
| 243 |
# ==========================================
|
| 244 |
@router.get("/api/messages/{account}")
|
| 245 |
async def get_messages(account: str, count_only: bool = False, current_user: str = Depends(require_auth)):
|
| 246 |
+
# 🔒 P0安全加固:拦截越权读取他人消息(含 count_only 轮询)
|
| 247 |
+
if account != current_user:
|
| 248 |
+
raise HTTPException(status_code=403, detail="无权访问他人消息")
|
| 249 |
# 🔥 count_only 模式:轻量级轮询,只返回未读数,不标记已读
|
| 250 |
if count_only:
|
| 251 |
messages_db = db.load_data("messages.json", default_data={})
|
|
|
|
| 357 |
return result_container[0]
|
| 358 |
|
| 359 |
@router.post("/api/messages/{account}/read")
|
| 360 |
+
async def mark_messages_read(account: str, current_user: str = Depends(require_auth)):
|
| 361 |
"""
|
| 362 |
标记消息为已读(原子操作,并发安全)
|
| 363 |
"""
|
| 364 |
+
# 🔒 P0安全加固:补全鉴权,防止任意用户篡改他人消息已读状态
|
| 365 |
+
if account != current_user:
|
| 366 |
+
raise HTTPException(status_code=403, detail="无权操作他人消息")
|
| 367 |
def updater(data):
|
| 368 |
user_msgs = data.get(account, [])
|
| 369 |
modified = False
|
router_posts.py
CHANGED
|
@@ -429,53 +429,21 @@ async def tip_post(post_id: str, amount: int, is_anon: bool = False, current_use
|
|
| 429 |
if amount > MAX_TIP_AMOUNT:
|
| 430 |
raise HTTPException(status_code=400, detail=f"打赏金额必须在1-{MAX_TIP_AMOUNT}之间")
|
| 431 |
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
target_post = None
|
| 438 |
-
for post in data:
|
| 439 |
-
if post["id"] == post_id:
|
| 440 |
-
target_post = post
|
| 441 |
-
break
|
| 442 |
-
|
| 443 |
-
if not target_post:
|
| 444 |
-
result_container[0] = {"error": "not_found"}
|
| 445 |
-
return
|
| 446 |
-
|
| 447 |
-
# 不能打赏自己
|
| 448 |
-
if target_post.get("author") == current_user:
|
| 449 |
-
result_container[0] = {"error": "self_tip"}
|
| 450 |
-
return
|
| 451 |
-
|
| 452 |
-
author_account[0] = target_post.get("author")
|
| 453 |
-
|
| 454 |
-
# 更新打赏榜单
|
| 455 |
-
tip_board = target_post.get("tip_board", [])
|
| 456 |
-
existing = next((t for t in tip_board if t["account"] == current_user), None)
|
| 457 |
-
if existing:
|
| 458 |
-
existing["amount"] += amount
|
| 459 |
-
else:
|
| 460 |
-
tip_board.append({"account": current_user, "amount": amount, "is_anon": is_anon})
|
| 461 |
-
tip_board.sort(key=lambda x: x["amount"], reverse=True)
|
| 462 |
-
target_post["tip_board"] = tip_board
|
| 463 |
-
|
| 464 |
-
result_container[0] = {"status": "success", "message": f"成功打赏 {amount} 积分"}
|
| 465 |
-
|
| 466 |
-
db.atomic_update("posts.json", updater, default_data=[])
|
| 467 |
-
|
| 468 |
-
result = result_container[0]
|
| 469 |
-
if result is None or result.get("error") == "not_found":
|
| 470 |
raise HTTPException(status_code=404, detail="帖子不存在")
|
| 471 |
-
if
|
| 472 |
raise HTTPException(status_code=400, detail="不能打赏自己的帖子")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 473 |
|
| 474 |
-
# 💳 使用 SQL Wallet 系统处理余额转账
|
| 475 |
try:
|
| 476 |
-
author = author_account[0]
|
| 477 |
-
if not author:
|
| 478 |
-
raise HTTPException(status_code=404, detail="作者账户不存在")
|
| 479 |
|
| 480 |
# 🔒 P1幂等性防护:检查最近5秒内是否存在相同交易
|
| 481 |
recent_cutoff = datetime.datetime.utcnow() - datetime.timedelta(seconds=5)
|
|
@@ -515,20 +483,13 @@ async def tip_post(post_id: str, amount: int, is_anon: bool = False, current_use
|
|
| 515 |
prev_hash_tipper = last_tx_tipper.tx_hash if last_tx_tipper else "GENESIS_HASH"
|
| 516 |
prev_hash_author = last_tx_author.tx_hash if last_tx_author else "GENESIS_HASH"
|
| 517 |
|
| 518 |
-
# 获取用户信息
|
| 519 |
users_db = db.load_data("users.json", default_data={})
|
| 520 |
-
posts_db = db.load_data("posts.json", default_data=[])
|
| 521 |
author_info = users_db.get(author, {})
|
| 522 |
tipper_info = users_db.get(current_user, {})
|
| 523 |
author_name = author_info.get("name", author)
|
| 524 |
tipper_name = tipper_info.get("name", current_user)
|
| 525 |
|
| 526 |
-
post_title = None
|
| 527 |
-
for post in posts_db:
|
| 528 |
-
if post["id"] == post_id:
|
| 529 |
-
post_title = post.get("title")
|
| 530 |
-
break
|
| 531 |
-
|
| 532 |
# 打赏方交易记录 (TIP_OUT)
|
| 533 |
tx_tipper = Transaction(
|
| 534 |
tx_id=tx_id_tipper,
|
|
@@ -597,7 +558,26 @@ async def tip_post(post_id: str, amount: int, is_anon: bool = False, current_use
|
|
| 597 |
logger.error(f"POST_TIP_ERROR | from={current_user} | post={post_id} | amount={amount} | error={str(e)}")
|
| 598 |
raise HTTPException(status_code=500, detail="打赏处理失败,请稍后重试")
|
| 599 |
|
| 600 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 601 |
|
| 602 |
# ==========================================
|
| 603 |
# 💬 评论接口(复用通用评论系统)
|
|
|
|
| 429 |
if amount > MAX_TIP_AMOUNT:
|
| 430 |
raise HTTPException(status_code=400, detail=f"打赏金额必须在1-{MAX_TIP_AMOUNT}之间")
|
| 431 |
|
| 432 |
+
# 🔒 P0数据修复(时序):先只读校验 → SQL转账扣款 → 提交成功后再写打赏榜
|
| 433 |
+
# 避免余额不足/重复请求时榜单已被更新的数据不一致
|
| 434 |
+
posts_db_ro = db.load_data("posts.json", default_data=[])
|
| 435 |
+
target_post = next((p for p in posts_db_ro if p["id"] == post_id), None)
|
| 436 |
+
if not target_post:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 437 |
raise HTTPException(status_code=404, detail="帖子不存在")
|
| 438 |
+
if target_post.get("author") == current_user:
|
| 439 |
raise HTTPException(status_code=400, detail="不能打赏自己的帖子")
|
| 440 |
+
author = target_post.get("author")
|
| 441 |
+
if not author:
|
| 442 |
+
raise HTTPException(status_code=404, detail="作者账户不存在")
|
| 443 |
+
post_title = target_post.get("title")
|
| 444 |
|
| 445 |
+
# 💳 使用 SQL Wallet 系统处理余额转账(先扣款)
|
| 446 |
try:
|
|
|
|
|
|
|
|
|
|
| 447 |
|
| 448 |
# 🔒 P1幂等性防护:检查最近5秒内是否存在相同交易
|
| 449 |
recent_cutoff = datetime.datetime.utcnow() - datetime.timedelta(seconds=5)
|
|
|
|
| 483 |
prev_hash_tipper = last_tx_tipper.tx_hash if last_tx_tipper else "GENESIS_HASH"
|
| 484 |
prev_hash_author = last_tx_author.tx_hash if last_tx_author else "GENESIS_HASH"
|
| 485 |
|
| 486 |
+
# 获取用户信息(post_title 已在预校验阶段取得)
|
| 487 |
users_db = db.load_data("users.json", default_data={})
|
|
|
|
| 488 |
author_info = users_db.get(author, {})
|
| 489 |
tipper_info = users_db.get(current_user, {})
|
| 490 |
author_name = author_info.get("name", author)
|
| 491 |
tipper_name = tipper_info.get("name", current_user)
|
| 492 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 493 |
# 打赏方交易记录 (TIP_OUT)
|
| 494 |
tx_tipper = Transaction(
|
| 495 |
tx_id=tx_id_tipper,
|
|
|
|
| 558 |
logger.error(f"POST_TIP_ERROR | from={current_user} | post={post_id} | amount={amount} | error={str(e)}")
|
| 559 |
raise HTTPException(status_code=500, detail="打赏处理失败,请稍后重试")
|
| 560 |
|
| 561 |
+
# 🏆 扣款成功后再写打赏榜(写榜失败仅记录日志,不影响资金安全)
|
| 562 |
+
def board_updater(data):
|
| 563 |
+
for post in data:
|
| 564 |
+
if post["id"] == post_id:
|
| 565 |
+
tip_board = post.get("tip_board", [])
|
| 566 |
+
existing = next((t for t in tip_board if t["account"] == current_user), None)
|
| 567 |
+
if existing:
|
| 568 |
+
existing["amount"] += amount
|
| 569 |
+
else:
|
| 570 |
+
tip_board.append({"account": current_user, "amount": amount, "is_anon": is_anon})
|
| 571 |
+
tip_board.sort(key=lambda x: x["amount"], reverse=True)
|
| 572 |
+
post["tip_board"] = tip_board
|
| 573 |
+
break
|
| 574 |
+
|
| 575 |
+
try:
|
| 576 |
+
db.atomic_update("posts.json", board_updater, default_data=[])
|
| 577 |
+
except Exception as e:
|
| 578 |
+
logger.error(f"POST_TIP_BOARD_ERROR | post={post_id} | error={str(e)}")
|
| 579 |
+
|
| 580 |
+
return {"status": "success", "message": f"成功打赏 {amount} 积分"}
|
| 581 |
|
| 582 |
# ==========================================
|
| 583 |
# 💬 评论接口(复用通用评论系统)
|
router_prompts.py
CHANGED
|
@@ -726,53 +726,21 @@ async def tip_prompt(prompt_id: str, amount: int, is_anon: bool = False,
|
|
| 726 |
if amount > MAX_TIP_AMOUNT:
|
| 727 |
raise HTTPException(status_code=400, detail=f"打赏金额必须在1-{MAX_TIP_AMOUNT}之间")
|
| 728 |
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
target_prompt = None
|
| 735 |
-
for prompt in data:
|
| 736 |
-
if prompt["id"] == prompt_id:
|
| 737 |
-
target_prompt = prompt
|
| 738 |
-
break
|
| 739 |
-
|
| 740 |
-
if not target_prompt:
|
| 741 |
-
result_container[0] = {"error": "not_found"}
|
| 742 |
-
return
|
| 743 |
-
|
| 744 |
-
# 不能打赏自己
|
| 745 |
-
if target_prompt.get("author") == current_user:
|
| 746 |
-
result_container[0] = {"error": "self_tip"}
|
| 747 |
-
return
|
| 748 |
-
|
| 749 |
-
author_account[0] = target_prompt.get("author")
|
| 750 |
-
|
| 751 |
-
# 更新打赏榜单
|
| 752 |
-
tip_board = target_prompt.get("tip_board", [])
|
| 753 |
-
existing = next((t for t in tip_board if t["account"] == current_user), None)
|
| 754 |
-
if existing:
|
| 755 |
-
existing["amount"] += amount
|
| 756 |
-
else:
|
| 757 |
-
tip_board.append({"account": current_user, "amount": amount, "is_anon": is_anon})
|
| 758 |
-
tip_board.sort(key=lambda x: x["amount"], reverse=True)
|
| 759 |
-
target_prompt["tip_board"] = tip_board
|
| 760 |
-
|
| 761 |
-
result_container[0] = {"status": "success", "message": f"成功打赏 {amount} 积分"}
|
| 762 |
-
|
| 763 |
-
db.atomic_update("prompts.json", updater, default_data=[])
|
| 764 |
-
|
| 765 |
-
result = result_container[0]
|
| 766 |
-
if result is None or result.get("error") == "not_found":
|
| 767 |
raise HTTPException(status_code=404, detail="提示词不存在")
|
| 768 |
-
if
|
| 769 |
raise HTTPException(status_code=400, detail="不能打赏自己的提示词")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 770 |
|
| 771 |
-
# 💳 使用 SQL Wallet 系统处理余额转账
|
| 772 |
try:
|
| 773 |
-
author = author_account[0]
|
| 774 |
-
if not author:
|
| 775 |
-
raise HTTPException(status_code=404, detail="作者账户不存在")
|
| 776 |
|
| 777 |
# 🔒 P1幂等性防护:检查最近5秒内是否存在相同交易
|
| 778 |
recent_cutoff = datetime.datetime.utcnow() - datetime.timedelta(seconds=5)
|
|
@@ -812,20 +780,13 @@ async def tip_prompt(prompt_id: str, amount: int, is_anon: bool = False,
|
|
| 812 |
prev_hash_tipper = last_tx_tipper.tx_hash if last_tx_tipper else "GENESIS_HASH"
|
| 813 |
prev_hash_author = last_tx_author.tx_hash if last_tx_author else "GENESIS_HASH"
|
| 814 |
|
| 815 |
-
# 获取用户信息
|
| 816 |
users_db = db.load_data("users.json", default_data={})
|
| 817 |
author_info = users_db.get(author, {})
|
| 818 |
tipper_info = users_db.get(current_user, {})
|
| 819 |
author_name = author_info.get("name", author)
|
| 820 |
tipper_name = tipper_info.get("name", current_user)
|
| 821 |
|
| 822 |
-
prompt_title = None
|
| 823 |
-
prompts_db = db.load_data("prompts.json", default_data=[])
|
| 824 |
-
for prompt in prompts_db:
|
| 825 |
-
if prompt["id"] == prompt_id:
|
| 826 |
-
prompt_title = prompt.get("title")
|
| 827 |
-
break
|
| 828 |
-
|
| 829 |
# 打赏方交易记录 (TIP_OUT)
|
| 830 |
tx_tipper = Transaction(
|
| 831 |
tx_id=tx_id_tipper,
|
|
@@ -894,7 +855,26 @@ async def tip_prompt(prompt_id: str, amount: int, is_anon: bool = False,
|
|
| 894 |
logger.error(f"PROMPT_TIP_ERROR | from={current_user} | prompt={prompt_id} | amount={amount} | error={str(e)}")
|
| 895 |
raise HTTPException(status_code=500, detail="打赏处理失败,请稍后重试")
|
| 896 |
|
| 897 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 898 |
|
| 899 |
|
| 900 |
# ==========================================
|
|
|
|
| 726 |
if amount > MAX_TIP_AMOUNT:
|
| 727 |
raise HTTPException(status_code=400, detail=f"打赏金额必须在1-{MAX_TIP_AMOUNT}之间")
|
| 728 |
|
| 729 |
+
# 🔒 P0数据修复(时序):先只读校验 → SQL转账扣款 → 提交成功后再写打赏榜
|
| 730 |
+
# 避免余额不足/重复请求时榜单已被更新的数据不一致
|
| 731 |
+
prompts_db_ro = db.load_data("prompts.json", default_data=[])
|
| 732 |
+
target_prompt = next((p for p in prompts_db_ro if p["id"] == prompt_id), None)
|
| 733 |
+
if not target_prompt:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 734 |
raise HTTPException(status_code=404, detail="提示词不存在")
|
| 735 |
+
if target_prompt.get("author") == current_user:
|
| 736 |
raise HTTPException(status_code=400, detail="不能打赏自己的提示词")
|
| 737 |
+
author = target_prompt.get("author")
|
| 738 |
+
if not author:
|
| 739 |
+
raise HTTPException(status_code=404, detail="作者账户不存在")
|
| 740 |
+
prompt_title = target_prompt.get("title")
|
| 741 |
|
| 742 |
+
# 💳 使用 SQL Wallet 系统处理余额转账(先扣款)
|
| 743 |
try:
|
|
|
|
|
|
|
|
|
|
| 744 |
|
| 745 |
# 🔒 P1幂等性防护:检查最近5秒内是否存在相同交易
|
| 746 |
recent_cutoff = datetime.datetime.utcnow() - datetime.timedelta(seconds=5)
|
|
|
|
| 780 |
prev_hash_tipper = last_tx_tipper.tx_hash if last_tx_tipper else "GENESIS_HASH"
|
| 781 |
prev_hash_author = last_tx_author.tx_hash if last_tx_author else "GENESIS_HASH"
|
| 782 |
|
| 783 |
+
# 获取用户信息(prompt_title 已在预校验阶段取得)
|
| 784 |
users_db = db.load_data("users.json", default_data={})
|
| 785 |
author_info = users_db.get(author, {})
|
| 786 |
tipper_info = users_db.get(current_user, {})
|
| 787 |
author_name = author_info.get("name", author)
|
| 788 |
tipper_name = tipper_info.get("name", current_user)
|
| 789 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 790 |
# 打赏方交易记录 (TIP_OUT)
|
| 791 |
tx_tipper = Transaction(
|
| 792 |
tx_id=tx_id_tipper,
|
|
|
|
| 855 |
logger.error(f"PROMPT_TIP_ERROR | from={current_user} | prompt={prompt_id} | amount={amount} | error={str(e)}")
|
| 856 |
raise HTTPException(status_code=500, detail="打赏处理失败,请稍后重试")
|
| 857 |
|
| 858 |
+
# 🏆 扣款成功后再写打赏榜(写榜失败仅记录日志,不影响资金安全)
|
| 859 |
+
def board_updater(data):
|
| 860 |
+
for prompt in data:
|
| 861 |
+
if prompt["id"] == prompt_id:
|
| 862 |
+
tip_board = prompt.get("tip_board", [])
|
| 863 |
+
existing = next((t for t in tip_board if t["account"] == current_user), None)
|
| 864 |
+
if existing:
|
| 865 |
+
existing["amount"] += amount
|
| 866 |
+
else:
|
| 867 |
+
tip_board.append({"account": current_user, "amount": amount, "is_anon": is_anon})
|
| 868 |
+
tip_board.sort(key=lambda x: x["amount"], reverse=True)
|
| 869 |
+
prompt["tip_board"] = tip_board
|
| 870 |
+
break
|
| 871 |
+
|
| 872 |
+
try:
|
| 873 |
+
db.atomic_update("prompts.json", board_updater, default_data=[])
|
| 874 |
+
except Exception as e:
|
| 875 |
+
logger.error(f"PROMPT_TIP_BOARD_ERROR | prompt={prompt_id} | error={str(e)}")
|
| 876 |
+
|
| 877 |
+
return {"status": "success", "message": f"成功打赏 {amount} 积分"}
|
| 878 |
|
| 879 |
|
| 880 |
# ==========================================
|
router_proxy.py
CHANGED
|
@@ -14,6 +14,7 @@ import urllib.error
|
|
| 14 |
import 数据库连接 as json_db
|
| 15 |
from database_sql import get_db
|
| 16 |
from models_sql import Ownership
|
|
|
|
| 17 |
|
| 18 |
router = APIRouter()
|
| 19 |
|
|
@@ -22,7 +23,8 @@ class ProxyGithubZipRequest(BaseModel):
|
|
| 22 |
"""GitHub ZIP 代理下载请求模型"""
|
| 23 |
url: str = Field(..., min_length=1, description="资源URL")
|
| 24 |
item_id: str = Field(..., min_length=1, max_length=100, description="资源ID")
|
| 25 |
-
|
|
|
|
| 26 |
|
| 27 |
@field_validator('item_id')
|
| 28 |
@classmethod
|
|
@@ -31,31 +33,21 @@ class ProxyGithubZipRequest(BaseModel):
|
|
| 31 |
if not re.match(r'^[a-zA-Z0-9_-]+$', v):
|
| 32 |
raise ValueError('item_id只能包含字母、数字、下划线和连字符')
|
| 33 |
return v
|
| 34 |
-
|
| 35 |
-
@field_validator('account')
|
| 36 |
-
@classmethod
|
| 37 |
-
def validate_account(cls, v: str) -> str:
|
| 38 |
-
"""验证account非空且长度符合要求"""
|
| 39 |
-
if len(v) < 1:
|
| 40 |
-
raise ValueError('account不能为空')
|
| 41 |
-
if len(v) > 50:
|
| 42 |
-
raise ValueError('account长度不能超过50个字符')
|
| 43 |
-
return v
|
| 44 |
|
| 45 |
@router.post("/api/proxy_github_zip")
|
| 46 |
-
async def proxy_github_zip(req_data: ProxyGithubZipRequest, db: Session = Depends(get_db)):
|
| 47 |
"""云端代理:校验所有权后,拉取 GitHub 私有库 ZIP 流透传给本地"""
|
| 48 |
items_db = json_db.load_data("items.json", default_data=[])
|
| 49 |
item = next((i for i in items_db if i["id"] == req_data.item_id), None)
|
| 50 |
if not item: return JSONResponse(content={"error": "资源不存在"}, status_code=404)
|
| 51 |
|
| 52 |
-
# 1. 核心鉴权:验证用户是否购买过该工具
|
| 53 |
try:
|
| 54 |
price = int(float(item.get("price", 0) or 0))
|
| 55 |
except (ValueError, TypeError):
|
| 56 |
price = 0
|
| 57 |
-
if price > 0 and
|
| 58 |
-
owned = db.query(Ownership).filter(Ownership.account ==
|
| 59 |
if not owned:
|
| 60 |
return JSONResponse(content={"error": "🚨 拒绝访问:未找到购买记录!"}, status_code=403)
|
| 61 |
|
|
@@ -198,7 +190,8 @@ class ProxyDownloadRequest(BaseModel):
|
|
| 198 |
"""工作流/应用 JSON 代理下载请求模型"""
|
| 199 |
url: str = Field(..., min_length=1, description="下载URL")
|
| 200 |
item_id: str = Field(..., min_length=1, max_length=100, description="资源ID")
|
| 201 |
-
|
|
|
|
| 202 |
|
| 203 |
@field_validator('item_id')
|
| 204 |
@classmethod
|
|
@@ -208,16 +201,6 @@ class ProxyDownloadRequest(BaseModel):
|
|
| 208 |
raise ValueError('item_id只能包含字母、数字、下划线和连字符')
|
| 209 |
return v
|
| 210 |
|
| 211 |
-
@field_validator('account')
|
| 212 |
-
@classmethod
|
| 213 |
-
def validate_account(cls, v: str) -> str:
|
| 214 |
-
"""验证account非空且长度符合要求"""
|
| 215 |
-
if len(v) < 1:
|
| 216 |
-
raise ValueError('account不能为空')
|
| 217 |
-
if len(v) > 50:
|
| 218 |
-
raise ValueError('account长度不能超过50个字符')
|
| 219 |
-
return v
|
| 220 |
-
|
| 221 |
@field_validator('url')
|
| 222 |
@classmethod
|
| 223 |
def validate_url(cls, v: str) -> str:
|
|
@@ -242,7 +225,7 @@ class ProxyDownloadRequest(BaseModel):
|
|
| 242 |
return v
|
| 243 |
|
| 244 |
@router.post("/api/proxy_download")
|
| 245 |
-
async def proxy_download(req_data: ProxyDownloadRequest, db: Session = Depends(get_db)):
|
| 246 |
"""云端代理:校验所有权后,拉取真实的工作流 JSON 文件并透传给本地"""
|
| 247 |
items_db = json_db.load_data("items.json", default_data=[])
|
| 248 |
item = next((i for i in items_db if i["id"] == req_data.item_id), None)
|
|
@@ -250,10 +233,13 @@ async def proxy_download(req_data: ProxyDownloadRequest, db: Session = Depends(g
|
|
| 250 |
if not item:
|
| 251 |
return JSONResponse(content={"error": "云端记录中找不到该资源"}, status_code=404)
|
| 252 |
|
| 253 |
-
# 1. 核心鉴权:验证用户是否购买/获取过该工作流
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
|
|
|
|
|
|
|
|
|
| 257 |
if not owned:
|
| 258 |
return JSONResponse(content={"error": "您尚未获取该工作流,请先在社区页面点击获取"}, status_code=403)
|
| 259 |
|
|
|
|
| 14 |
import 数据库连接 as json_db
|
| 15 |
from database_sql import get_db
|
| 16 |
from models_sql import Ownership
|
| 17 |
+
from 安全认证 import require_auth
|
| 18 |
|
| 19 |
router = APIRouter()
|
| 20 |
|
|
|
|
| 23 |
"""GitHub ZIP 代理下载请求模型"""
|
| 24 |
url: str = Field(..., min_length=1, description="资源URL")
|
| 25 |
item_id: str = Field(..., min_length=1, max_length=100, description="资源ID")
|
| 26 |
+
# 🔒 P0安全加固:身份以 Authorization Token 为准,account 仅作向后兼容字段,不再作为鉴权依据
|
| 27 |
+
account: Optional[str] = Field(None, max_length=50, description="用户账号(已废弃,以Token为准)")
|
| 28 |
|
| 29 |
@field_validator('item_id')
|
| 30 |
@classmethod
|
|
|
|
| 33 |
if not re.match(r'^[a-zA-Z0-9_-]+$', v):
|
| 34 |
raise ValueError('item_id只能包含字母、数字、下划线和连字符')
|
| 35 |
return v
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
@router.post("/api/proxy_github_zip")
|
| 38 |
+
async def proxy_github_zip(req_data: ProxyGithubZipRequest, account: str = Depends(require_auth), db: Session = Depends(get_db)):
|
| 39 |
"""云端代理:校验所有权后,拉取 GitHub 私有库 ZIP 流透传给本地"""
|
| 40 |
items_db = json_db.load_data("items.json", default_data=[])
|
| 41 |
item = next((i for i in items_db if i["id"] == req_data.item_id), None)
|
| 42 |
if not item: return JSONResponse(content={"error": "资源不存在"}, status_code=404)
|
| 43 |
|
| 44 |
+
# 1. 核心鉴权:验证用户是否购买过该工具(account 来自 Token 验证,不可伪造)
|
| 45 |
try:
|
| 46 |
price = int(float(item.get("price", 0) or 0))
|
| 47 |
except (ValueError, TypeError):
|
| 48 |
price = 0
|
| 49 |
+
if price > 0 and account != item.get("author"):
|
| 50 |
+
owned = db.query(Ownership).filter(Ownership.account == account, Ownership.item_id == req_data.item_id).first()
|
| 51 |
if not owned:
|
| 52 |
return JSONResponse(content={"error": "🚨 拒绝访问:未找到购买记录!"}, status_code=403)
|
| 53 |
|
|
|
|
| 190 |
"""工作流/应用 JSON 代理下载请求模型"""
|
| 191 |
url: str = Field(..., min_length=1, description="下载URL")
|
| 192 |
item_id: str = Field(..., min_length=1, max_length=100, description="资源ID")
|
| 193 |
+
# 🔒 P0安全加固:身份以 Authorization Token 为准,account 仅作向后兼容字段
|
| 194 |
+
account: Optional[str] = Field(None, max_length=50, description="用户账号(已废弃,以Token为准)")
|
| 195 |
|
| 196 |
@field_validator('item_id')
|
| 197 |
@classmethod
|
|
|
|
| 201 |
raise ValueError('item_id只能包含字母、数字、下划线和连字符')
|
| 202 |
return v
|
| 203 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
@field_validator('url')
|
| 205 |
@classmethod
|
| 206 |
def validate_url(cls, v: str) -> str:
|
|
|
|
| 225 |
return v
|
| 226 |
|
| 227 |
@router.post("/api/proxy_download")
|
| 228 |
+
async def proxy_download(req_data: ProxyDownloadRequest, account: str = Depends(require_auth), db: Session = Depends(get_db)):
|
| 229 |
"""云端代理:校验所有权后,拉取真实的工作流 JSON 文件并透传给本地"""
|
| 230 |
items_db = json_db.load_data("items.json", default_data=[])
|
| 231 |
item = next((i for i in items_db if i["id"] == req_data.item_id), None)
|
|
|
|
| 233 |
if not item:
|
| 234 |
return JSONResponse(content={"error": "云端记录中找不到该资源"}, status_code=404)
|
| 235 |
|
| 236 |
+
# 1. 核心鉴权:验证用户是否购买/获取过该工作流(account 来自 Token 验证,不可伪造)
|
| 237 |
+
try:
|
| 238 |
+
price = int(float(item.get("price", 0) or 0))
|
| 239 |
+
except (ValueError, TypeError):
|
| 240 |
+
price = 0
|
| 241 |
+
if price > 0 and account != item.get("author"):
|
| 242 |
+
owned = db.query(Ownership).filter(Ownership.account == account, Ownership.item_id == req_data.item_id).first()
|
| 243 |
if not owned:
|
| 244 |
return JSONResponse(content={"error": "您尚未获取该工作流,请先在社区页面点击获取"}, status_code=403)
|
| 245 |
|
router_tasks.py
CHANGED
|
@@ -13,7 +13,7 @@
|
|
| 13 |
|
| 14 |
from fastapi import APIRouter, HTTPException, Depends, Query
|
| 15 |
from sqlalchemy.orm import Session
|
| 16 |
-
from models import TaskCreate, TaskUpdate,
|
| 17 |
import 数据库连接 as db
|
| 18 |
from 安全认证 import require_auth, is_admin
|
| 19 |
from notifications import add_notification
|
|
@@ -103,6 +103,11 @@ TASK_STATUS = {
|
|
| 103 |
# 🔄 过期任务自动处理 + 自动退款
|
| 104 |
# ==========================================
|
| 105 |
import datetime
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
def check_and_update_expired_tasks(tasks_db, db_session=None):
|
| 108 |
"""
|
|
@@ -159,60 +164,80 @@ def check_and_update_expired_tasks(tasks_db, db_session=None):
|
|
| 159 |
# ========== 第二阶段:执行SQL事务(钱包退款+交易记录) ==========
|
| 160 |
refund_results = [] # 记录退款成功的任务
|
| 161 |
sql_committed = False
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
# 💳 P0修复:先 commit SQL 事务
|
| 181 |
-
db_session.commit()
|
| 182 |
-
sql_committed = True
|
| 183 |
-
logger.info(f"TASK_EXPIRED_SQL_COMMIT | refunded_tasks={len(refund_results)}")
|
| 184 |
-
|
| 185 |
-
except Exception as e:
|
| 186 |
-
# 💳 P0修复:SQL事务失败,回滚所有操作,不修改JSON
|
| 187 |
-
db_session.rollback()
|
| 188 |
-
logger.error(f"TASK_EXPIRED_SQL_ROLLBACK | error={str(e)}")
|
| 189 |
-
# 不修改 JSON,下次再试
|
| 190 |
-
return False
|
| 191 |
-
|
| 192 |
-
# ========== 第三阶段:SQL成功后修改JSON任务状态 ==========
|
| 193 |
-
if sql_committed:
|
| 194 |
try:
|
| 195 |
for item in expired_task_indices:
|
| 196 |
-
task = tasks_db[item["index"]]
|
| 197 |
-
task["status"] = "expired"
|
| 198 |
-
task["expired_at"] = int(time.time())
|
| 199 |
if item["amount"] > 0:
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
except Exception as e:
|
| 208 |
-
# 💳 P0修复:
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
|
| 217 |
# 发送退款通知(在事务外执行,失败不影响主流程)
|
| 218 |
for item in refund_results:
|
|
@@ -748,10 +773,13 @@ async def assign_task(task_id: str, assignee: str, current_user: str = Depends(r
|
|
| 748 |
# ==========================================
|
| 749 |
|
| 750 |
@router.post("/api/tasks/{task_id}/submit")
|
| 751 |
-
async def submit_task(task_id: str,
|
| 752 |
"""
|
| 753 |
接单者提交成果
|
| 754 |
"""
|
|
|
|
|
|
|
|
|
|
| 755 |
tasks_db = db.load_data("tasks.json", default_data=[])
|
| 756 |
|
| 757 |
for task in tasks_db:
|
|
@@ -926,11 +954,14 @@ async def accept_task(task_id: str, is_accepted: bool, feedback: str = None, cur
|
|
| 926 |
|
| 927 |
|
| 928 |
@router.post("/api/tasks/{task_id}/dispute")
|
| 929 |
-
async def create_dispute(task_id: str,
|
| 930 |
"""
|
| 931 |
发起申诉(发布者或接单者均可)
|
| 932 |
- evidence: 证据图片URL列表(可选)
|
| 933 |
"""
|
|
|
|
|
|
|
|
|
|
| 934 |
tasks_db = db.load_data("tasks.json", default_data=[])
|
| 935 |
disputes_db = db.load_data("disputes.json", default_data=[])
|
| 936 |
|
|
@@ -1030,10 +1061,13 @@ async def get_dispute_detail(dispute_id: str):
|
|
| 1030 |
raise HTTPException(status_code=404, detail="申诉不存在")
|
| 1031 |
|
| 1032 |
@router.post("/api/disputes/{dispute_id}/respond")
|
| 1033 |
-
async def respond_dispute(dispute_id: str,
|
| 1034 |
"""
|
| 1035 |
被申诉方回应申诉
|
| 1036 |
"""
|
|
|
|
|
|
|
|
|
|
| 1037 |
disputes_db = db.load_data("disputes.json", default_data=[])
|
| 1038 |
|
| 1039 |
for dispute in disputes_db:
|
|
@@ -1479,54 +1513,25 @@ async def tip_task(task_id: str, amount: int, is_anon: bool = False, current_use
|
|
| 1479 |
"""
|
| 1480 |
if amount <= 0:
|
| 1481 |
raise HTTPException(status_code=400, detail="打赏金额必须大于0")
|
| 1482 |
-
|
| 1483 |
-
|
| 1484 |
-
|
| 1485 |
-
|
| 1486 |
-
|
| 1487 |
-
|
| 1488 |
-
|
| 1489 |
-
|
| 1490 |
-
|
| 1491 |
-
target_task = task
|
| 1492 |
-
break
|
| 1493 |
-
|
| 1494 |
-
if not target_task:
|
| 1495 |
-
result_container[0] = {"error": "not_found"}
|
| 1496 |
-
return
|
| 1497 |
-
|
| 1498 |
-
# 不能打赏自己
|
| 1499 |
-
if target_task.get("publisher") == current_user:
|
| 1500 |
-
result_container[0] = {"error": "self_tip"}
|
| 1501 |
-
return
|
| 1502 |
-
|
| 1503 |
-
publisher_account[0] = target_task.get("publisher")
|
| 1504 |
-
|
| 1505 |
-
# 更新打赏榜单
|
| 1506 |
-
tip_board = target_task.get("tip_board", [])
|
| 1507 |
-
existing = next((t for t in tip_board if t["account"] == current_user), None)
|
| 1508 |
-
if existing:
|
| 1509 |
-
existing["amount"] += amount
|
| 1510 |
-
else:
|
| 1511 |
-
tip_board.append({"account": current_user, "amount": amount, "is_anon": is_anon})
|
| 1512 |
-
tip_board.sort(key=lambda x: x["amount"], reverse=True)
|
| 1513 |
-
target_task["tip_board"] = tip_board
|
| 1514 |
-
|
| 1515 |
-
result_container[0] = {"status": "success", "message": f"成功打赏 {amount} 积分"}
|
| 1516 |
-
|
| 1517 |
-
db.atomic_update("tasks.json", updater, default_data=[])
|
| 1518 |
-
|
| 1519 |
-
result = result_container[0]
|
| 1520 |
-
if result is None or result.get("error") == "not_found":
|
| 1521 |
raise HTTPException(status_code=404, detail="任务不存在")
|
| 1522 |
-
if
|
| 1523 |
raise HTTPException(status_code=400, detail="不能打赏自己的任务")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1524 |
|
| 1525 |
-
# 💳 使用 SQL Wallet 系统处理余额转账
|
| 1526 |
try:
|
| 1527 |
-
publisher = publisher_account[0]
|
| 1528 |
-
if not publisher:
|
| 1529 |
-
raise HTTPException(status_code=404, detail="作者账户不存在")
|
| 1530 |
|
| 1531 |
# 🔒 P1幂等性防护:检查最近5秒内是否存在相同交易
|
| 1532 |
recent_cutoff = datetime.datetime.utcnow() - datetime.timedelta(seconds=5)
|
|
@@ -1566,20 +1571,13 @@ async def tip_task(task_id: str, amount: int, is_anon: bool = False, current_use
|
|
| 1566 |
prev_hash_tipper = last_tx_tipper.tx_hash if last_tx_tipper else "GENESIS_HASH"
|
| 1567 |
prev_hash_author = last_tx_author.tx_hash if last_tx_author else "GENESIS_HASH"
|
| 1568 |
|
| 1569 |
-
# 获取用户信息
|
| 1570 |
users_db = db.load_data("users.json", default_data={})
|
| 1571 |
-
tasks_db = db.load_data("tasks.json", default_data=[])
|
| 1572 |
author_info = users_db.get(publisher, {})
|
| 1573 |
tipper_info = users_db.get(current_user, {})
|
| 1574 |
author_name = author_info.get("name", publisher)
|
| 1575 |
tipper_name = tipper_info.get("name", current_user)
|
| 1576 |
|
| 1577 |
-
task_title = None
|
| 1578 |
-
for task in tasks_db:
|
| 1579 |
-
if task["id"] == task_id:
|
| 1580 |
-
task_title = task.get("title")
|
| 1581 |
-
break
|
| 1582 |
-
|
| 1583 |
# 打赏方交易记录 (TIP_OUT)
|
| 1584 |
tx_tipper = Transaction(
|
| 1585 |
tx_id=tx_id_tipper,
|
|
@@ -1648,7 +1646,26 @@ async def tip_task(task_id: str, amount: int, is_anon: bool = False, current_use
|
|
| 1648 |
logger.error(f"TASK_TIP_ERROR | task={task_id} | amount={amount} | error={str(e)}")
|
| 1649 |
raise HTTPException(status_code=500, detail="打赏处理失败,请稍后重试")
|
| 1650 |
|
| 1651 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1652 |
|
| 1653 |
# ==========================================
|
| 1654 |
# 💬 评论接口(复用通用评论系统)
|
|
|
|
| 13 |
|
| 14 |
from fastapi import APIRouter, HTTPException, Depends, Query
|
| 15 |
from sqlalchemy.orm import Session
|
| 16 |
+
from models import TaskCreate, TaskUpdate, TaskSubmit, TaskDispute, TaskDisputeResponse # 🔧 P2修复:提交/申诉/回应端点改为 body 模型,匹配前端 JSON 提交
|
| 17 |
import 数据库连接 as db
|
| 18 |
from 安全认证 import require_auth, is_admin
|
| 19 |
from notifications import add_notification
|
|
|
|
| 103 |
# 🔄 过期任务自动处理 + 自动退款
|
| 104 |
# ==========================================
|
| 105 |
import datetime
|
| 106 |
+
import threading
|
| 107 |
+
|
| 108 |
+
# 🔒 P1修复(CAS):过期退款串行锁——防止并发请求各持旧 tasks.json 快照,
|
| 109 |
+
# 把同一任务重复判定为过期导致双重退款
|
| 110 |
+
_expired_task_lock = threading.Lock()
|
| 111 |
|
| 112 |
def check_and_update_expired_tasks(tasks_db, db_session=None):
|
| 113 |
"""
|
|
|
|
| 164 |
# ========== 第二阶段:执行SQL事务(钱包退款+交易记录) ==========
|
| 165 |
refund_results = [] # 记录退款成功的任务
|
| 166 |
sql_committed = False
|
| 167 |
+
|
| 168 |
+
# 🔒 P1修复(CAS):整个"复核→退款→写状态"流程串行化,并以磁盘最新数据复核任务状态
|
| 169 |
+
# (仅退仍为 open 且未退过款的任务),防止并发旧快照导致同一任务重复退款
|
| 170 |
+
with _expired_task_lock:
|
| 171 |
+
try:
|
| 172 |
+
fresh_tasks = {t["id"]: t for t in db.load_data("tasks.json", default_data=[])}
|
| 173 |
+
except Exception:
|
| 174 |
+
fresh_tasks = {} # 复核失败时宁可不退款,等待下次扫描
|
| 175 |
+
|
| 176 |
+
def _still_refundable(item):
|
| 177 |
+
fresh = fresh_tasks.get(item["task_id"])
|
| 178 |
+
return fresh is not None and fresh.get("status") == "open" and not fresh.get("refunded")
|
| 179 |
+
|
| 180 |
+
expired_task_indices = [item for item in expired_task_indices if _still_refundable(item)]
|
| 181 |
+
if not expired_task_indices:
|
| 182 |
+
return updated
|
| 183 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
try:
|
| 185 |
for item in expired_task_indices:
|
|
|
|
|
|
|
|
|
|
| 186 |
if item["amount"] > 0:
|
| 187 |
+
wallet = db_session.query(Wallet).filter(Wallet.account == item["publisher"]).with_for_update().first()
|
| 188 |
+
if wallet:
|
| 189 |
+
wallet.frozen_balance = max(0, wallet.frozen_balance - item["amount"])
|
| 190 |
+
wallet.balance += item["amount"]
|
| 191 |
+
|
| 192 |
+
# 记录退款交易
|
| 193 |
+
create_task_transaction(
|
| 194 |
+
db_session, item["publisher"], "TASK_REFUND",
|
| 195 |
+
item["amount"], task_id=item["task_id"],
|
| 196 |
+
task_title=item["title"]
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
refund_results.append(item)
|
| 200 |
+
|
| 201 |
+
# 💳 P0修复:先 commit SQL 事务
|
| 202 |
+
db_session.commit()
|
| 203 |
+
sql_committed = True
|
| 204 |
+
logger.info(f"TASK_EXPIRED_SQL_COMMIT | refunded_tasks={len(refund_results)}")
|
| 205 |
+
|
| 206 |
except Exception as e:
|
| 207 |
+
# 💳 P0修复:SQL事务失败,回滚所有操作,不修改JSON
|
| 208 |
+
db_session.rollback()
|
| 209 |
+
logger.error(f"TASK_EXPIRED_SQL_ROLLBACK | error={str(e)}")
|
| 210 |
+
# 不修改 JSON,下次再试
|
| 211 |
+
return False
|
| 212 |
+
|
| 213 |
+
# ========== 第三阶段:SQL成功后修改JSON任务状态 ==========
|
| 214 |
+
if sql_committed:
|
| 215 |
+
try:
|
| 216 |
+
# 🔒 P1修复:按 id 定位任务(而非索引),避免并发修改后索引错位
|
| 217 |
+
task_map = {t["id"]: t for t in tasks_db}
|
| 218 |
+
for item in expired_task_indices:
|
| 219 |
+
task = task_map.get(item["task_id"])
|
| 220 |
+
if not task:
|
| 221 |
+
continue
|
| 222 |
+
task["status"] = "expired"
|
| 223 |
+
task["expired_at"] = int(time.time())
|
| 224 |
+
if item["amount"] > 0:
|
| 225 |
+
task["refunded"] = True
|
| 226 |
+
task["refund_amount"] = item["amount"]
|
| 227 |
+
|
| 228 |
+
# 💳 P0修复:保存JSON数据
|
| 229 |
+
db.save_data("tasks.json", tasks_db)
|
| 230 |
+
logger.info(f"TASK_EXPIRED_JSON_SAVED | tasks={len(expired_task_indices)}")
|
| 231 |
+
|
| 232 |
+
except Exception as e:
|
| 233 |
+
# 💳 P0修复:JSON保存失败,记录告警但不回滚SQL(资金已转移,可后续手动恢复)
|
| 234 |
+
import traceback
|
| 235 |
+
traceback.print_exc()
|
| 236 |
+
task_ids = ",".join(item["task_id"] for item in expired_task_indices)
|
| 237 |
+
print(f"[CRITICAL-DATA-INCONSISTENCY] task_id={task_ids}, SQL已退款但JSON状态未更新. Error: {e}")
|
| 238 |
+
logger.critical(f"TASK_EXPIRED_JSON_SAVE_FAILED | [CRITICAL-DATA-INCONSISTENCY] task_ids={task_ids} | SQL已退款但JSON状态未更新 | error={str(e)}")
|
| 239 |
+
# 不抛出异常 - 资金安全退回是更高优先级
|
| 240 |
+
# 定时任务会在下次扫描时修正状态不一致
|
| 241 |
|
| 242 |
# 发送退款通知(在事务外执行,失败不影响主流程)
|
| 243 |
for item in refund_results:
|
|
|
|
| 773 |
# ==========================================
|
| 774 |
|
| 775 |
@router.post("/api/tasks/{task_id}/submit")
|
| 776 |
+
async def submit_task(task_id: str, payload: TaskSubmit, current_user: str = Depends(require_auth)):
|
| 777 |
"""
|
| 778 |
接单者提交成果
|
| 779 |
"""
|
| 780 |
+
# 🔧 P2修复:前端以 JSON body 提交,原裸标量参数会被解析为 query 导致 422
|
| 781 |
+
deliverables = payload.deliverables
|
| 782 |
+
note = payload.note
|
| 783 |
tasks_db = db.load_data("tasks.json", default_data=[])
|
| 784 |
|
| 785 |
for task in tasks_db:
|
|
|
|
| 954 |
|
| 955 |
|
| 956 |
@router.post("/api/tasks/{task_id}/dispute")
|
| 957 |
+
async def create_dispute(task_id: str, payload: TaskDispute, current_user: str = Depends(require_auth)):
|
| 958 |
"""
|
| 959 |
发起申诉(发布者或接单者均可)
|
| 960 |
- evidence: 证据图片URL列表(可选)
|
| 961 |
"""
|
| 962 |
+
# 🔧 P2修复:前端以 JSON body 提交,原裸标量参数会被解析为 query 导致 422
|
| 963 |
+
reason = payload.reason
|
| 964 |
+
evidence = payload.evidence
|
| 965 |
tasks_db = db.load_data("tasks.json", default_data=[])
|
| 966 |
disputes_db = db.load_data("disputes.json", default_data=[])
|
| 967 |
|
|
|
|
| 1061 |
raise HTTPException(status_code=404, detail="申诉不存在")
|
| 1062 |
|
| 1063 |
@router.post("/api/disputes/{dispute_id}/respond")
|
| 1064 |
+
async def respond_dispute(dispute_id: str, payload: TaskDisputeResponse, current_user: str = Depends(require_auth)):
|
| 1065 |
"""
|
| 1066 |
被申诉方回应申诉
|
| 1067 |
"""
|
| 1068 |
+
# 🔧 P2修复:前端以 JSON body 提交,原裸标量参数会被解析为 query 导致 422
|
| 1069 |
+
response = payload.response
|
| 1070 |
+
evidence = payload.evidence
|
| 1071 |
disputes_db = db.load_data("disputes.json", default_data=[])
|
| 1072 |
|
| 1073 |
for dispute in disputes_db:
|
|
|
|
| 1513 |
"""
|
| 1514 |
if amount <= 0:
|
| 1515 |
raise HTTPException(status_code=400, detail="打赏金额必须大于0")
|
| 1516 |
+
MAX_TIP_AMOUNT = 100000
|
| 1517 |
+
if amount > MAX_TIP_AMOUNT:
|
| 1518 |
+
raise HTTPException(status_code=400, detail=f"打赏金额必须在1-{MAX_TIP_AMOUNT}之间")
|
| 1519 |
+
|
| 1520 |
+
# 🔒 P0数据修复(时序):先只读校验 → SQL转账扣款 → 提交成功后再写打赏榜
|
| 1521 |
+
# 避免余额不足/重复请求时榜单已被更新的数据不一致
|
| 1522 |
+
tasks_db_ro = db.load_data("tasks.json", default_data=[])
|
| 1523 |
+
target_task = next((t for t in tasks_db_ro if t["id"] == task_id), None)
|
| 1524 |
+
if not target_task:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1525 |
raise HTTPException(status_code=404, detail="任务不存在")
|
| 1526 |
+
if target_task.get("publisher") == current_user:
|
| 1527 |
raise HTTPException(status_code=400, detail="不能打赏自己的任务")
|
| 1528 |
+
publisher = target_task.get("publisher")
|
| 1529 |
+
if not publisher:
|
| 1530 |
+
raise HTTPException(status_code=404, detail="作者账户不存在")
|
| 1531 |
+
task_title = target_task.get("title")
|
| 1532 |
|
| 1533 |
+
# 💳 使用 SQL Wallet 系统处理余额转账(先扣款)
|
| 1534 |
try:
|
|
|
|
|
|
|
|
|
|
| 1535 |
|
| 1536 |
# 🔒 P1幂等性防护:检查最近5秒内是否存在相同交易
|
| 1537 |
recent_cutoff = datetime.datetime.utcnow() - datetime.timedelta(seconds=5)
|
|
|
|
| 1571 |
prev_hash_tipper = last_tx_tipper.tx_hash if last_tx_tipper else "GENESIS_HASH"
|
| 1572 |
prev_hash_author = last_tx_author.tx_hash if last_tx_author else "GENESIS_HASH"
|
| 1573 |
|
| 1574 |
+
# 获取用户信息(task_title 已在预校验阶段取得)
|
| 1575 |
users_db = db.load_data("users.json", default_data={})
|
|
|
|
| 1576 |
author_info = users_db.get(publisher, {})
|
| 1577 |
tipper_info = users_db.get(current_user, {})
|
| 1578 |
author_name = author_info.get("name", publisher)
|
| 1579 |
tipper_name = tipper_info.get("name", current_user)
|
| 1580 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1581 |
# 打赏方交易记录 (TIP_OUT)
|
| 1582 |
tx_tipper = Transaction(
|
| 1583 |
tx_id=tx_id_tipper,
|
|
|
|
| 1646 |
logger.error(f"TASK_TIP_ERROR | task={task_id} | amount={amount} | error={str(e)}")
|
| 1647 |
raise HTTPException(status_code=500, detail="打赏处理失败,请稍后重试")
|
| 1648 |
|
| 1649 |
+
# 🏆 扣款成功后再写打赏榜(写榜失败仅记录日志,不影响资金安全)
|
| 1650 |
+
def board_updater(data):
|
| 1651 |
+
for task in data:
|
| 1652 |
+
if task["id"] == task_id:
|
| 1653 |
+
tip_board = task.get("tip_board", [])
|
| 1654 |
+
existing = next((t for t in tip_board if t["account"] == current_user), None)
|
| 1655 |
+
if existing:
|
| 1656 |
+
existing["amount"] += amount
|
| 1657 |
+
else:
|
| 1658 |
+
tip_board.append({"account": current_user, "amount": amount, "is_anon": is_anon})
|
| 1659 |
+
tip_board.sort(key=lambda x: x["amount"], reverse=True)
|
| 1660 |
+
task["tip_board"] = tip_board
|
| 1661 |
+
break
|
| 1662 |
+
|
| 1663 |
+
try:
|
| 1664 |
+
db.atomic_update("tasks.json", board_updater, default_data=[])
|
| 1665 |
+
except Exception as e:
|
| 1666 |
+
logger.error(f"TASK_TIP_BOARD_ERROR | task={task_id} | error={str(e)}")
|
| 1667 |
+
|
| 1668 |
+
return {"status": "success", "message": f"成功打赏 {amount} 积分"}
|
| 1669 |
|
| 1670 |
# ==========================================
|
| 1671 |
# 💬 评论接口(复用通用评论系统)
|
router_wallet.py
CHANGED
|
@@ -21,7 +21,7 @@ import os
|
|
| 21 |
import datetime
|
| 22 |
import logging
|
| 23 |
from database_sql import get_db
|
| 24 |
-
from models_sql import Wallet, Transaction, Ownership, Refund
|
| 25 |
from models import RechargeRequest, WithdrawRequest, PurchaseRequest, TipRequest
|
| 26 |
import 数据库连接 as json_db
|
| 27 |
from notifications import add_notification
|
|
@@ -49,8 +49,51 @@ from verify_code_engine import VERIFY_CODES
|
|
| 49 |
router = APIRouter()
|
| 50 |
|
| 51 |
# 🔒 P0安全修复:充值订单预存映射,回调时通过订单号查找真实账号,不依赖 subject 字段
|
|
|
|
| 52 |
PENDING_RECHARGE_ORDERS = {} # {order_id: {"account": str, "amount": int, "created_at": int}}
|
| 53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
# ==========================================
|
| 55 |
# 脱敏工具函数
|
| 56 |
# ==========================================
|
|
@@ -127,7 +170,7 @@ def calculate_tx_hash(tx_id, account, tx_type, amount, prev_hash):
|
|
| 127 |
return hashlib.sha256(data.encode()).hexdigest()
|
| 128 |
|
| 129 |
@router.post("/api/wallet/create_recharge_order")
|
| 130 |
-
async def create_recharge_order(req: RechargeRequest, current_user: str = Depends(require_auth)):
|
| 131 |
if not alipay:
|
| 132 |
# 这里会将真实的错误原因直接弹窗发给前端!
|
| 133 |
raise HTTPException(status_code=500, detail="支付网关配置错误,请联系管理员")
|
|
@@ -137,12 +180,8 @@ async def create_recharge_order(req: RechargeRequest, current_user: str = Depend
|
|
| 137 |
order_id = f"PAY_{uuid.uuid4().hex}"
|
| 138 |
subject = f"ComfyUI Community Points - {authenticated_account}"
|
| 139 |
|
| 140 |
-
# 🔒 P0安全修复:预存订单号->账号映射,供回调时安全查找
|
| 141 |
-
|
| 142 |
-
"account": authenticated_account,
|
| 143 |
-
"amount": req.amount,
|
| 144 |
-
"created_at": int(time.time())
|
| 145 |
-
}
|
| 146 |
|
| 147 |
order_string = alipay.api_alipay_trade_precreate(
|
| 148 |
out_trade_no=order_id,
|
|
@@ -179,8 +218,8 @@ async def alipay_notify(request: Request, db: Session = Depends(get_db)):
|
|
| 179 |
|
| 180 |
amount = int(float(data.get("total_amount", 0)))
|
| 181 |
|
| 182 |
-
# 🔒 P0安全修复:通过预存订单映射查找真实账号,不依赖 subject 字段提取
|
| 183 |
-
order_info =
|
| 184 |
if not order_info:
|
| 185 |
logger.warning(f"RECHARGE_ORDER_NOT_FOUND | order={order_id} | subject={data.get('subject', '')}")
|
| 186 |
return Response(content="fail", media_type="text/plain")
|
|
@@ -192,8 +231,8 @@ async def alipay_notify(request: Request, db: Session = Depends(get_db)):
|
|
| 192 |
logger.warning(f"RECHARGE_ACCOUNT_MISMATCH | order={order_id} | expected={account} | subject={subject_account}")
|
| 193 |
return Response(content="fail", media_type="text/plain")
|
| 194 |
|
| 195 |
-
# 清理已处理的预存订单
|
| 196 |
-
|
| 197 |
|
| 198 |
# 🔒 P0安全修复:充值金额有效性校验
|
| 199 |
if amount <= 0:
|
|
@@ -243,8 +282,8 @@ async def check_order(order_id: str, current_user: str = Depends(require_auth),
|
|
| 243 |
# 🔒 P0安全修复:使用认证用户账号,不再接受客户端传入的 account 参数
|
| 244 |
account = current_user
|
| 245 |
|
| 246 |
-
# 🔒 纵深防御:优先从预存订单映射查找账号
|
| 247 |
-
order_info =
|
| 248 |
if order_info:
|
| 249 |
account = order_info["account"]
|
| 250 |
# 验证认证用户与订单所属用户一致
|
|
@@ -299,7 +338,10 @@ async def check_order(order_id: str, current_user: str = Depends(require_auth),
|
|
| 299 |
return {"status": "PENDING"}
|
| 300 |
|
| 301 |
@router.get("/api/wallet/{account}")
|
| 302 |
-
async def get_wallet(account: str, db: Session = Depends(get_db)):
|
|
|
|
|
|
|
|
|
|
| 303 |
wallet = db.query(Wallet).filter(Wallet.account == account).first()
|
| 304 |
|
| 305 |
# 🚀 P1性能优化:使用聚合函数代替 .all() + sum
|
|
@@ -813,12 +855,16 @@ async def get_transactions(
|
|
| 813 |
page: int = 1,
|
| 814 |
limit: int = 20,
|
| 815 |
tx_type: str = None,
|
|
|
|
| 816 |
db: Session = Depends(get_db)
|
| 817 |
):
|
| 818 |
"""
|
| 819 |
获取用户交易明细(分页)
|
| 820 |
- tx_type: 可选筛选(RECHARGE/PURCHASE/SALE/TIP_OUT/TIP_IN/WITHDRAW/TASK_FREEZE/TASK_DEPOSIT/TASK_PAYMENT/TASK_INCOME/TASK_REFUND)
|
| 821 |
"""
|
|
|
|
|
|
|
|
|
|
| 822 |
query = db.query(Transaction).filter(Transaction.account == account)
|
| 823 |
|
| 824 |
if tx_type:
|
|
@@ -865,11 +911,14 @@ async def get_transactions(
|
|
| 865 |
}
|
| 866 |
|
| 867 |
@router.get("/api/wallet/{account}/task-stats")
|
| 868 |
-
async def get_task_stats(account: str, db: Session = Depends(get_db)):
|
| 869 |
"""
|
| 870 |
📊 获取用户任务收益统计
|
| 871 |
🚀 P1性能优化:使用单次查询+分组聚合替代多次查询
|
| 872 |
"""
|
|
|
|
|
|
|
|
|
|
| 873 |
from sqlalchemy import func as sql_func, case
|
| 874 |
|
| 875 |
# 🚀 P1性能优化:使用分组聚合一次查询多种类型的统计
|
|
@@ -930,8 +979,11 @@ async def get_task_stats(account: str, db: Session = Depends(get_db)):
|
|
| 930 |
}
|
| 931 |
|
| 932 |
@router.get("/api/wallet/{account}/tip-stats")
|
| 933 |
-
async def get_tip_stats(account: str, db: Session = Depends(get_db)):
|
| 934 |
"""📊 获取用户打赏统计"""
|
|
|
|
|
|
|
|
|
|
| 935 |
from sqlalchemy import func as sql_func
|
| 936 |
|
| 937 |
stats = db.query(
|
|
@@ -963,12 +1015,15 @@ async def get_tip_stats(account: str, db: Session = Depends(get_db)):
|
|
| 963 |
}
|
| 964 |
|
| 965 |
@router.get("/api/wallet/{account}/sales-stats")
|
| 966 |
-
async def get_sales_stats(account: str, db: Session = Depends(get_db)):
|
| 967 |
"""
|
| 968 |
📊 获取用户销售统计
|
| 969 |
- 销售收入:来自 earn_balance(累计销售收益)
|
| 970 |
- 购买支出:来自 PURCHASE 类型的交易记录
|
| 971 |
"""
|
|
|
|
|
|
|
|
|
|
| 972 |
from sqlalchemy import func as sql_func
|
| 973 |
|
| 974 |
# 获取钱包信息(用于读取 earn_balance)
|
|
@@ -1057,10 +1112,13 @@ async def get_purchases(
|
|
| 1057 |
# ==========================================
|
| 1058 |
|
| 1059 |
@router.get("/api/wallet/{account}/purchase/{item_id}")
|
| 1060 |
-
async def get_purchase_status(account: str, item_id: str, db: Session = Depends(get_db)):
|
| 1061 |
"""
|
| 1062 |
获取购买状态(用���判断是否可退款)
|
| 1063 |
"""
|
|
|
|
|
|
|
|
|
|
| 1064 |
ownership = db.query(Ownership).filter(
|
| 1065 |
Ownership.account == account,
|
| 1066 |
Ownership.item_id == item_id,
|
|
|
|
| 21 |
import datetime
|
| 22 |
import logging
|
| 23 |
from database_sql import get_db
|
| 24 |
+
from models_sql import Wallet, Transaction, Ownership, Refund, RechargeOrder
|
| 25 |
from models import RechargeRequest, WithdrawRequest, PurchaseRequest, TipRequest
|
| 26 |
import 数据库连接 as json_db
|
| 27 |
from notifications import add_notification
|
|
|
|
| 49 |
router = APIRouter()
|
| 50 |
|
| 51 |
# 🔒 P0安全修复:充值订单预存映射,回调时通过订单号查找真实账号,不依赖 subject 字段
|
| 52 |
+
# 🔒 P0数据修复:内存字典仅作热缓存,订单同时落库 RechargeOrder 表,实例重启不丢失
|
| 53 |
PENDING_RECHARGE_ORDERS = {} # {order_id: {"account": str, "amount": int, "created_at": int}}
|
| 54 |
|
| 55 |
+
def _save_pending_order(order_id, account, amount, db):
|
| 56 |
+
"""预存充值订单:内存缓存 + SQL 持久化双写"""
|
| 57 |
+
PENDING_RECHARGE_ORDERS[order_id] = {
|
| 58 |
+
"account": account,
|
| 59 |
+
"amount": amount,
|
| 60 |
+
"created_at": int(time.time())
|
| 61 |
+
}
|
| 62 |
+
try:
|
| 63 |
+
db.add(RechargeOrder(order_id=order_id, account=account, amount=amount, status="pending"))
|
| 64 |
+
db.commit()
|
| 65 |
+
except Exception as e:
|
| 66 |
+
db.rollback()
|
| 67 |
+
logger.warning(f"RECHARGE_ORDER_PERSIST_FAILED | order={order_id} | error={str(e)}")
|
| 68 |
+
|
| 69 |
+
def _get_pending_order(order_id, db):
|
| 70 |
+
"""查找充值订单:优先内存缓存,未命中回查 SQL(重启容灾)"""
|
| 71 |
+
info = PENDING_RECHARGE_ORDERS.get(order_id)
|
| 72 |
+
if info:
|
| 73 |
+
return info
|
| 74 |
+
try:
|
| 75 |
+
row = db.query(RechargeOrder).filter(RechargeOrder.order_id == order_id).first()
|
| 76 |
+
if row:
|
| 77 |
+
return {
|
| 78 |
+
"account": row.account,
|
| 79 |
+
"amount": row.amount,
|
| 80 |
+
"created_at": int(row.created_at.timestamp()) if row.created_at else 0
|
| 81 |
+
}
|
| 82 |
+
except Exception as e:
|
| 83 |
+
logger.warning(f"RECHARGE_ORDER_QUERY_FAILED | order={order_id} | error={str(e)}")
|
| 84 |
+
return None
|
| 85 |
+
|
| 86 |
+
def _mark_order_paid(order_id, db):
|
| 87 |
+
"""订单入账后:清理内存缓存并在 SQL 中标记已支付"""
|
| 88 |
+
PENDING_RECHARGE_ORDERS.pop(order_id, None)
|
| 89 |
+
try:
|
| 90 |
+
row = db.query(RechargeOrder).filter(RechargeOrder.order_id == order_id).first()
|
| 91 |
+
if row:
|
| 92 |
+
row.status = "paid"
|
| 93 |
+
db.commit()
|
| 94 |
+
except Exception:
|
| 95 |
+
db.rollback()
|
| 96 |
+
|
| 97 |
# ==========================================
|
| 98 |
# 脱敏工具函数
|
| 99 |
# ==========================================
|
|
|
|
| 170 |
return hashlib.sha256(data.encode()).hexdigest()
|
| 171 |
|
| 172 |
@router.post("/api/wallet/create_recharge_order")
|
| 173 |
+
async def create_recharge_order(req: RechargeRequest, current_user: str = Depends(require_auth), db: Session = Depends(get_db)):
|
| 174 |
if not alipay:
|
| 175 |
# 这里会将真实的错误原因直接弹窗发给前端!
|
| 176 |
raise HTTPException(status_code=500, detail="支付网关配置错误,请联系管理员")
|
|
|
|
| 180 |
order_id = f"PAY_{uuid.uuid4().hex}"
|
| 181 |
subject = f"ComfyUI Community Points - {authenticated_account}"
|
| 182 |
|
| 183 |
+
# 🔒 P0安全修复:预存订单号->账号映射,供回调时安全查找(内存+SQL双写)
|
| 184 |
+
_save_pending_order(order_id, authenticated_account, req.amount, db)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
|
| 186 |
order_string = alipay.api_alipay_trade_precreate(
|
| 187 |
out_trade_no=order_id,
|
|
|
|
| 218 |
|
| 219 |
amount = int(float(data.get("total_amount", 0)))
|
| 220 |
|
| 221 |
+
# 🔒 P0安全修复:通过预存订单映射查找真实账号,不依赖 subject 字段提取(内存未命中时回查SQL)
|
| 222 |
+
order_info = _get_pending_order(order_id, db)
|
| 223 |
if not order_info:
|
| 224 |
logger.warning(f"RECHARGE_ORDER_NOT_FOUND | order={order_id} | subject={data.get('subject', '')}")
|
| 225 |
return Response(content="fail", media_type="text/plain")
|
|
|
|
| 231 |
logger.warning(f"RECHARGE_ACCOUNT_MISMATCH | order={order_id} | expected={account} | subject={subject_account}")
|
| 232 |
return Response(content="fail", media_type="text/plain")
|
| 233 |
|
| 234 |
+
# 清理已处理的预存订单(内存移除 + SQL 标记 paid)
|
| 235 |
+
_mark_order_paid(order_id, db)
|
| 236 |
|
| 237 |
# 🔒 P0安全修复:充值金额有效性校验
|
| 238 |
if amount <= 0:
|
|
|
|
| 282 |
# 🔒 P0安全修复:使用认证用户账号,不再接受客户端传入的 account 参数
|
| 283 |
account = current_user
|
| 284 |
|
| 285 |
+
# 🔒 纵深防御:优先从预存订单映射查找账号(内存未命中时回查SQL)
|
| 286 |
+
order_info = _get_pending_order(order_id, db)
|
| 287 |
if order_info:
|
| 288 |
account = order_info["account"]
|
| 289 |
# 验证认证用户与订单所属用户一致
|
|
|
|
| 338 |
return {"status": "PENDING"}
|
| 339 |
|
| 340 |
@router.get("/api/wallet/{account}")
|
| 341 |
+
async def get_wallet(account: str, current_user: str = Depends(require_auth), db: Session = Depends(get_db)):
|
| 342 |
+
# 🔒 P0安全加固:钱包数据属敏感信息,需登录且仅可查询本人钱包
|
| 343 |
+
if current_user != account:
|
| 344 |
+
raise HTTPException(status_code=403, detail="只能查询自己的钱包信息")
|
| 345 |
wallet = db.query(Wallet).filter(Wallet.account == account).first()
|
| 346 |
|
| 347 |
# 🚀 P1性能优化:使用聚合函数代替 .all() + sum
|
|
|
|
| 855 |
page: int = 1,
|
| 856 |
limit: int = 20,
|
| 857 |
tx_type: str = None,
|
| 858 |
+
current_user: str = Depends(require_auth),
|
| 859 |
db: Session = Depends(get_db)
|
| 860 |
):
|
| 861 |
"""
|
| 862 |
获取用户交易明细(分页)
|
| 863 |
- tx_type: 可选筛选(RECHARGE/PURCHASE/SALE/TIP_OUT/TIP_IN/WITHDRAW/TASK_FREEZE/TASK_DEPOSIT/TASK_PAYMENT/TASK_INCOME/TASK_REFUND)
|
| 864 |
"""
|
| 865 |
+
# 🔒 P0安全加固:交易明细属敏感信息,需登录且仅可查询本人记录
|
| 866 |
+
if current_user != account:
|
| 867 |
+
raise HTTPException(status_code=403, detail="只能查询自己的交易明细")
|
| 868 |
query = db.query(Transaction).filter(Transaction.account == account)
|
| 869 |
|
| 870 |
if tx_type:
|
|
|
|
| 911 |
}
|
| 912 |
|
| 913 |
@router.get("/api/wallet/{account}/task-stats")
|
| 914 |
+
async def get_task_stats(account: str, current_user: str = Depends(require_auth), db: Session = Depends(get_db)):
|
| 915 |
"""
|
| 916 |
📊 获取用户任务收益统计
|
| 917 |
🚀 P1性能优化:使用单次查询+分组聚合替代多次查询
|
| 918 |
"""
|
| 919 |
+
# 🔒 P0安全加固:收益统计属敏感信息,需登录且仅可查询本人数据
|
| 920 |
+
if current_user != account:
|
| 921 |
+
raise HTTPException(status_code=403, detail="只能查询自己的任务统计")
|
| 922 |
from sqlalchemy import func as sql_func, case
|
| 923 |
|
| 924 |
# 🚀 P1性能优化:使用分组聚合一次查询多种类型的统计
|
|
|
|
| 979 |
}
|
| 980 |
|
| 981 |
@router.get("/api/wallet/{account}/tip-stats")
|
| 982 |
+
async def get_tip_stats(account: str, current_user: str = Depends(require_auth), db: Session = Depends(get_db)):
|
| 983 |
"""📊 获取用户打赏统计"""
|
| 984 |
+
# 🔒 P0安全加固:打赏统计属敏感信息,需登录且仅可查询本人数据
|
| 985 |
+
if current_user != account:
|
| 986 |
+
raise HTTPException(status_code=403, detail="只能查询自己的打赏统计")
|
| 987 |
from sqlalchemy import func as sql_func
|
| 988 |
|
| 989 |
stats = db.query(
|
|
|
|
| 1015 |
}
|
| 1016 |
|
| 1017 |
@router.get("/api/wallet/{account}/sales-stats")
|
| 1018 |
+
async def get_sales_stats(account: str, current_user: str = Depends(require_auth), db: Session = Depends(get_db)):
|
| 1019 |
"""
|
| 1020 |
📊 获取用户销售统计
|
| 1021 |
- 销售收入:来自 earn_balance(累计销售收益)
|
| 1022 |
- 购买支出:来自 PURCHASE 类型的交易记录
|
| 1023 |
"""
|
| 1024 |
+
# 🔒 P0安全加固:销售统计属敏感信息,需登录且仅可查询本人数据
|
| 1025 |
+
if current_user != account:
|
| 1026 |
+
raise HTTPException(status_code=403, detail="只能查询自己的销售统计")
|
| 1027 |
from sqlalchemy import func as sql_func
|
| 1028 |
|
| 1029 |
# 获取钱包信息(用于读取 earn_balance)
|
|
|
|
| 1112 |
# ==========================================
|
| 1113 |
|
| 1114 |
@router.get("/api/wallet/{account}/purchase/{item_id}")
|
| 1115 |
+
async def get_purchase_status(account: str, item_id: str, current_user: str = Depends(require_auth), db: Session = Depends(get_db)):
|
| 1116 |
"""
|
| 1117 |
获取购买状态(用���判断是否可退款)
|
| 1118 |
"""
|
| 1119 |
+
# 🔒 P0安全加固:购买状态属敏感信息,需登录且仅可查询本人记录
|
| 1120 |
+
if current_user != account:
|
| 1121 |
+
raise HTTPException(status_code=403, detail="只能查询自己的购买状态")
|
| 1122 |
ownership = db.query(Ownership).filter(
|
| 1123 |
Ownership.account == account,
|
| 1124 |
Ownership.item_id == item_id,
|
安全认证.py
CHANGED
|
@@ -27,14 +27,16 @@ import bcrypt
|
|
| 27 |
# ==========================================
|
| 28 |
# JWT_SECRET: 用于签名 Token,生产环境应设置环境变量
|
| 29 |
# PASSWORD_SALT: 密码哈希加盐,增强安全性
|
| 30 |
-
#
|
| 31 |
_DEFAULT_JWT_SECRET = "ComfyUI-Ranking-JWT-Secret-v2-beta-2026"
|
| 32 |
_DEFAULT_PASSWORD_SALT = "ComfyUI-Ranking-Salt-v1"
|
| 33 |
|
| 34 |
JWT_SECRET = os.environ.get("JWT_SECRET") or _DEFAULT_JWT_SECRET
|
| 35 |
PASSWORD_SALT = os.environ.get("PASSWORD_SALT") or _DEFAULT_PASSWORD_SALT
|
| 36 |
|
| 37 |
-
#
|
|
|
|
|
|
|
| 38 |
|
| 39 |
# Token 有效期配置(单位:秒)
|
| 40 |
TOKEN_EXPIRE_SECONDS = 7 * 24 * 60 * 60 # 默认7天(向后兼容)
|
|
@@ -299,7 +301,7 @@ async def require_auth(authorization: str = Header(None, alias="Authorization"))
|
|
| 299 |
|
| 300 |
token = parts[1]
|
| 301 |
|
| 302 |
-
# 验证 Token(
|
| 303 |
is_valid, account, error_msg = verify_token_with_fallback(token)
|
| 304 |
|
| 305 |
if not is_valid:
|
|
@@ -333,57 +335,33 @@ async def optional_auth(authorization: str = Header(None, alias="Authorization")
|
|
| 333 |
|
| 334 |
|
| 335 |
# ==========================================
|
| 336 |
-
#
|
| 337 |
# ==========================================
|
| 338 |
-
#
|
| 339 |
-
#
|
|
|
|
| 340 |
|
| 341 |
def verify_token_with_fallback(token: str) -> Tuple[bool, Optional[str], str]:
|
| 342 |
"""
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
验证顺序:
|
| 346 |
-
1. 标准 JWT 验证(当前密钥)
|
| 347 |
-
2. 旧版 mock_token 格式兼容
|
| 348 |
-
3. 签名无效的 JWT:降级提取 payload 中的 sub 字段(过渡期兼容)
|
| 349 |
-
|
| 350 |
-
⚠️ 过渡期策略:
|
| 351 |
-
- 当前阶段:宽容模式,允许签名无效的旧 JWT 通过(提取 sub 信任)
|
| 352 |
-
- 下一版本:移除第3步,强制要求有效签名
|
| 353 |
|
| 354 |
参数:
|
| 355 |
-
token: Token 字符串
|
| 356 |
|
| 357 |
返回:
|
| 358 |
(is_valid, account, error_message)
|
| 359 |
"""
|
| 360 |
-
#
|
|
|
|
|
|
|
|
|
|
| 361 |
is_valid, payload, error_msg = verify_token(token)
|
| 362 |
if is_valid:
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
if token.startswith("mock_token_"):
|
| 367 |
-
account = token.replace("mock_token_", "")
|
| 368 |
-
print(f"⚠️ [过渡兼容] 旧版 mock_token 用户: {account}")
|
| 369 |
return True, account, ""
|
| 370 |
-
|
| 371 |
-
# 3. 过渡期兼容:JWT 签名无效时,尝试直接解析 payload 提取账号
|
| 372 |
-
# 这允许旧版(密钥不同时签发的)JWT 仍能使用
|
| 373 |
-
# ⚠️ 下一版本将移除此降级逻辑,届时用户必须重新登录
|
| 374 |
-
if error_msg in ("Token 签名无效", "Token 已过期,请重新登录"):
|
| 375 |
-
try:
|
| 376 |
-
parts = token.split(".")
|
| 377 |
-
if len(parts) == 3:
|
| 378 |
-
payload_json = _base64url_decode(parts[1]).decode("utf-8")
|
| 379 |
-
payload_data = json.loads(payload_json)
|
| 380 |
-
account = payload_data.get("sub")
|
| 381 |
-
if account:
|
| 382 |
-
print(f"⚠️ [过渡兼容] 旧版 JWT 降级信任,用户: {account}(签名未验证,下版本将失效)")
|
| 383 |
-
return True, account, ""
|
| 384 |
-
except Exception:
|
| 385 |
-
pass
|
| 386 |
-
|
| 387 |
return False, None, error_msg or "Token 验证失败"
|
| 388 |
|
| 389 |
|
|
|
|
| 27 |
# ==========================================
|
| 28 |
# JWT_SECRET: 用于签名 Token,生产环境应设置环境变量
|
| 29 |
# PASSWORD_SALT: 密码哈希加盐,增强安全性
|
| 30 |
+
# ⚠️ 默认密钥仅供开发环境使用;生产环境必须通过环境变量配置 JWT_SECRET
|
| 31 |
_DEFAULT_JWT_SECRET = "ComfyUI-Ranking-JWT-Secret-v2-beta-2026"
|
| 32 |
_DEFAULT_PASSWORD_SALT = "ComfyUI-Ranking-Salt-v1"
|
| 33 |
|
| 34 |
JWT_SECRET = os.environ.get("JWT_SECRET") or _DEFAULT_JWT_SECRET
|
| 35 |
PASSWORD_SALT = os.environ.get("PASSWORD_SALT") or _DEFAULT_PASSWORD_SALT
|
| 36 |
|
| 37 |
+
# 🔒 P0安全加固:未配置 JWT_SECRET 时启动日志告警(默认密钥已公开于源码,必须轮换)
|
| 38 |
+
if not os.environ.get("JWT_SECRET"):
|
| 39 |
+
print("⚠️ [安全告警] 未设置 JWT_SECRET 环境变量,正在使用内置默认密钥,请尽快配置并轮换!")
|
| 40 |
|
| 41 |
# Token 有效期配置(单位:秒)
|
| 42 |
TOKEN_EXPIRE_SECONDS = 7 * 24 * 60 * 60 # 默认7天(向后兼容)
|
|
|
|
| 301 |
|
| 302 |
token = parts[1]
|
| 303 |
|
| 304 |
+
# 验证 Token(严格 JWT 验证,不再有降级信任)
|
| 305 |
is_valid, account, error_msg = verify_token_with_fallback(token)
|
| 306 |
|
| 307 |
if not is_valid:
|
|
|
|
| 335 |
|
| 336 |
|
| 337 |
# ==========================================
|
| 338 |
+
# 🔒 严格 Token 验证(已移除所有降级信任)
|
| 339 |
# ==========================================
|
| 340 |
+
# 🔒 P0安全修复历史:旧版实现存在两个高危后门,已全部移除:
|
| 341 |
+
# 1. mock_token_xxx 前缀直接信任后缀为账号(任何人可伪造任意身份)
|
| 342 |
+
# 2. 签名无效/过期的 JWT 仍解析 payload 信任 sub(base64 即可伪造)
|
| 343 |
|
| 344 |
def verify_token_with_fallback(token: str) -> Tuple[bool, Optional[str], str]:
|
| 345 |
"""
|
| 346 |
+
严格验证:仅信任签名有效且未过期的 JWT
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
|
| 348 |
参数:
|
| 349 |
+
token: Token 字符串
|
| 350 |
|
| 351 |
返回:
|
| 352 |
(is_valid, account, error_message)
|
| 353 |
"""
|
| 354 |
+
# 🔒 拒绝旧版 mock_token:前缀可被任何人构造,等同于任意账号伪造后门
|
| 355 |
+
if token.startswith("mock_token_"):
|
| 356 |
+
return False, None, "旧版凭证已失效,请重新登录"
|
| 357 |
+
|
| 358 |
is_valid, payload, error_msg = verify_token(token)
|
| 359 |
if is_valid:
|
| 360 |
+
account = payload.get("sub")
|
| 361 |
+
if not account:
|
| 362 |
+
return False, None, "Token 中缺少用户信息"
|
|
|
|
|
|
|
|
|
|
| 363 |
return True, account, ""
|
| 364 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
return False, None, error_msg or "Token 验证失败"
|
| 366 |
|
| 367 |
|
数据库连接.py
CHANGED
|
@@ -425,9 +425,13 @@ def save_data(file_name: str, data: Union[Dict, List], force_sync: bool = False)
|
|
| 425 |
except Exception as e:
|
| 426 |
# 写入失败,清理临时文件
|
| 427 |
if os.path.exists(temp_path):
|
| 428 |
-
|
|
|
|
|
|
|
|
|
|
| 429 |
print(f"🚨 保存 {file_name} 失败: {e}")
|
| 430 |
-
raise
|
|
|
|
| 431 |
|
| 432 |
# ========== 第五步:标记文件脏,等待批量同步 ==========
|
| 433 |
if HF_TOKEN:
|
|
@@ -437,6 +441,8 @@ def save_data(file_name: str, data: Union[Dict, List], force_sync: bool = False)
|
|
| 437 |
if force_sync:
|
| 438 |
_immediate_sync_file(file_name)
|
| 439 |
|
|
|
|
|
|
|
| 440 |
|
| 441 |
# ==========================================
|
| 442 |
# ☁️ HF 批量同步机制
|
|
|
|
| 425 |
except Exception as e:
|
| 426 |
# 写入失败,清理临时文件
|
| 427 |
if os.path.exists(temp_path):
|
| 428 |
+
try:
|
| 429 |
+
os.remove(temp_path)
|
| 430 |
+
except OSError:
|
| 431 |
+
pass
|
| 432 |
print(f"🚨 保存 {file_name} 失败: {e}")
|
| 433 |
+
# 🔧 回归核查修复:按契约返回 False(旧版 raise 与 -> bool 声明不符,且无调用方依赖异常)
|
| 434 |
+
return False
|
| 435 |
|
| 436 |
# ========== 第五步:标记文件脏,等待批量同步 ==========
|
| 437 |
if HF_TOKEN:
|
|
|
|
| 441 |
if force_sync:
|
| 442 |
_immediate_sync_file(file_name)
|
| 443 |
|
| 444 |
+
return True
|
| 445 |
+
|
| 446 |
|
| 447 |
# ==========================================
|
| 448 |
# ☁️ HF 批量同步机制
|
测试脚本.py
CHANGED
|
@@ -34,12 +34,16 @@ class TestAuthSecurity(unittest.TestCase):
|
|
| 34 |
self.create_token = create_token
|
| 35 |
self.verify_token = verify_token
|
| 36 |
|
| 37 |
-
def
|
| 38 |
-
"""测试:
|
|
|
|
| 39 |
password = "test123456"
|
| 40 |
hash1 = self.hash_password(password)
|
| 41 |
hash2 = self.hash_password(password)
|
| 42 |
-
self.
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
def test_password_hash_different(self):
|
| 45 |
"""测试:不同密码生成不同哈希"""
|
|
@@ -58,14 +62,19 @@ class TestAuthSecurity(unittest.TestCase):
|
|
| 58 |
hashed = self.hash_password("correctPassword")
|
| 59 |
self.assertFalse(self.verify_password("wrongPassword", hashed), "错误密码应验证失败")
|
| 60 |
|
| 61 |
-
def
|
| 62 |
-
"""测试:
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
def test_token_create_and_verify(self):
|
| 68 |
"""测试:Token 创建和验证"""
|
|
|
|
| 69 |
account = "test_user"
|
| 70 |
token = self.create_token(account)
|
| 71 |
|
|
@@ -74,13 +83,15 @@ class TestAuthSecurity(unittest.TestCase):
|
|
| 74 |
self.assertIn(".", token, "Token 应包含点号分隔符")
|
| 75 |
|
| 76 |
# 验证 Token
|
| 77 |
-
|
| 78 |
-
self.
|
|
|
|
| 79 |
|
| 80 |
def test_token_invalid(self):
|
| 81 |
"""测试:无效 Token 验证失败"""
|
| 82 |
-
|
| 83 |
-
self.
|
|
|
|
| 84 |
|
| 85 |
def test_token_with_extra_data(self):
|
| 86 |
"""测试:Token 携带额外数据"""
|
|
@@ -88,18 +99,20 @@ class TestAuthSecurity(unittest.TestCase):
|
|
| 88 |
extra = {"role": "admin", "level": 5}
|
| 89 |
token = self.create_token(account, extra_data=extra)
|
| 90 |
|
| 91 |
-
|
| 92 |
-
self.
|
|
|
|
|
|
|
| 93 |
|
| 94 |
-
def
|
| 95 |
-
"""测试:mock_token
|
| 96 |
from 安全认证 import verify_token_with_fallback
|
| 97 |
|
| 98 |
-
#
|
| 99 |
mock_token = "mock_token_testuser_1234567890"
|
| 100 |
is_valid, account, _ = verify_token_with_fallback(mock_token)
|
| 101 |
-
self.
|
| 102 |
-
self.
|
| 103 |
|
| 104 |
|
| 105 |
# ==========================================
|
|
|
|
| 34 |
self.create_token = create_token
|
| 35 |
self.verify_token = verify_token
|
| 36 |
|
| 37 |
+
def test_password_hash_unique_and_verifiable(self):
|
| 38 |
+
"""测试:bcrypt 每次加盐不同,哈希唯一且可验证"""
|
| 39 |
+
# 🧹 P2修正:bcrypt 随机加盐,相同密码每次哈希不同(旧断言基于已废弃的固定盐 SHA256)
|
| 40 |
password = "test123456"
|
| 41 |
hash1 = self.hash_password(password)
|
| 42 |
hash2 = self.hash_password(password)
|
| 43 |
+
self.assertNotEqual(hash1, hash2, "bcrypt 应每次生成不同哈希(随机盐)")
|
| 44 |
+
self.assertTrue(hash1.startswith("$2b$"), "哈希应为 bcrypt 格式")
|
| 45 |
+
self.assertTrue(self.verify_password(password, hash1), "哈希1应验证通过")
|
| 46 |
+
self.assertTrue(self.verify_password(password, hash2), "哈希2应验证通过")
|
| 47 |
|
| 48 |
def test_password_hash_different(self):
|
| 49 |
"""测试:不同密码生成不同哈希"""
|
|
|
|
| 62 |
hashed = self.hash_password("correctPassword")
|
| 63 |
self.assertFalse(self.verify_password("wrongPassword", hashed), "错误密码应验证失败")
|
| 64 |
|
| 65 |
+
def test_password_verify_legacy_sha256(self):
|
| 66 |
+
"""测试:旧版 SHA256 哈希向后兼容"""
|
| 67 |
+
# 🧹 P2修正:明文兼容已不存在,实际兼容路径为旧版 SHA256(64位十六进制)
|
| 68 |
+
import hashlib
|
| 69 |
+
from 安全认证 import PASSWORD_SALT
|
| 70 |
+
password = "oldPassword123"
|
| 71 |
+
legacy_hash = hashlib.sha256(f"{PASSWORD_SALT}{password}".encode("utf-8")).hexdigest()
|
| 72 |
+
self.assertTrue(self.verify_password(password, legacy_hash), "旧版 SHA256 哈希应向后兼容")
|
| 73 |
+
self.assertFalse(self.verify_password("wrongPassword", legacy_hash), "错误密码不应通过旧版兼容路径")
|
| 74 |
|
| 75 |
def test_token_create_and_verify(self):
|
| 76 |
"""测试:Token 创建和验证"""
|
| 77 |
+
# 🧹 P2修正:verify_token 返回 (is_valid, payload, error) 三元组
|
| 78 |
account = "test_user"
|
| 79 |
token = self.create_token(account)
|
| 80 |
|
|
|
|
| 83 |
self.assertIn(".", token, "Token 应包含点号分隔符")
|
| 84 |
|
| 85 |
# 验证 Token
|
| 86 |
+
is_valid, payload, error_msg = self.verify_token(token)
|
| 87 |
+
self.assertTrue(is_valid, f"有效 Token 应验证通过: {error_msg}")
|
| 88 |
+
self.assertEqual(payload.get("sub"), account, "验证后的账号应与原账号一致")
|
| 89 |
|
| 90 |
def test_token_invalid(self):
|
| 91 |
"""测试:无效 Token 验证失败"""
|
| 92 |
+
is_valid, payload, error_msg = self.verify_token("invalid.token.here")
|
| 93 |
+
self.assertFalse(is_valid, "无效 Token 应验证失败")
|
| 94 |
+
self.assertIsNone(payload, "无效 Token 的 payload 应为 None")
|
| 95 |
|
| 96 |
def test_token_with_extra_data(self):
|
| 97 |
"""测试:Token 携带额外数据"""
|
|
|
|
| 99 |
extra = {"role": "admin", "level": 5}
|
| 100 |
token = self.create_token(account, extra_data=extra)
|
| 101 |
|
| 102 |
+
is_valid, payload, _ = self.verify_token(token)
|
| 103 |
+
self.assertTrue(is_valid, "携带额外数据的 Token 应验证通过")
|
| 104 |
+
self.assertEqual(payload.get("sub"), account, "验证后的账号应正确")
|
| 105 |
+
self.assertEqual(payload.get("role"), "admin", "额外数据应保留在 payload 中")
|
| 106 |
|
| 107 |
+
def test_mock_token_rejected(self):
|
| 108 |
+
"""测试:旧版 mock_token 必须被拒绝(P0安全后门已移除)"""
|
| 109 |
from 安全认证 import verify_token_with_fallback
|
| 110 |
|
| 111 |
+
# 🧹 P2修正:mock_token 前缀可伪造任意身份,P0已移除降级信任,必须拒绝
|
| 112 |
mock_token = "mock_token_testuser_1234567890"
|
| 113 |
is_valid, account, _ = verify_token_with_fallback(mock_token)
|
| 114 |
+
self.assertFalse(is_valid, "mock_token 应被拒绝(伪造后门已封堵)")
|
| 115 |
+
self.assertIsNone(account, "mock_token 不应解析出账号")
|
| 116 |
|
| 117 |
|
| 118 |
# ==========================================
|