Spaces:
Running
Running
Upload 13 files
Browse files- app.py +74 -19
- database_sql.py +26 -0
- models.py +18 -2
- models_sql.py +43 -0
- requirements.txt +2 -1
- router_items.py +8 -0
- router_wallet.py +124 -0
app.py
CHANGED
|
@@ -1,22 +1,31 @@
|
|
| 1 |
# ⚙️ 后端逻辑/核心服务端.py (Hugging Face Spaces app.py)
|
| 2 |
-
from fastapi import FastAPI, File, UploadFile, Form
|
| 3 |
from fastapi.middleware.cors import CORSMiddleware
|
| 4 |
from fastapi.responses import Response, JSONResponse
|
| 5 |
-
from
|
|
|
|
|
|
|
| 6 |
import hashlib
|
| 7 |
import urllib.parse
|
| 8 |
import urllib.request
|
| 9 |
import os
|
| 10 |
import 数据库连接 as db
|
| 11 |
|
| 12 |
-
# 引入拆分后的四大业务模块
|
| 13 |
from router_users import router as users_router
|
| 14 |
from router_items import router as items_router
|
| 15 |
from router_comments import router as comments_router
|
| 16 |
from router_messages import router as messages_router
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
app = FastAPI(title="ComfyUI Ranking Community API")
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
app.add_middleware(
|
| 21 |
CORSMiddleware,
|
| 22 |
allow_origins=["*"],
|
|
@@ -29,10 +38,11 @@ app.include_router(users_router)
|
|
| 29 |
app.include_router(items_router)
|
| 30 |
app.include_router(comments_router)
|
| 31 |
app.include_router(messages_router)
|
|
|
|
| 32 |
|
| 33 |
@app.get("/")
|
| 34 |
def read_root():
|
| 35 |
-
return {"status": "ok"
|
| 36 |
|
| 37 |
@app.post("/api/upload")
|
| 38 |
async def upload_file(file: UploadFile = File(...), file_type: str = Form(...)):
|
|
@@ -52,36 +62,82 @@ async def upload_file(file: UploadFile = File(...), file_type: str = Form(...)):
|
|
| 52 |
url = f"https://huggingface.co/datasets/{db.DATASET_REPO_ID}/resolve/main/{target_dir}/{safe_url_filename}"
|
| 53 |
return {"status": "success", "url": url, "display_name": file.filename, "hashed_name": new_filename}
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
|
|
|
|
|
|
|
|
|
| 61 |
class ProxyDownloadRequest(BaseModel):
|
| 62 |
url: str
|
|
|
|
|
|
|
| 63 |
|
| 64 |
@app.post("/api/proxy_download")
|
| 65 |
-
async def proxy_download(req_data: ProxyDownloadRequest):
|
| 66 |
target_url = req_data.url
|
| 67 |
-
|
| 68 |
-
# 校验 URL 格式
|
| 69 |
if not target_url or "resolve/main/" not in target_url:
|
| 70 |
return JSONResponse(content={"error": "无效的 Hugging Face 下载链接"}, status_code=400)
|
| 71 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
hf_token = os.environ.get("HF_TOKEN")
|
| 73 |
-
if not hf_token:
|
| 74 |
-
return JSONResponse(content={"error": "云端环境变量未配置 HF_TOKEN,无法读取私有库"}, status_code=401)
|
| 75 |
|
| 76 |
try:
|
| 77 |
-
# 1. 从前端传来的 URL 中切割出仓库内的真实相对路径
|
| 78 |
-
# 例如从 https://.../resolve/main/apps/123_%E6%B5%8B.json 提取出 apps/123_%E6%B5%8B.json
|
| 79 |
repo_path_encoded = target_url.split("resolve/main/")[-1]
|
| 80 |
-
|
| 81 |
-
# 2. 对可能存在的被编码的中文字符进行解码 (恢复为 apps/123_测.json)
|
| 82 |
repo_path = urllib.parse.unquote(repo_path_encoded)
|
| 83 |
|
| 84 |
-
# 3. 调用官方库直接从私有 Dataset 拉取文件(完美处理鉴权和重定向)
|
| 85 |
cached_file_path = hf_hub_download(
|
| 86 |
repo_id=db.DATASET_REPO_ID,
|
| 87 |
repo_type="dataset",
|
|
@@ -89,7 +145,6 @@ async def proxy_download(req_data: ProxyDownloadRequest):
|
|
| 89 |
token=hf_token
|
| 90 |
)
|
| 91 |
|
| 92 |
-
# 4. 读取缓存在云端的本地文件流并返回给前端
|
| 93 |
with open(cached_file_path, "rb") as f:
|
| 94 |
content = f.read()
|
| 95 |
|
|
|
|
| 1 |
# ⚙️ 后端逻辑/核心服务端.py (Hugging Face Spaces app.py)
|
| 2 |
+
from fastapi import FastAPI, File, UploadFile, Form, Depends
|
| 3 |
from fastapi.middleware.cors import CORSMiddleware
|
| 4 |
from fastapi.responses import Response, JSONResponse
|
| 5 |
+
from sqlalchemy.orm import Session
|
| 6 |
+
from pydantic import BaseModel
|
| 7 |
+
from huggingface_hub import hf_hub_download, HfApi
|
| 8 |
import hashlib
|
| 9 |
import urllib.parse
|
| 10 |
import urllib.request
|
| 11 |
import os
|
| 12 |
import 数据库连接 as db
|
| 13 |
|
|
|
|
| 14 |
from router_users import router as users_router
|
| 15 |
from router_items import router as items_router
|
| 16 |
from router_comments import router as comments_router
|
| 17 |
from router_messages import router as messages_router
|
| 18 |
+
from router_wallet import router as wallet_router
|
| 19 |
+
from database_sql import init_sql_db, get_db
|
| 20 |
+
from models_sql import Ownership
|
| 21 |
|
| 22 |
app = FastAPI(title="ComfyUI Ranking Community API")
|
| 23 |
|
| 24 |
+
@app.on_event("startup")
|
| 25 |
+
def on_startup():
|
| 26 |
+
init_sql_db()
|
| 27 |
+
print("关系型数据库加载完毕,金融表同步完成。")
|
| 28 |
+
|
| 29 |
app.add_middleware(
|
| 30 |
CORSMiddleware,
|
| 31 |
allow_origins=["*"],
|
|
|
|
| 38 |
app.include_router(items_router)
|
| 39 |
app.include_router(comments_router)
|
| 40 |
app.include_router(messages_router)
|
| 41 |
+
app.include_router(wallet_router)
|
| 42 |
|
| 43 |
@app.get("/")
|
| 44 |
def read_root():
|
| 45 |
+
return {"status": "ok"}
|
| 46 |
|
| 47 |
@app.post("/api/upload")
|
| 48 |
async def upload_file(file: UploadFile = File(...), file_type: str = Form(...)):
|
|
|
|
| 62 |
url = f"https://huggingface.co/datasets/{db.DATASET_REPO_ID}/resolve/main/{target_dir}/{safe_url_filename}"
|
| 63 |
return {"status": "success", "url": url, "display_name": file.filename, "hashed_name": new_filename}
|
| 64 |
|
| 65 |
+
# =======================================================
|
| 66 |
+
# 【核心新增】:死链与资源有效性检测 (解决问题1:防买空)
|
| 67 |
+
# =======================================================
|
| 68 |
+
class ValidateRequest(BaseModel):
|
| 69 |
+
item_id: str
|
| 70 |
|
| 71 |
+
@app.post("/api/validate_resource")
|
| 72 |
+
async def validate_resource(req: ValidateRequest):
|
| 73 |
+
items_db = db.load_data("items.json", default_data=[])
|
| 74 |
+
item = next((i for i in items_db if i["id"] == req.item_id), None)
|
| 75 |
+
if not item:
|
| 76 |
+
return JSONResponse(content={"error": "该资源已被原作者删除"}, status_code=404)
|
| 77 |
+
|
| 78 |
+
link = item.get("link", "")
|
| 79 |
+
itype = item.get("type", "")
|
| 80 |
+
|
| 81 |
+
if itype.startswith("tool"):
|
| 82 |
+
# 探测 Git 仓库是否为 404 死链
|
| 83 |
+
try:
|
| 84 |
+
req_obj = urllib.request.Request(link, method="HEAD", headers={'User-Agent': 'Mozilla/5.0'})
|
| 85 |
+
with urllib.request.urlopen(req_obj, timeout=5) as response:
|
| 86 |
+
if response.status >= 400:
|
| 87 |
+
return JSONResponse(content={"error": "原作者的 Git 仓库已失效或设为私有"}, status_code=400)
|
| 88 |
+
except Exception as e:
|
| 89 |
+
return JSONResponse(content={"error": "原作者的 Git 仓库无法访问,链接已失效"}, status_code=400)
|
| 90 |
+
|
| 91 |
+
elif itype.startswith("app"):
|
| 92 |
+
# 探测 HF 云端的 JSON 文件是否丢失
|
| 93 |
+
if "resolve/main/" in link:
|
| 94 |
+
repo_path = urllib.parse.unquote(link.split("resolve/main/")[-1])
|
| 95 |
+
hf_token = os.environ.get("HF_TOKEN")
|
| 96 |
+
try:
|
| 97 |
+
api = HfApi()
|
| 98 |
+
exists = api.file_exists(repo_id=db.DATASET_REPO_ID, filename=repo_path, repo_type="dataset", token=hf_token)
|
| 99 |
+
if not exists:
|
| 100 |
+
return JSONResponse(content={"error": "该工作流的 JSON 文件已在云端损坏或丢失"}, status_code=400)
|
| 101 |
+
except Exception:
|
| 102 |
+
pass # 忽略 HF API 自身的偶发网络波动,不强行阻断
|
| 103 |
+
|
| 104 |
+
return {"status": "success"}
|
| 105 |
|
| 106 |
+
# =======================================================
|
| 107 |
+
# 代理下载与所有权鉴权防线
|
| 108 |
+
# =======================================================
|
| 109 |
class ProxyDownloadRequest(BaseModel):
|
| 110 |
url: str
|
| 111 |
+
item_id: str
|
| 112 |
+
account: str
|
| 113 |
|
| 114 |
@app.post("/api/proxy_download")
|
| 115 |
+
async def proxy_download(req_data: ProxyDownloadRequest, sql_db: Session = Depends(get_db)):
|
| 116 |
target_url = req_data.url
|
|
|
|
|
|
|
| 117 |
if not target_url or "resolve/main/" not in target_url:
|
| 118 |
return JSONResponse(content={"error": "无效的 Hugging Face 下载链接"}, status_code=400)
|
| 119 |
|
| 120 |
+
items_db = db.load_data("items.json", default_data=[])
|
| 121 |
+
item = next((i for i in items_db if i["id"] == req_data.item_id), None)
|
| 122 |
+
|
| 123 |
+
if not item: return JSONResponse(content={"error": "资源不存在或已被删除"}, status_code=404)
|
| 124 |
+
|
| 125 |
+
price = int(item.get("price", 0))
|
| 126 |
+
author = item.get("author")
|
| 127 |
+
|
| 128 |
+
# 所有权拦截:如果收费且不是作者本人,严查 SQL 所有权表
|
| 129 |
+
if price > 0 and req_data.account != author:
|
| 130 |
+
owned = sql_db.query(Ownership).filter(Ownership.account == req_data.account, Ownership.item_id == req_data.item_id).first()
|
| 131 |
+
if not owned:
|
| 132 |
+
return JSONResponse(content={"error": "🚨 非法下载:云端数据库未找到您的购买凭证!"}, status_code=403)
|
| 133 |
+
|
| 134 |
hf_token = os.environ.get("HF_TOKEN")
|
| 135 |
+
if not hf_token: return JSONResponse(content={"error": "云端环境变量未配置 HF_TOKEN"}, status_code=401)
|
|
|
|
| 136 |
|
| 137 |
try:
|
|
|
|
|
|
|
| 138 |
repo_path_encoded = target_url.split("resolve/main/")[-1]
|
|
|
|
|
|
|
| 139 |
repo_path = urllib.parse.unquote(repo_path_encoded)
|
| 140 |
|
|
|
|
| 141 |
cached_file_path = hf_hub_download(
|
| 142 |
repo_id=db.DATASET_REPO_ID,
|
| 143 |
repo_type="dataset",
|
|
|
|
| 145 |
token=hf_token
|
| 146 |
)
|
| 147 |
|
|
|
|
| 148 |
with open(cached_file_path, "rb") as f:
|
| 149 |
content = f.read()
|
| 150 |
|
database_sql.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# database_sql.py
|
| 2 |
+
import os
|
| 3 |
+
from sqlalchemy import create_engine
|
| 4 |
+
from sqlalchemy.orm import sessionmaker
|
| 5 |
+
from models_sql import Base
|
| 6 |
+
|
| 7 |
+
# 优先读取环境变量中的 PostgreSQL 数据库连接,如果没有则使用 SQLite 降级方案
|
| 8 |
+
# 生产环境建议在 HF Spaces 的 Secrets 中配置 DATABASE_URL = postgresql://user:pass@host/dbname
|
| 9 |
+
SQLALCHEMY_DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:////tmp/comfy_financial.db")
|
| 10 |
+
|
| 11 |
+
# 如果是 SQLite,需要 check_same_thread=False
|
| 12 |
+
connect_args = {"check_same_thread": False} if "sqlite" in SQLALCHEMY_DATABASE_URL else {}
|
| 13 |
+
|
| 14 |
+
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args=connect_args)
|
| 15 |
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 16 |
+
|
| 17 |
+
def init_sql_db():
|
| 18 |
+
# 启动时自动建表
|
| 19 |
+
Base.metadata.create_all(bind=engine)
|
| 20 |
+
|
| 21 |
+
def get_db():
|
| 22 |
+
db = SessionLocal()
|
| 23 |
+
try:
|
| 24 |
+
yield db
|
| 25 |
+
finally:
|
| 26 |
+
db.close()
|
models.py
CHANGED
|
@@ -63,7 +63,7 @@ class ItemCreate(BaseModel):
|
|
| 63 |
link: str
|
| 64 |
coverUrl: Optional[str] = None
|
| 65 |
author: str
|
| 66 |
-
price:
|
| 67 |
|
| 68 |
class FollowToggle(BaseModel):
|
| 69 |
user_id: str
|
|
@@ -87,4 +87,20 @@ class ItemUpdate(BaseModel):
|
|
| 87 |
fullDesc: Optional[str] = None
|
| 88 |
link: Optional[str] = None
|
| 89 |
coverUrl: Optional[str] = None
|
| 90 |
-
price: Optional[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
link: str
|
| 64 |
coverUrl: Optional[str] = None
|
| 65 |
author: str
|
| 66 |
+
price: int = 0
|
| 67 |
|
| 68 |
class FollowToggle(BaseModel):
|
| 69 |
user_id: str
|
|
|
|
| 87 |
fullDesc: Optional[str] = None
|
| 88 |
link: Optional[str] = None
|
| 89 |
coverUrl: Optional[str] = None
|
| 90 |
+
price: Optional[int] = None
|
| 91 |
+
|
| 92 |
+
class RechargeRequest(BaseModel):
|
| 93 |
+
account: str
|
| 94 |
+
amount: int # 充值的积分数量
|
| 95 |
+
method: str # "alipay" 或 "wechat"
|
| 96 |
+
|
| 97 |
+
class WithdrawRequest(BaseModel):
|
| 98 |
+
account: str
|
| 99 |
+
amount: int # 提现积分数量
|
| 100 |
+
alipay_account: str
|
| 101 |
+
real_name: str
|
| 102 |
+
code: str # 邮箱安全验证码
|
| 103 |
+
|
| 104 |
+
class PurchaseRequest(BaseModel):
|
| 105 |
+
account: str
|
| 106 |
+
item_id: str
|
models_sql.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# models_sql.py
|
| 2 |
+
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Float
|
| 3 |
+
from sqlalchemy.orm import declarative_base
|
| 4 |
+
import datetime
|
| 5 |
+
|
| 6 |
+
Base = declarative_base()
|
| 7 |
+
|
| 8 |
+
class Wallet(Base):
|
| 9 |
+
"""用户钱包表"""
|
| 10 |
+
__tablename__ = "wallets"
|
| 11 |
+
|
| 12 |
+
account = Column(String, primary_key=True, index=True)
|
| 13 |
+
balance = Column(Integer, default=0) # 用于消费的余额 (充值获得)
|
| 14 |
+
earn_balance = Column(Integer, default=0) # 创作者收益余额 (别人购买获得)
|
| 15 |
+
frozen_balance = Column(Integer, default=0) # 提现审核冻结中的余额
|
| 16 |
+
|
| 17 |
+
# 乐观锁版本号,防止并发扣款被击穿
|
| 18 |
+
version = Column(Integer, default=1)
|
| 19 |
+
|
| 20 |
+
class Ownership(Base):
|
| 21 |
+
"""资源所有权表 (解决Req 4:一次购买,终身免费)"""
|
| 22 |
+
__tablename__ = "ownerships"
|
| 23 |
+
|
| 24 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
| 25 |
+
account = Column(String, index=True)
|
| 26 |
+
item_id = Column(String, index=True)
|
| 27 |
+
purchased_at = Column(DateTime, default=datetime.datetime.utcnow)
|
| 28 |
+
|
| 29 |
+
class Transaction(Base):
|
| 30 |
+
"""交易流水表 (复式记账,解决Req 6, 7:税务对账与哈希防篡改)"""
|
| 31 |
+
__tablename__ = "transactions"
|
| 32 |
+
|
| 33 |
+
tx_id = Column(String, primary_key=True) # 全局唯一流水号 (如 ORDER_uuid)
|
| 34 |
+
account = Column(String, index=True)
|
| 35 |
+
tx_type = Column(String) # RECHARGE, CONSUME, EARN, WITHDRAW
|
| 36 |
+
amount = Column(Integer) # 交易额 (正负值)
|
| 37 |
+
target_id = Column(String, nullable=True) # 关联的 item_id 或第三方支付宝流水号
|
| 38 |
+
|
| 39 |
+
# 区块链级安全:哈希防篡改链
|
| 40 |
+
prev_hash = Column(String, nullable=True) # 上一笔交易的哈希值
|
| 41 |
+
tx_hash = Column(String) # 当前交易哈希值
|
| 42 |
+
|
| 43 |
+
created_at = Column(DateTime, default=datetime.datetime.utcnow)
|
requirements.txt
CHANGED
|
@@ -4,4 +4,5 @@ pydantic
|
|
| 4 |
huggingface_hub
|
| 5 |
datasets
|
| 6 |
python-multipart
|
| 7 |
-
alibabacloud_dysmsapi20170525==2.0.24
|
|
|
|
|
|
| 4 |
huggingface_hub
|
| 5 |
datasets
|
| 6 |
python-multipart
|
| 7 |
+
alibabacloud_dysmsapi20170525==2.0.24
|
| 8 |
+
sqlalchemy
|
router_items.py
CHANGED
|
@@ -94,6 +94,10 @@ async def get_creators(sort: str = "downloads", limit: int = 20):
|
|
| 94 |
|
| 95 |
@router.post("/api/items")
|
| 96 |
async def create_item(item: ItemCreate):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
items_db = db.load_data("items.json", default_data=[])
|
| 98 |
new_item = {
|
| 99 |
"id": f"{item.type}_{int(time.time())}_{uuid.uuid4().hex[:6]}", "type": item.type, "title": item.title, "author": item.author,
|
|
@@ -106,6 +110,10 @@ async def create_item(item: ItemCreate):
|
|
| 106 |
|
| 107 |
@router.put("/api/items/{item_id}")
|
| 108 |
async def update_item(item_id: str, update_data: ItemUpdate, author: str):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
items_db = db.load_data("items.json", default_data=[])
|
| 110 |
for item in items_db:
|
| 111 |
if item["id"] == item_id:
|
|
|
|
| 94 |
|
| 95 |
@router.post("/api/items")
|
| 96 |
async def create_item(item: ItemCreate):
|
| 97 |
+
# 【安全新增】:禁止负向消费防线
|
| 98 |
+
if item.price < 0:
|
| 99 |
+
raise HTTPException(status_code=400, detail="🚨 安全拦截:商品价格不能为负数")
|
| 100 |
+
|
| 101 |
items_db = db.load_data("items.json", default_data=[])
|
| 102 |
new_item = {
|
| 103 |
"id": f"{item.type}_{int(time.time())}_{uuid.uuid4().hex[:6]}", "type": item.type, "title": item.title, "author": item.author,
|
|
|
|
| 110 |
|
| 111 |
@router.put("/api/items/{item_id}")
|
| 112 |
async def update_item(item_id: str, update_data: ItemUpdate, author: str):
|
| 113 |
+
# 【安全新增】:禁止负向消费防线
|
| 114 |
+
if update_data.price is not None and update_data.price < 0:
|
| 115 |
+
raise HTTPException(status_code=400, detail="🚨 安全拦截:商品价格不能为负数")
|
| 116 |
+
|
| 117 |
items_db = db.load_data("items.json", default_data=[])
|
| 118 |
for item in items_db:
|
| 119 |
if item["id"] == item_id:
|
router_wallet.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# router_wallet.py
|
| 2 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
import time
|
| 5 |
+
import uuid
|
| 6 |
+
import hashlib
|
| 7 |
+
from database_sql import get_db
|
| 8 |
+
from models_sql import Wallet, Transaction, Ownership
|
| 9 |
+
from models import RechargeRequest, WithdrawRequest, PurchaseRequest
|
| 10 |
+
import 数据库连接 as json_db
|
| 11 |
+
from router_users import VERIFY_CODES
|
| 12 |
+
|
| 13 |
+
router = APIRouter()
|
| 14 |
+
|
| 15 |
+
def calculate_tx_hash(tx_id, account, tx_type, amount, prev_hash):
|
| 16 |
+
"""计算哈希值:依赖前一笔订单的哈希,实现链式防篡改"""
|
| 17 |
+
data = f"{tx_id}{account}{tx_type}{amount}{prev_hash}"
|
| 18 |
+
return hashlib.sha256(data.encode()).hexdigest()
|
| 19 |
+
|
| 20 |
+
def record_transaction(db: Session, account: str, tx_type: str, amount: int, target_id: str = None):
|
| 21 |
+
"""安全记录交易流水"""
|
| 22 |
+
last_tx = db.query(Transaction).filter(Transaction.account == account).order_by(Transaction.created_at.desc()).first()
|
| 23 |
+
prev_hash = last_tx.tx_hash if last_tx else "GENESIS"
|
| 24 |
+
|
| 25 |
+
tx_id = f"TX_{int(time.time())}_{uuid.uuid4().hex[:8]}"
|
| 26 |
+
tx_hash = calculate_tx_hash(tx_id, account, tx_type, amount, prev_hash)
|
| 27 |
+
|
| 28 |
+
new_tx = Transaction(tx_id=tx_id, account=account, tx_type=tx_type, amount=amount, target_id=target_id, prev_hash=prev_hash, tx_hash=tx_hash)
|
| 29 |
+
db.add(new_tx)
|
| 30 |
+
return new_tx
|
| 31 |
+
|
| 32 |
+
@router.post("/api/wallet/recharge")
|
| 33 |
+
async def recharge_points(req: RechargeRequest, db: Session = Depends(get_db)):
|
| 34 |
+
"""充值接口"""
|
| 35 |
+
wallet = db.query(Wallet).filter(Wallet.account == req.account).first()
|
| 36 |
+
if not wallet:
|
| 37 |
+
wallet = Wallet(account=req.account)
|
| 38 |
+
db.add(wallet)
|
| 39 |
+
|
| 40 |
+
# 模拟支付成功后直接给用户加钱
|
| 41 |
+
wallet.balance += req.amount
|
| 42 |
+
record_transaction(db, req.account, "RECHARGE", req.amount, "MOCK_ALIPAY_ORDER")
|
| 43 |
+
|
| 44 |
+
db.commit()
|
| 45 |
+
return {"status": "success", "balance": wallet.balance}
|
| 46 |
+
|
| 47 |
+
@router.post("/api/wallet/purchase")
|
| 48 |
+
async def purchase_item(req: PurchaseRequest, db: Session = Depends(get_db)):
|
| 49 |
+
"""消费购买接口:严密的并发防刷控制,以及早鸟保护机制"""
|
| 50 |
+
items_db = json_db.load_data("items.json", default_data=[])
|
| 51 |
+
item = next((i for i in items_db if i["id"] == req.item_id), None)
|
| 52 |
+
if not item: raise HTTPException(status_code=404, detail="商品不存在")
|
| 53 |
+
|
| 54 |
+
price = int(item.get("price", 0))
|
| 55 |
+
author = item.get("author")
|
| 56 |
+
|
| 57 |
+
# 1. 优先检查是否已经购买或获取过 (核心:无论后续怎么改价,只要所有权表里有你,直接放行)
|
| 58 |
+
owned = db.query(Ownership).filter(Ownership.account == req.account, Ownership.item_id == req.item_id).first()
|
| 59 |
+
if owned: return {"status": "success", "message": "已永久授权,无需重复扣费"}
|
| 60 |
+
|
| 61 |
+
# ==========================================
|
| 62 |
+
# 2. 【核心修改】免费商品或创作者本人的“0元购”固化
|
| 63 |
+
# ==========================================
|
| 64 |
+
if price <= 0 or req.account == author:
|
| 65 |
+
# 直接写入所有权表,赋予永久权限
|
| 66 |
+
new_owner = Ownership(account=req.account, item_id=req.item_id)
|
| 67 |
+
db.add(new_owner)
|
| 68 |
+
# 记录一笔金额为 0 的流水,保证账本的完整溯源
|
| 69 |
+
record_transaction(db, req.account, "CONSUME", 0, req.item_id)
|
| 70 |
+
db.commit()
|
| 71 |
+
return {"status": "success", "message": "免费商品或创作者本人,已永久授权"}
|
| 72 |
+
|
| 73 |
+
# 3. 开启数据库锁防止并发重复扣款 (排他锁)
|
| 74 |
+
buyer_wallet = db.query(Wallet).filter(Wallet.account == req.account).with_for_update().first()
|
| 75 |
+
if not buyer_wallet or buyer_wallet.balance < price:
|
| 76 |
+
raise HTTPException(status_code=400, detail="余额不足,请先充值")
|
| 77 |
+
|
| 78 |
+
# 4. 执行扣费
|
| 79 |
+
buyer_wallet.balance -= price
|
| 80 |
+
record_transaction(db, req.account, "CONSUME", -price, req.item_id)
|
| 81 |
+
|
| 82 |
+
# 5. 记录所有权
|
| 83 |
+
new_owner = Ownership(account=req.account, item_id=req.item_id)
|
| 84 |
+
db.add(new_owner)
|
| 85 |
+
|
| 86 |
+
# 6. 卖家获得收益
|
| 87 |
+
author_wallet = db.query(Wallet).filter(Wallet.account == author).with_for_update().first()
|
| 88 |
+
if not author_wallet:
|
| 89 |
+
author_wallet = Wallet(account=author)
|
| 90 |
+
db.add(author_wallet)
|
| 91 |
+
|
| 92 |
+
author_wallet.earn_balance += price
|
| 93 |
+
record_transaction(db, author, "EARN", price, req.item_id)
|
| 94 |
+
|
| 95 |
+
db.commit()
|
| 96 |
+
return {"status": "success"}
|
| 97 |
+
|
| 98 |
+
@router.post("/api/wallet/withdraw")
|
| 99 |
+
async def withdraw_earnings(req: WithdrawRequest, db: Session = Depends(get_db)):
|
| 100 |
+
"""提现接口"""
|
| 101 |
+
users_db = json_db.load_data("users.json", default_data={})
|
| 102 |
+
user = users_db.get(req.account)
|
| 103 |
+
if not user: raise HTTPException(status_code=404, detail="账号异常")
|
| 104 |
+
|
| 105 |
+
cache_key = f"{user.get('email')}_withdraw"
|
| 106 |
+
cached = VERIFY_CODES.get(cache_key)
|
| 107 |
+
if not cached or cached["code"] != req.code or int(time.time()) > cached["expires_at"]:
|
| 108 |
+
raise HTTPException(status_code=400, detail="验证码��正确或已过期")
|
| 109 |
+
|
| 110 |
+
wallet = db.query(Wallet).filter(Wallet.account == req.account).with_for_update().first()
|
| 111 |
+
if not wallet or wallet.earn_balance < req.amount:
|
| 112 |
+
raise HTTPException(status_code=400, detail="可提现收益不足")
|
| 113 |
+
|
| 114 |
+
if req.amount < 100:
|
| 115 |
+
raise HTTPException(status_code=400, detail="最低提现金额为 100 积分")
|
| 116 |
+
|
| 117 |
+
wallet.earn_balance -= req.amount
|
| 118 |
+
wallet.frozen_balance += req.amount
|
| 119 |
+
record_transaction(db, req.account, "WITHDRAW_FREEZE", -req.amount, req.alipay_account)
|
| 120 |
+
|
| 121 |
+
VERIFY_CODES.pop(cache_key, None)
|
| 122 |
+
|
| 123 |
+
db.commit()
|
| 124 |
+
return {"status": "success", "earn_balance": wallet.earn_balance}
|