ZHIWEI666 commited on
Commit
96a1735
·
verified ·
1 Parent(s): 2cea5aa

Update router_wallet.py

Browse files
Files changed (1) hide show
  1. router_wallet.py +720 -703
router_wallet.py CHANGED
@@ -1,704 +1,721 @@
1
- # router_wallet.py
2
- # ==========================================
3
- # 💰 钱包与交易路由模块
4
- # ==========================================
5
- # 作用:处理充值、提现、购买、打赏等资金操作
6
- # 关联文件:
7
- # - verify_code_engine.py (提现验证码缓存)
8
- # - database_sql.py (SQL数据库连接)
9
- # - models_sql.py (Wallet, Transaction, Ownership 模型)
10
- # 🔒 P0安全优化:API限流
11
- # ==========================================
12
-
13
- from fastapi import APIRouter, Depends, HTTPException, Request
14
- from fastapi.responses import Response
15
- from sqlalchemy.orm import Session
16
- import time
17
- import uuid
18
- import hashlib
19
- import os
20
- import datetime
21
- import logging
22
- from database_sql import get_db
23
- from models_sql import Wallet, Transaction, Ownership, Refund
24
- from models import RechargeRequest, WithdrawRequest, PurchaseRequest, TipRequest
25
- import 数据库连接 as json_db
26
-
27
- # 🔒 P0安全优化:API限流
28
- from slowapi import Limiter
29
- from slowapi.util import get_remote_address
30
- limiter = Limiter(key_func=get_remote_address)
31
-
32
- # 🔄 P7后悔模式:24小时退款窗口
33
- REFUND_WINDOW_HOURS = 24
34
- # 🔄 P7后悔模式:退款后30天禁购
35
- REFUND_BAN_DAYS = 30
36
-
37
- # 📝 P2优化:审计日志
38
- logger = logging.getLogger("ComfyUI-Ranking.Wallet")
39
-
40
- # 🔐 导入验证码缓存 (提现时需要验证)
41
- from verify_code_engine import VERIFY_CODES
42
-
43
- router = APIRouter()
44
-
45
- try:
46
- from alipay import AliPay
47
- from alipay.utils import AliPayConfig
48
- alipay = AliPay(
49
- appid=os.environ.get("ALIPAY_APPID", ""),
50
- app_notify_url="https://zhiwei666-comfyui-ranking-api.hf.space/api/wallet/alipay_notify",
51
- app_private_key_string=os.environ.get("ALIPAY_PRIVATE_KEY", "").replace("\\n", "\n"),
52
- alipay_public_key_string=os.environ.get("ALIPAY_PUBLIC_KEY", "").replace("\\n", "\n"),
53
- sign_type="RSA2",
54
- debug=False,
55
- config=AliPayConfig(timeout=15)
56
- )
57
- except Exception as e:
58
- alipay = None
59
-
60
- def calculate_tx_hash(tx_id, account, tx_type, amount, prev_hash):
61
- data = f"{tx_id}{account}{tx_type}{amount}{prev_hash}"
62
- return hashlib.sha256(data.encode()).hexdigest()
63
-
64
- @router.post("/api/wallet/create_recharge_order")
65
- async def create_recharge_order(req: RechargeRequest):
66
- if not alipay:
67
- raise HTTPException(status_code=500, detail="支付网关未配置或初始化失败")
68
-
69
- order_id = f"PAY_{int(time.time())}_{uuid.uuid4().hex[:6]}"
70
- subject = f"ComfyUI Community Points - {req.account}"
71
-
72
- order_string = alipay.api_alipay_trade_precreate(
73
- out_trade_no=order_id,
74
- total_amount=str(req.amount),
75
- subject=subject
76
- )
77
-
78
- qr_code_url = order_string.get("qr_code")
79
- if not qr_code_url:
80
- raise HTTPException(status_code=500, detail="生成支付二维码失败")
81
-
82
- return {"status": "success", "order_id": order_id, "qr_code": qr_code_url}
83
-
84
- # 🟢 业务流转细节修复:正确解析 application/x-www-form-urlencoded
85
- @router.post("/api/wallet/alipay_notify")
86
- async def alipay_notify(request: Request, db: Session = Depends(get_db)):
87
- # 强制将表单数据解析为纯字典,防止由于数据类型错误导致验签失败
88
- form_data = await request.form()
89
- data = dict(form_data.items())
90
-
91
- signature = data.pop("sign", None)
92
- data.pop("sign_type", None)
93
-
94
- if not alipay or not signature or not alipay.verify(data, signature):
95
- return Response(content="fail", media_type="text/plain")
96
-
97
- if data.get("trade_status") in ("TRADE_SUCCESS", "TRADE_FINISHED"):
98
- order_id = data.get("out_trade_no")
99
-
100
- existing_tx = db.query(Transaction).filter(Transaction.tx_id == order_id).first()
101
- if not existing_tx:
102
- amount = int(float(data.get("total_amount", 0)))
103
- account = data.get("subject", "").split(" - ")[-1]
104
-
105
- # 🔒 P1并发安全:使用悲观锁+异常处理+事务回滚防止并发充值问题
106
- try:
107
- wallet = db.query(Wallet).filter(Wallet.account == account).with_for_update().first()
108
- if not wallet:
109
- wallet = Wallet(account=account)
110
- db.add(wallet)
111
-
112
- wallet.balance += amount
113
-
114
- last_tx = db.query(Transaction).filter(Transaction.account == account).order_by(Transaction.created_at.desc()).first()
115
- prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
116
- tx_hash = calculate_tx_hash(order_id, account, "RECHARGE", amount, prev_hash)
117
-
118
- new_tx = Transaction(
119
- tx_id=order_id, account=account, tx_type="RECHARGE", amount=amount,
120
- prev_hash=prev_hash, tx_hash=tx_hash
121
- )
122
- db.add(new_tx)
123
- db.commit()
124
-
125
- # 📝 P2优化:充值审计日志
126
- logger.info(f"RECHARGE | account={account} | amount={amount} | order={order_id}")
127
- except Exception as e:
128
- db.rollback()
129
- logger.error(f"RECHARGE_ERROR | account={account} | amount={amount} | order={order_id} | error={str(e)}")
130
- return Response(content="fail", media_type="text/plain")
131
-
132
- return Response(content="success", media_type="text/plain")
133
-
134
- @router.get("/api/wallet/check_order/{order_id}")
135
- async def check_order(order_id: str, db: Session = Depends(get_db)):
136
- tx = db.query(Transaction).filter(Transaction.tx_id == order_id).first()
137
- if tx:
138
- return {"status": "SUCCESS"}
139
- return {"status": "PENDING"}
140
-
141
- @router.get("/api/wallet/{account}")
142
- async def get_wallet(account: str, db: Session = Depends(get_db)):
143
- wallet = db.query(Wallet).filter(Wallet.account == account).first()
144
-
145
- # 🚀 P1性能优化:使用聚合函数代替 .all() + sum
146
- from sqlalchemy import func
147
- total_withdrawn = db.query(func.coalesce(func.sum(Transaction.amount), 0)).filter(
148
- Transaction.account == account,
149
- Transaction.tx_type == 'WITHDRAW'
150
- ).scalar() or 0
151
- total_withdrawn = abs(total_withdrawn) # 提现金额是负数
152
-
153
- if not wallet:
154
- return {"status": "success", "balance": 0, "earn_balance": 0, "tip_balance": 0, "frozen_balance": 0, "total_withdrawn": total_withdrawn}
155
-
156
- return {
157
- "status": "success",
158
- "balance": wallet.balance,
159
- "earn_balance": wallet.earn_balance,
160
- "tip_balance": wallet.tip_balance,
161
- "frozen_balance": wallet.frozen_balance,
162
- "total_withdrawn": total_withdrawn # 暴露给前端
163
- }
164
-
165
- @router.post("/api/wallet/purchase")
166
- @limiter.limit("10/minute") # 🔒 P0安全优化:购买每分钟最多10次
167
- async def purchase_item(request: Request, req: PurchaseRequest, db: Session = Depends(get_db)):
168
- items_db = json_db.load_data("items.json", default_data=[])
169
- item = next((i for i in items_db if i["id"] == req.item_id), None)
170
-
171
- if not item:
172
- raise HTTPException(status_code=404, detail="商品不存在")
173
-
174
- # 🔄 P7后悔模式:检查价格是否延迟生效
175
- actual_price = item.get("price", 0)
176
- pending_price = item.get("pending_price")
177
- pending_price_effective = item.get("pending_price_effective_at")
178
- if pending_price is not None and pending_price_effective:
179
- # 检查是否已过生效时间
180
- effective_time = datetime.datetime.fromisoformat(pending_price_effective)
181
- if datetime.datetime.now() >= effective_time:
182
- actual_price = pending_price
183
- # 更新实际价格,清除待生效价格
184
- item["price"] = pending_price
185
- item["pending_price"] = None
186
- item["pending_price_effective_at"] = None
187
- json_db.save_data("items.json", items_db)
188
-
189
- price = int(actual_price)
190
- seller_account = item.get("author")
191
-
192
- if price <= 0 or req.account == seller_account:
193
- # ☁️ 免费资源或作者本人,也返回网盘密码
194
- return {
195
- "status": "success",
196
- "already_owned": True,
197
- "netdisk_password": item.get("netdisk_password"), # ☁️
198
- "is_netdisk": item.get("is_netdisk", False) # ☁️
199
- }
200
-
201
- # 🔄 P7后悔模式:检查30天禁购
202
- refund_ban = db.query(Refund).filter(
203
- Refund.account == req.account,
204
- Refund.item_id == req.item_id,
205
- Refund.ban_until > datetime.datetime.utcnow()
206
- ).first()
207
- if refund_ban:
208
- days_left = (refund_ban.ban_until - datetime.datetime.utcnow()).days + 1
209
- raise HTTPException(status_code=403, detail=f"您已退款过此商品,{days_left}天内禁止再次购买")
210
-
211
- # 检查是否已拥有(排除已退款的记录)
212
- owned = db.query(Ownership).filter(
213
- Ownership.account == req.account,
214
- Ownership.item_id == req.item_id,
215
- Ownership.is_refunded == False
216
- ).first()
217
- if owned:
218
- # ☁️ 买用户,返回网盘密码
219
- return {
220
- "status": "success",
221
- "already_owned": True,
222
- "netdisk_password": item.get("netdisk_password"), # ☁️
223
- "is_netdisk": item.get("is_netdisk", False) # ☁️
224
- }
225
-
226
- # 🔒 P1并发安全:使用悲观锁+异常处理+事务回滚防双花问题
227
- try:
228
- buyer_wallet = db.query(Wallet).filter(Wallet.account == req.account).with_for_update().first()
229
- if not buyer_wallet or buyer_wallet.balance < price:
230
- raise HTTPException(status_code=402, detail="余额不足,请先充值")
231
-
232
- seller_wallet = db.query(Wallet).filter(Wallet.account == seller_account).with_for_update().first()
233
- if not seller_wallet:
234
- seller_wallet = Wallet(account=seller_account)
235
- db.add(seller_wallet)
236
-
237
- buyer_wallet.balance -= price
238
- seller_wallet.earn_balance += price
239
-
240
- # 🔄 P7后悔模式:记录购买价格
241
- new_ownership = Ownership(account=req.account, item_id=req.item_id, price_paid=price)
242
- db.add(new_ownership)
243
-
244
- tx_id = f"BUY_{int(time.time())}_{uuid.uuid4().hex[:6]}"
245
- last_tx = db.query(Transaction).filter(Transaction.account == req.account).order_by(Transaction.created_at.desc()).first()
246
- prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
247
- tx_hash = calculate_tx_hash(tx_id, req.account, "PURCHASE", -price, prev_hash)
248
-
249
- # 创建交易记录 (字段名为 related_account,与 models_sql.py Transaction 模型保持一致)
250
- new_tx = Transaction(
251
- tx_id=tx_id, account=req.account, tx_type="PURCHASE", amount=-price,
252
- related_account=seller_account, item_id=req.item_id, prev_hash=prev_hash, tx_hash=tx_hash
253
- )
254
- db.add(new_tx)
255
- db.commit()
256
-
257
- # 📝 P2优化:购买审计日志
258
- logger.info(f"PURCHASE | buyer={req.account} | seller={seller_account} | item={req.item_id} | amount={price} | tx={tx_id}")
259
-
260
- # ☁️ 购买成功后返回网盘密码
261
- return {
262
- "status": "success",
263
- "already_owned": False,
264
- "netdisk_password": item.get("netdisk_password"), # ☁️ 只有购买成功才返回
265
- "is_netdisk": item.get("is_netdisk", False) # ☁️
266
- }
267
- except HTTPException:
268
- db.rollback()
269
- raise
270
- except Exception as e:
271
- db.rollback()
272
- logger.error(f"PURCHASE_ERROR | buyer={req.account} | item={req.item_id} | error={str(e)}")
273
- raise HTTPException(status_code=500, detail="购买处理失败,请稍后重试")
274
-
275
- @router.post("/api/wallet/tip")
276
- @limiter.limit("20/minute") # 🔒 P0安全优化:打赏每分钟最多20次
277
- async def tip_user(request: Request, req: TipRequest, db: Session = Depends(get_db)):
278
- if req.amount <= 0:
279
- raise HTTPException(status_code=400, detail="打赏金额必须大于0")
280
- if req.sender_account == req.target_account:
281
- raise HTTPException(status_code=400, detail="不能打赏给自己")
282
-
283
- # 🔒 P1并发安全:使用悲观锁+异常处理+事务回滚防止双花问题
284
- try:
285
- sender_wallet = db.query(Wallet).filter(Wallet.account == req.sender_account).with_for_update().first()
286
- target_wallet = db.query(Wallet).filter(Wallet.account == req.target_account).with_for_update().first()
287
-
288
- if not sender_wallet or sender_wallet.balance < req.amount:
289
- raise HTTPException(status_code=400, detail="余额不足")
290
- if not target_wallet:
291
- target_wallet = Wallet(account=req.target_account, balance=0, earn_balance=0, tip_balance=0, frozen_balance=0)
292
- db.add(target_wallet)
293
-
294
- sender_wallet.balance -= req.amount
295
- target_wallet.tip_balance += req.amount
296
-
297
- tx_id_sender = f"TIP_OUT_{int(time.time())}_{uuid.uuid4().hex[:6]}"
298
- tx_id_target = f"TIP_IN_{int(time.time())}_{uuid.uuid4().hex[:6]}"
299
-
300
- # 记录交易
301
- last_tx_sender = db.query(Transaction).filter(Transaction.account == req.sender_account).order_by(Transaction.created_at.desc()).first()
302
- last_tx_target = db.query(Transaction).filter(Transaction.account == req.target_account).order_by(Transaction.created_at.desc()).first()
303
- prev_hash_sender = last_tx_sender.tx_hash if last_tx_sender else "GENESIS_HASH"
304
- prev_hash_target = last_tx_target.tx_hash if last_tx_target else "GENESIS_HASH"
305
-
306
- # 发送方交易记录 (字段名为 related_account,与 models_sql.py 中 Transaction 模型保持一致)
307
- tx_sender = Transaction(tx_id=tx_id_sender, account=req.sender_account, tx_type="TIP_OUT", amount=-req.amount,
308
- related_account=req.target_account, prev_hash=prev_hash_sender,
309
- tx_hash=calculate_tx_hash(tx_id_sender, req.sender_account, "TIP_OUT", -req.amount, prev_hash_sender))
310
-
311
- # 接收方交易记录
312
- tx_target = Transaction(tx_id=tx_id_target, account=req.target_account, tx_type="TIP_IN", amount=req.amount,
313
- related_account=req.sender_account, prev_hash=prev_hash_target,
314
- tx_hash=calculate_tx_hash(tx_id_target, req.target_account, "TIP_IN", req.amount, prev_hash_target))
315
-
316
- db.add(tx_sender)
317
- db.add(tx_target)
318
- db.commit()
319
-
320
- # 📝 P2优化:打赏审计日志
321
- logger.info(f"TIP | from={req.sender_account} | to={req.target_account} | amount={req.amount} | item={req.item_id or 'N/A'} | anon={req.is_anonymous}")
322
-
323
- # 🚀 核心新增:记录打赏榜单和月度收益趋势 (写入 JSON 以供高频读取)
324
- users_db = json_db.load_data("users.json", default_data={})
325
- items_db = json_db.load_data("items.json", default_data=[])
326
- current_month = datetime.date.today().strftime("%Y-%m")
327
-
328
- # 1. 更新创作者的总打赏榜与益趋势
329
- if req.target_account in users_db:
330
- u = users_db[req.target_account]
331
- if "tip_history" not in u: u["tip_history"] = {}
332
- u["tip_history"][current_month] = u["tip_history"].get(current_month, 0) + req.amount
333
-
334
- if "tip_board" not in u: u["tip_board"] = []
335
- sender_entry = next((x for x in u["tip_board"] if x["account"] == req.sender_account), None)
336
- if sender_entry:
337
- sender_entry["amount"] += req.amount
338
- else:
339
- u["tip_board"].append({"account": req.sender_account, "amount": req.amount, "is_anon": req.is_anonymous})
340
- u["tip_board"] = sorted(u["tip_board"], key=lambda x: x["amount"], reverse=True)
341
- json_db.save_data("users.json", users_db)
342
-
343
- # 2. 如果关联了具体作品,更新作品详情的专属打赏榜与收益趋势
344
- if req.item_id:
345
- for item in items_db:
346
- if item["id"] == req.item_id:
347
- if "tip_history" not in item: item["tip_history"] = {}
348
- item["tip_history"][current_month] = item["tip_history"].get(current_month, 0) + req.amount
349
-
350
- if "tip_board" not in item: item["tip_board"] = []
351
- sender_entry = next((x for x in item["tip_board"] if x["account"] == req.sender_account), None)
352
- if sender_entry:
353
- sender_entry["amount"] += req.amount
354
- else:
355
- item["tip_board"].append({"account": req.sender_account, "amount": req.amount, "is_anon": req.is_anonymous})
356
- item["tip_board"] = sorted(item["tip_board"], key=lambda x: x["amount"], reverse=True)
357
- json_db.save_data("items.json", items_db)
358
- break
359
-
360
- return {"status": "success", "balance": sender_wallet.balance}
361
- except HTTPException:
362
- db.rollback()
363
- raise
364
- except Exception as e:
365
- db.rollback()
366
- logger.error(f"TIP_ERROR | from={req.sender_account} | to={req.target_account} | amount={req.amount} | error={str(e)}")
367
- raise HTTPException(status_code=500, detail="打赏处理失败,请稍后重试")
368
-
369
- @router.post("/api/wallet/withdraw")
370
- @limiter.limit("3/minute") # 🔒 P0安全优化:提现每分钟最多3次
371
- async def withdraw(request: Request, req: WithdrawRequest, db: Session = Depends(get_db)):
372
- key = f"{req.account}_withdraw"
373
- code_data = VERIFY_CODES.get(key)
374
- # 🔒 P0安全修复:统一使用 expires_at 字段,兼容旧版 expires
375
- expire_time = code_data.get("expires_at", code_data.get("expires", 0)) if code_data else 0
376
- if not code_data or code_data["code"] != req.code or time.time() > expire_time:
377
- raise HTTPException(status_code=400, detail="验证码无效或已过期")
378
-
379
- # 🔒 P1并发安全:使用悲观锁+异常处理+事务回滚防止并发问题
380
- try:
381
- wallet = db.query(Wallet).filter(Wallet.account == req.account).with_for_update().first()
382
- if not wallet:
383
- raise HTTPException(status_code=400, detail="钱包不存在")
384
-
385
- # 🚀 核心新增:阶梯手续费计算 (与前端逻辑统一)
386
- # 查询历史累计提现总额 (WITHDRAW 类型的 amount 是负数,需要取绝对值)
387
- # 🚀 P1性能优化:使用聚合函数代替 .all() + sum
388
- from sqlalchemy import func as sql_func
389
- withdrawals_sum = db.query(sql_func.coalesce(sql_func.sum(Transaction.amount), 0)).filter(
390
- Transaction.account == req.account,
391
- Transaction.tx_type == 'WITHDRAW'
392
- ).scalar() or 0
393
- total_withdrawn = abs(withdrawals_sum)
394
-
395
- # 手续费规则:100元 = 10000积分 免手续费额度,超出部分收取 10%
396
- free_quota = max(0, 10000 - total_withdrawn) # 剩余免责额度
397
- fee_amount = 0
398
- if req.amount > free_quota:
399
- fee_amount = int((req.amount - free_quota) * 0.10) # 只对超出部分收 10%
400
-
401
- actual_withdraw = req.amount # 从账户扣除的金额
402
- net_amount = req.amount - fee_amount # 用户实际到账金额
403
-
404
- total_withdrawable = wallet.earn_balance + wallet.tip_balance
405
- if actual_withdraw > total_withdrawable:
406
- raise HTTPException(status_code=400, detail="可提现余额不足")
407
-
408
- if actual_withdraw <= wallet.earn_balance:
409
- wallet.earn_balance -= actual_withdraw
410
- else:
411
- remaining = actual_withdraw - wallet.earn_balance
412
- wallet.earn_balance = 0
413
- wallet.tip_balance -= remaining
414
-
415
- wallet.frozen_balance += net_amount # 冻结的是到账金额,非手续费部分
416
-
417
- tx_id = f"WD_{int(time.time())}_{uuid.uuid4().hex[:6]}"
418
- last_tx = db.query(Transaction).filter(Transaction.account == req.account).order_by(Transaction.created_at.desc()).first()
419
- prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
420
- tx_hash = calculate_tx_hash(tx_id, req.account, "WITHDRAW", -actual_withdraw, prev_hash)
421
-
422
- new_tx = Transaction(
423
- tx_id=tx_id, account=req.account, tx_type="WITHDRAW", amount=-actual_withdraw,
424
- prev_hash=prev_hash, tx_hash=tx_hash
425
- )
426
- db.add(new_tx)
427
-
428
- # 🚀 如果有手续费,额外记录一笔手续费交易
429
- if fee_amount > 0:
430
- fee_tx_id = f"FEE_{int(time.time())}_{uuid.uuid4().hex[:6]}"
431
- fee_tx_hash = calculate_tx_hash(fee_tx_id, req.account, "WITHDRAW_FEE", -fee_amount, tx_hash)
432
- fee_tx = Transaction(
433
- tx_id=fee_tx_id, account=req.account, tx_type="WITHDRAW_FEE", amount=-fee_amount,
434
- prev_hash=tx_hash, tx_hash=fee_tx_hash
435
- )
436
- db.add(fee_tx)
437
-
438
- db.commit()
439
-
440
- # 📝 P2优化:提现审计日志
441
- logger.info(f"WITHDRAW | account={req.account} | amount={actual_withdraw} | fee={fee_amount} | net={net_amount} | tx={tx_id}")
442
-
443
- del VERIFY_CODES[key]
444
- return {
445
- "status": "success",
446
- "withdraw_amount": actual_withdraw,
447
- "fee_amount": fee_amount,
448
- "net_amount": net_amount,
449
- "free_quota_used": min(req.amount, free_quota + total_withdrawn) - total_withdrawn # 本次消耗的免责额度
450
- }
451
- except HTTPException:
452
- db.rollback()
453
- raise
454
- except Exception as e:
455
- db.rollback()
456
- logger.error(f"WITHDRAW_ERROR | account={req.account} | amount={req.amount} | error={str(e)}")
457
- raise HTTPException(status_code=500, detail="提现处理失败,请稍后重试")
458
-
459
- # ==========================================
460
- # 💳 P6支付增强:交易明细查询API
461
- # ==========================================
462
-
463
- @router.get("/api/wallet/{account}/transactions")
464
- async def get_transactions(
465
- account: str,
466
- page: int = 1,
467
- limit: int = 20,
468
- tx_type: str = None,
469
- db: Session = Depends(get_db)
470
- ):
471
- """
472
- 获取用户交易明细(分页)
473
- - tx_type: 可选筛选(RECHARGE/PURCHASE/TIP_OUT/TIP_IN/WITHDRAW/TASK_FREEZE/TASK_DEPOSIT/TASK_PAYMENT/TASK_INCOME/TASK_REFUND)
474
- """
475
- query = db.query(Transaction).filter(Transaction.account == account)
476
-
477
- if tx_type:
478
- query = query.filter(Transaction.tx_type == tx_type)
479
-
480
- total = query.count()
481
- transactions = query.order_by(Transaction.created_at.desc()).offset((page - 1) * limit).limit(limit).all()
482
-
483
- # 格式化输出
484
- tx_list = []
485
- for tx in transactions:
486
- tx_list.append({
487
- "tx_id": tx.tx_id,
488
- "tx_type": tx.tx_type,
489
- "amount": tx.amount,
490
- "related_account": tx.related_account,
491
- "item_id": tx.item_id,
492
- "created_at": tx.created_at.isoformat() if tx.created_at else None
493
- })
494
-
495
- return {
496
- "status": "success",
497
- "data": tx_list,
498
- "total": total,
499
- "page": page,
500
- "limit": limit
501
- }
502
-
503
- @router.get("/api/wallet/{account}/task-stats")
504
- async def get_task_stats(account: str, db: Session = Depends(get_db)):
505
- """
506
- 📊 获取用户任务收益统计
507
- 🚀 P1性能优化:使用单次查询+分组聚合替代多次查询
508
- """
509
- from sqlalchemy import func as sql_func, case
510
-
511
- # 🚀 P1性能优化:使用分组聚合一次查询多种类型的统计
512
- stats = db.query(
513
- Transaction.tx_type,
514
- sql_func.count(Transaction.tx_id).label('count'),
515
- sql_func.coalesce(sql_func.sum(Transaction.amount), 0).label('total')
516
- ).filter(
517
- Transaction.account == account,
518
- Transaction.tx_type.in_(["TASK_INCOME", "TASK_FREEZE", "TASK_DEPOSIT", "TASK_PAYMENT", "TASK_REFUND"])
519
- ).group_by(Transaction.tx_type).all()
520
-
521
- # 解析统计结果
522
- stats_map = {s.tx_type: {'count': s.count, 'total': s.total} for s in stats}
523
-
524
- total_income = stats_map.get('TASK_INCOME', {}).get('total', 0) or 0
525
- income_count = stats_map.get('TASK_INCOME', {}).get('count', 0) or 0
526
-
527
- # 任务支出(发布任务的支付)
528
- total_payment = abs(
529
- (stats_map.get('TASK_FREEZE', {}).get('total', 0) or 0) +
530
- (stats_map.get('TASK_DEPOSIT', {}).get('total', 0) or 0) +
531
- (stats_map.get('TASK_PAYMENT', {}).get('total', 0) or 0)
532
- )
533
- payment_count = (
534
- (stats_map.get('TASK_FREEZE', {}).get('count', 0) or 0) +
535
- (stats_map.get('TASK_DEPOSIT', {}).get('count', 0) or 0) +
536
- (stats_map.get('TASK_PAYMENT', {}).get('count', 0) or 0)
537
- )
538
-
539
- total_refund = stats_map.get('TASK_REFUND', {}).get('total', 0) or 0
540
-
541
- # 最近交易(任务相关)
542
- recent_txs = db.query(Transaction).filter(
543
- Transaction.account == account,
544
- Transaction.tx_type.in_(["TASK_INCOME", "TASK_PAYMENT", "TASK_DEPOSIT", "TASK_FREEZE", "TASK_REFUND"])
545
- ).order_by(Transaction.created_at.desc()).limit(10).all()
546
-
547
- recent_list = [{
548
- "tx_id": tx.tx_id,
549
- "tx_type": tx.tx_type,
550
- "amount": tx.amount,
551
- "item_id": tx.item_id,
552
- "created_at": tx.created_at.isoformat() if tx.created_at else None
553
- } for tx in recent_txs]
554
-
555
- return {
556
- "status": "success",
557
- "data": {
558
- "total_income": total_income,
559
- "income_count": income_count,
560
- "total_payment": total_payment,
561
- "payment_count": payment_count,
562
- "total_refund": total_refund,
563
- "net_earnings": total_income - total_payment + total_refund,
564
- "recent_transactions": recent_list
565
- }
566
- }
567
-
568
- # ==========================================
569
- # 🔄 P7后悔模式:退款API
570
- # ==========================================
571
-
572
- @router.get("/api/wallet/{account}/purchase/{item_id}")
573
- async def get_purchase_status(account: str, item_id: str, db: Session = Depends(get_db)):
574
- """
575
- 获取购买状态(用于判断是否可退款)
576
- """
577
- ownership = db.query(Ownership).filter(
578
- Ownership.account == account,
579
- Ownership.item_id == item_id,
580
- Ownership.is_refunded == False
581
- ).first()
582
-
583
- if not ownership:
584
- return {"status": "success", "owned": False}
585
-
586
- # 计算是否在退款窗口内
587
- purchased_at = ownership.purchased_at
588
- now = datetime.datetime.utcnow()
589
- refund_deadline = purchased_at + datetime.timedelta(hours=REFUND_WINDOW_HOURS)
590
- can_refund = now < refund_deadline
591
- hours_left = max(0, (refund_deadline - now).total_seconds() / 3600) if can_refund else 0
592
-
593
- return {
594
- "status": "success",
595
- "owned": True,
596
- "purchased_at": purchased_at.isoformat(),
597
- "price_paid": ownership.price_paid,
598
- "can_refund": can_refund,
599
- "refund_hours_left": round(hours_left, 1)
600
- }
601
-
602
- @router.post("/api/wallet/refund")
603
- @limiter.limit("3/minute") # 🔒 P0安全优化:退款每分钟最多3次
604
- async def refund_purchase(request: Request, account: str, item_id: str, db: Session = Depends(get_db)):
605
- """
606
- 🔄 P7后悔模式:申请退款
607
- - 24小时内可退款
608
- - 退款后30天内禁止再次购买
609
- - 退款后权限回收
610
- """
611
- items_db = json_db.load_data("items.json", default_data=[])
612
- item = next((i for i in items_db if i["id"] == item_id), None)
613
-
614
- if not item:
615
- raise HTTPException(status_code=404, detail="商品不存在")
616
-
617
- # 查找购买记录
618
- ownership = db.query(Ownership).filter(
619
- Ownership.account == account,
620
- Ownership.item_id == item_id,
621
- Ownership.is_refunded == False
622
- ).first()
623
-
624
- if not ownership:
625
- raise HTTPException(status_code=404, detail="未找到购买记录")
626
-
627
- # 检查是否在退款窗口内
628
- purchased_at = ownership.purchased_at
629
- now = datetime.datetime.utcnow()
630
- refund_deadline = purchased_at + datetime.timedelta(hours=REFUND_WINDOW_HOURS)
631
-
632
- if now >= refund_deadline:
633
- hours_passed = (now - purchased_at).total_seconds() / 3600
634
- raise HTTPException(status_code=400, detail=f"已超过24小时退款窗口(已购买{hours_passed:.1f}小时)")
635
-
636
- refund_amount = ownership.price_paid or 0
637
- seller_account = item.get("author")
638
-
639
- if refund_amount <= 0:
640
- raise HTTPException(status_code=400, detail="该商品为免费资源,无需退款")
641
-
642
- # 🔒 P1并发安全:使用悲观锁+异常处理+事务回滚防止并发问题
643
- try:
644
- # 执行退款
645
- buyer_wallet = db.query(Wallet).filter(Wallet.account == account).with_for_update().first()
646
- seller_wallet = db.query(Wallet).filter(Wallet.account == seller_account).with_for_update().first()
647
-
648
- if seller_wallet:
649
- # 从卖家收益中扣除(如果不足则从余额扣除)
650
- if seller_wallet.earn_balance >= refund_amount:
651
- seller_wallet.earn_balance -= refund_amount
652
- else:
653
- remaining = refund_amount - seller_wallet.earn_balance
654
- seller_wallet.earn_balance = 0
655
- seller_wallet.balance = max(0, seller_wallet.balance - remaining)
656
-
657
- if buyer_wallet:
658
- buyer_wallet.balance += refund_amount
659
- else:
660
- buyer_wallet = Wallet(account=account, balance=refund_amount)
661
- db.add(buyer_wallet)
662
-
663
- # 标记所有权为已退款
664
- ownership.is_refunded = True
665
- ownership.refunded_at = now
666
-
667
- # 创建退款记录(30天禁购)
668
- ban_until = now + datetime.timedelta(days=REFUND_BAN_DAYS)
669
- new_refund = Refund(
670
- account=account,
671
- item_id=item_id,
672
- amount=refund_amount,
673
- ban_until=ban_until
674
- )
675
- db.add(new_refund)
676
-
677
- # 记录退款交易
678
- tx_id = f"REFUND_{int(time.time())}_{uuid.uuid4().hex[:6]}"
679
- last_tx = db.query(Transaction).filter(Transaction.account == account).order_by(Transaction.created_at.desc()).first()
680
- prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
681
- tx_hash = calculate_tx_hash(tx_id, account, "REFUND", refund_amount, prev_hash)
682
-
683
- new_tx = Transaction(
684
- tx_id=tx_id, account=account, tx_type="REFUND", amount=refund_amount,
685
- related_account=seller_account, item_id=item_id, prev_hash=prev_hash, tx_hash=tx_hash
686
- )
687
- db.add(new_tx)
688
- db.commit()
689
-
690
- logger.info(f"REFUND | buyer={account} | seller={seller_account} | item={item_id} | amount={refund_amount} | ban_until={ban_until.isoformat()}")
691
-
692
- return {
693
- "status": "success",
694
- "message": f"退款成功,{refund_amount}积分已退还",
695
- "refund_amount": refund_amount,
696
- "ban_days": REFUND_BAN_DAYS
697
- }
698
- except HTTPException:
699
- db.rollback()
700
- raise
701
- except Exception as e:
702
- db.rollback()
703
- logger.error(f"REFUND_ERROR | buyer={account} | item={item_id} | amount={refund_amount} | error={str(e)}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
704
  raise HTTPException(status_code=500, detail="退款处理失败,请稍后重试")
 
1
+ # router_wallet.py
2
+ # ==========================================
3
+ # 💰 钱包与交易路由模块
4
+ # ==========================================
5
+ # 作用:处理充值、提现、购买、打赏等资金操作
6
+ # 关联文件:
7
+ # - verify_code_engine.py (提现验证码缓存)
8
+ # - database_sql.py (SQL数据库连接)
9
+ # - models_sql.py (Wallet, Transaction, Ownership 模型)
10
+ # 🔒 P0安全优化:API限流
11
+ # ==========================================
12
+
13
+ from fastapi import APIRouter, Depends, HTTPException, Request
14
+ from fastapi.responses import Response
15
+ from sqlalchemy.orm import Session
16
+ import time
17
+ import uuid
18
+ import hashlib
19
+ import os
20
+ import datetime
21
+ import logging
22
+ from database_sql import get_db
23
+ from models_sql import Wallet, Transaction, Ownership, Refund
24
+ from models import RechargeRequest, WithdrawRequest, PurchaseRequest, TipRequest
25
+ import 数据库连接 as json_db
26
+
27
+ # 🔒 P0安全优化:API限流
28
+ from slowapi import Limiter
29
+ from slowapi.util import get_remote_address
30
+ limiter = Limiter(key_func=get_remote_address)
31
+
32
+ # 🔄 P7后悔模式:24小时退款窗口
33
+ REFUND_WINDOW_HOURS = 24
34
+ # 🔄 P7后悔模式:退款后30天禁购
35
+ REFUND_BAN_DAYS = 30
36
+
37
+ # 📝 P2优化:审计日志
38
+ logger = logging.getLogger("ComfyUI-Ranking.Wallet")
39
+
40
+ # 🔐 导入验证码缓存 (提现时需要验证)
41
+ from verify_code_engine import VERIFY_CODES
42
+
43
+ router = APIRouter()
44
+
45
+ # ==========================================
46
+ # 🚨 替换这里的支付宝初始化逻辑 🚨
47
+ # ==========================================
48
+ alipay_error_msg = "未知错误"
49
+ try:
50
+ from alipay import AliPay
51
+ from alipay.utils import AliPayConfig
52
+
53
+ # 获取环境变量并清理可能存在的首尾空格
54
+ appid = os.environ.get("ALIPAY_APPID", "").strip()
55
+ priv_key = os.environ.get("ALIPAY_PRIVATE_KEY", "").replace("\\n", "\n").strip()
56
+ pub_key = os.environ.get("ALIPAY_PUBLIC_KEY", "").replace("\\n", "\n").strip()
57
+
58
+ if not appid or not priv_key or not pub_key:
59
+ alipay_error_msg = f"缺少环境变量。当前读取到: APPID={bool(appid)}, PRIV_KEY={bool(priv_key)}, PUB_KEY={bool(pub_key)}"
60
+ alipay = None
61
+ else:
62
+ alipay = AliPay(
63
+ appid=appid,
64
+ app_notify_url="https://zhiwei666-comfyui-ranking-api.hf.space/api/wallet/alipay_notify",
65
+ app_private_key_string=priv_key,
66
+ alipay_public_key_string=pub_key,
67
+ sign_type="RSA2",
68
+ debug=False,
69
+ config=AliPayConfig(timeout=15)
70
+ )
71
+ except Exception as e:
72
+ alipay = None
73
+ alipay_error_msg = f"支付宝 SDK 崩溃: {str(e)}"
74
+ print(f"🚨 支付宝初始化异常: {alipay_error_msg}")
75
+
76
+ def calculate_tx_hash(tx_id, account, tx_type, amount, prev_hash):
77
+ data = f"{tx_id}{account}{tx_type}{amount}{prev_hash}"
78
+ return hashlib.sha256(data.encode()).hexdigest()
79
+
80
+ @router.post("/api/wallet/create_recharge_order")
81
+ async def create_recharge_order(req: RechargeRequest):
82
+ if not alipay:
83
+ # 这里会将真实的错误原因直接弹窗发给前端!
84
+ raise HTTPException(status_code=500, detail=f"支付网关配置错误: {alipay_error_msg}")
85
+
86
+ order_id = f"PAY_{int(time.time())}_{uuid.uuid4().hex[:6]}"
87
+ subject = f"ComfyUI Community Points - {req.account}"
88
+
89
+ order_string = alipay.api_alipay_trade_precreate(
90
+ out_trade_no=order_id,
91
+ total_amount=str(req.amount),
92
+ subject=subject
93
+ )
94
+
95
+ qr_code_url = order_string.get("qr_code")
96
+ if not qr_code_url:
97
+ raise HTTPException(status_code=500, detail="生成支付二维码失败")
98
+
99
+ return {"status": "success", "order_id": order_id, "qr_code": qr_code_url}
100
+
101
+ # 🟢 业务流转细节修复:正确解析 application/x-www-form-urlencoded
102
+ @router.post("/api/wallet/alipay_notify")
103
+ async def alipay_notify(request: Request, db: Session = Depends(get_db)):
104
+ # 强制将表单数据解析为纯字典,防止由于数据类型错误导致验签失败
105
+ form_data = await request.form()
106
+ data = dict(form_data.items())
107
+
108
+ signature = data.pop("sign", None)
109
+ data.pop("sign_type", None)
110
+
111
+ if not alipay or not signature or not alipay.verify(data, signature):
112
+ return Response(content="fail", media_type="text/plain")
113
+
114
+ if data.get("trade_status") in ("TRADE_SUCCESS", "TRADE_FINISHED"):
115
+ order_id = data.get("out_trade_no")
116
+
117
+ existing_tx = db.query(Transaction).filter(Transaction.tx_id == order_id).first()
118
+ if not existing_tx:
119
+ amount = int(float(data.get("total_amount", 0)))
120
+ account = data.get("subject", "").split(" - ")[-1]
121
+
122
+ # 🔒 P1并发安全:使用悲观锁+异常处理+事务回滚防止并发充值问题
123
+ try:
124
+ wallet = db.query(Wallet).filter(Wallet.account == account).with_for_update().first()
125
+ if not wallet:
126
+ wallet = Wallet(account=account)
127
+ db.add(wallet)
128
+
129
+ wallet.balance += amount
130
+
131
+ last_tx = db.query(Transaction).filter(Transaction.account == account).order_by(Transaction.created_at.desc()).first()
132
+ prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
133
+ tx_hash = calculate_tx_hash(order_id, account, "RECHARGE", amount, prev_hash)
134
+
135
+ new_tx = Transaction(
136
+ tx_id=order_id, account=account, tx_type="RECHARGE", amount=amount,
137
+ prev_hash=prev_hash, tx_hash=tx_hash
138
+ )
139
+ db.add(new_tx)
140
+ db.commit()
141
+
142
+ # 📝 P2优化:充值审计日志
143
+ logger.info(f"RECHARGE | account={account} | amount={amount} | order={order_id}")
144
+ except Exception as e:
145
+ db.rollback()
146
+ logger.error(f"RECHARGE_ERROR | account={account} | amount={amount} | order={order_id} | error={str(e)}")
147
+ return Response(content="fail", media_type="text/plain")
148
+
149
+ return Response(content="success", media_type="text/plain")
150
+
151
+ @router.get("/api/wallet/check_order/{order_id}")
152
+ async def check_order(order_id: str, db: Session = Depends(get_db)):
153
+ tx = db.query(Transaction).filter(Transaction.tx_id == order_id).first()
154
+ if tx:
155
+ return {"status": "SUCCESS"}
156
+ return {"status": "PENDING"}
157
+
158
+ @router.get("/api/wallet/{account}")
159
+ async def get_wallet(account: str, db: Session = Depends(get_db)):
160
+ wallet = db.query(Wallet).filter(Wallet.account == account).first()
161
+
162
+ # 🚀 P1性能优化:使用聚合函数代替 .all() + sum
163
+ from sqlalchemy import func
164
+ total_withdrawn = db.query(func.coalesce(func.sum(Transaction.amount), 0)).filter(
165
+ Transaction.account == account,
166
+ Transaction.tx_type == 'WITHDRAW'
167
+ ).scalar() or 0
168
+ total_withdrawn = abs(total_withdrawn) # 提现金额是负数
169
+
170
+ if not wallet:
171
+ return {"status": "success", "balance": 0, "earn_balance": 0, "tip_balance": 0, "frozen_balance": 0, "total_withdrawn": total_withdrawn}
172
+
173
+ return {
174
+ "status": "success",
175
+ "balance": wallet.balance,
176
+ "earn_balance": wallet.earn_balance,
177
+ "tip_balance": wallet.tip_balance,
178
+ "frozen_balance": wallet.frozen_balance,
179
+ "total_withdrawn": total_withdrawn # 暴露给前端
180
+ }
181
+
182
+ @router.post("/api/wallet/purchase")
183
+ @limiter.limit("10/minute") # 🔒 P0安全优化:购买每分钟最多10次
184
+ async def purchase_item(request: Request, req: PurchaseRequest, db: Session = Depends(get_db)):
185
+ items_db = json_db.load_data("items.json", default_data=[])
186
+ item = next((i for i in items_db if i["id"] == req.item_id), None)
187
+
188
+ if not item:
189
+ raise HTTPException(status_code=404, detail="商品不存在")
190
+
191
+ # 🔄 P7后悔模式:检查价格是否延迟生效
192
+ actual_price = item.get("price", 0)
193
+ pending_price = item.get("pending_price")
194
+ pending_price_effective = item.get("pending_price_effective_at")
195
+ if pending_price is not None and pending_price_effective:
196
+ # 检查是否已过生效时间
197
+ effective_time = datetime.datetime.fromisoformat(pending_price_effective)
198
+ if datetime.datetime.now() >= effective_time:
199
+ actual_price = pending_price
200
+ # 更新实际价格,清除待生效价格
201
+ item["price"] = pending_price
202
+ item["pending_price"] = None
203
+ item["pending_price_effective_at"] = None
204
+ json_db.save_data("items.json", items_db)
205
+
206
+ price = int(actual_price)
207
+ seller_account = item.get("author")
208
+
209
+ if price <= 0 or req.account == seller_account:
210
+ # ☁️ 免费资源或作者本人,也返回网盘密码
211
+ return {
212
+ "status": "success",
213
+ "already_owned": True,
214
+ "netdisk_password": item.get("netdisk_password"), # ☁️
215
+ "is_netdisk": item.get("is_netdisk", False) # ☁️
216
+ }
217
+
218
+ # 🔄 P7后悔模式:检查30天禁
219
+ refund_ban = db.query(Refund).filter(
220
+ Refund.account == req.account,
221
+ Refund.item_id == req.item_id,
222
+ Refund.ban_until > datetime.datetime.utcnow()
223
+ ).first()
224
+ if refund_ban:
225
+ days_left = (refund_ban.ban_until - datetime.datetime.utcnow()).days + 1
226
+ raise HTTPException(status_code=403, detail=f"您已退款过此商品,{days_left}天内禁再次购买")
227
+
228
+ # 检查是否已拥有(排除已退款的记录)
229
+ owned = db.query(Ownership).filter(
230
+ Ownership.account == req.account,
231
+ Ownership.item_id == req.item_id,
232
+ Ownership.is_refunded == False
233
+ ).first()
234
+ if owned:
235
+ # ☁️ 已购买用户,返回网盘密码
236
+ return {
237
+ "status": "success",
238
+ "already_owned": True,
239
+ "netdisk_password": item.get("netdisk_password"), # ☁️
240
+ "is_netdisk": item.get("is_netdisk", False) # ☁️
241
+ }
242
+
243
+ # 🔒 P1并发安全:使用悲观锁+异常处理+事务回滚防止双花问题
244
+ try:
245
+ buyer_wallet = db.query(Wallet).filter(Wallet.account == req.account).with_for_update().first()
246
+ if not buyer_wallet or buyer_wallet.balance < price:
247
+ raise HTTPException(status_code=402, detail="余额不足,请先充值")
248
+
249
+ seller_wallet = db.query(Wallet).filter(Wallet.account == seller_account).with_for_update().first()
250
+ if not seller_wallet:
251
+ seller_wallet = Wallet(account=seller_account)
252
+ db.add(seller_wallet)
253
+
254
+ buyer_wallet.balance -= price
255
+ seller_wallet.earn_balance += price
256
+
257
+ # 🔄 P7后悔模式记录购买价格
258
+ new_ownership = Ownership(account=req.account, item_id=req.item_id, price_paid=price)
259
+ db.add(new_ownership)
260
+
261
+ tx_id = f"BUY_{int(time.time())}_{uuid.uuid4().hex[:6]}"
262
+ last_tx = db.query(Transaction).filter(Transaction.account == req.account).order_by(Transaction.created_at.desc()).first()
263
+ prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
264
+ tx_hash = calculate_tx_hash(tx_id, req.account, "PURCHASE", -price, prev_hash)
265
+
266
+ # 创建交易记录 (字段名为 related_account,与 models_sql.py 中 Transaction 模型保持一致)
267
+ new_tx = Transaction(
268
+ tx_id=tx_id, account=req.account, tx_type="PURCHASE", amount=-price,
269
+ related_account=seller_account, item_id=req.item_id, prev_hash=prev_hash, tx_hash=tx_hash
270
+ )
271
+ db.add(new_tx)
272
+ db.commit()
273
+
274
+ # 📝 P2优化:购买审计日志
275
+ logger.info(f"PURCHASE | buyer={req.account} | seller={seller_account} | item={req.item_id} | amount={price} | tx={tx_id}")
276
+
277
+ # ☁️ 购买成功后返回网盘密码
278
+ return {
279
+ "status": "success",
280
+ "already_owned": False,
281
+ "netdisk_password": item.get("netdisk_password"), # ☁️ 只有购买成功才返回
282
+ "is_netdisk": item.get("is_netdisk", False) # ☁️
283
+ }
284
+ except HTTPException:
285
+ db.rollback()
286
+ raise
287
+ except Exception as e:
288
+ db.rollback()
289
+ logger.error(f"PURCHASE_ERROR | buyer={req.account} | item={req.item_id} | error={str(e)}")
290
+ raise HTTPException(status_code=500, detail="购买处理失败,请稍后重试")
291
+
292
+ @router.post("/api/wallet/tip")
293
+ @limiter.limit("20/minute") # 🔒 P0安全优化:打赏每分钟最多20次
294
+ async def tip_user(request: Request, req: TipRequest, db: Session = Depends(get_db)):
295
+ if req.amount <= 0:
296
+ raise HTTPException(status_code=400, detail="打赏金额必须大于0")
297
+ if req.sender_account == req.target_account:
298
+ raise HTTPException(status_code=400, detail="不能打赏给自己")
299
+
300
+ # 🔒 P1并发安全:使用悲观锁+异常处理+事务回滚防止双花问题
301
+ try:
302
+ sender_wallet = db.query(Wallet).filter(Wallet.account == req.sender_account).with_for_update().first()
303
+ target_wallet = db.query(Wallet).filter(Wallet.account == req.target_account).with_for_update().first()
304
+
305
+ if not sender_wallet or sender_wallet.balance < req.amount:
306
+ raise HTTPException(status_code=400, detail="余额不足")
307
+ if not target_wallet:
308
+ target_wallet = Wallet(account=req.target_account, balance=0, earn_balance=0, tip_balance=0, frozen_balance=0)
309
+ db.add(target_wallet)
310
+
311
+ sender_wallet.balance -= req.amount
312
+ target_wallet.tip_balance += req.amount
313
+
314
+ tx_id_sender = f"TIP_OUT_{int(time.time())}_{uuid.uuid4().hex[:6]}"
315
+ tx_id_target = f"TIP_IN_{int(time.time())}_{uuid.uuid4().hex[:6]}"
316
+
317
+ # 记录交易
318
+ last_tx_sender = db.query(Transaction).filter(Transaction.account == req.sender_account).order_by(Transaction.created_at.desc()).first()
319
+ last_tx_target = db.query(Transaction).filter(Transaction.account == req.target_account).order_by(Transaction.created_at.desc()).first()
320
+ prev_hash_sender = last_tx_sender.tx_hash if last_tx_sender else "GENESIS_HASH"
321
+ prev_hash_target = last_tx_target.tx_hash if last_tx_target else "GENESIS_HASH"
322
+
323
+ # 发送方交易记录 (字段名为 related_account,与 models_sql.py 中 Transaction 模型保持一致)
324
+ tx_sender = Transaction(tx_id=tx_id_sender, account=req.sender_account, tx_type="TIP_OUT", amount=-req.amount,
325
+ related_account=req.target_account, prev_hash=prev_hash_sender,
326
+ tx_hash=calculate_tx_hash(tx_id_sender, req.sender_account, "TIP_OUT", -req.amount, prev_hash_sender))
327
+
328
+ # 方交易记录
329
+ tx_target = Transaction(tx_id=tx_id_target, account=req.target_account, tx_type="TIP_IN", amount=req.amount,
330
+ related_account=req.sender_account, prev_hash=prev_hash_target,
331
+ tx_hash=calculate_tx_hash(tx_id_target, req.target_account, "TIP_IN", req.amount, prev_hash_target))
332
+
333
+ db.add(tx_sender)
334
+ db.add(tx_target)
335
+ db.commit()
336
+
337
+ # 📝 P2优化:打赏审计日志
338
+ logger.info(f"TIP | from={req.sender_account} | to={req.target_account} | amount={req.amount} | item={req.item_id or 'N/A'} | anon={req.is_anonymous}")
339
+
340
+ # 🚀 核心新增:记录打赏榜单和月度收益趋势 (写入 JSON 以供高频读取)
341
+ users_db = json_db.load_data("users.json", default_data={})
342
+ items_db = json_db.load_data("items.json", default_data=[])
343
+ current_month = datetime.date.today().strftime("%Y-%m")
344
+
345
+ # 1. 更新创作者的总打赏榜与收益趋势
346
+ if req.target_account in users_db:
347
+ u = users_db[req.target_account]
348
+ if "tip_history" not in u: u["tip_history"] = {}
349
+ u["tip_history"][current_month] = u["tip_history"].get(current_month, 0) + req.amount
350
+
351
+ if "tip_board" not in u: u["tip_board"] = []
352
+ sender_entry = next((x for x in u["tip_board"] if x["account"] == req.sender_account), None)
353
+ if sender_entry:
354
+ sender_entry["amount"] += req.amount
355
+ else:
356
+ u["tip_board"].append({"account": req.sender_account, "amount": req.amount, "is_anon": req.is_anonymous})
357
+ u["tip_board"] = sorted(u["tip_board"], key=lambda x: x["amount"], reverse=True)
358
+ json_db.save_data("users.json", users_db)
359
+
360
+ # 2. 如果关联了具体作品,更新作品详情的专属打赏榜与收益趋势
361
+ if req.item_id:
362
+ for item in items_db:
363
+ if item["id"] == req.item_id:
364
+ if "tip_history" not in item: item["tip_history"] = {}
365
+ item["tip_history"][current_month] = item["tip_history"].get(current_month, 0) + req.amount
366
+
367
+ if "tip_board" not in item: item["tip_board"] = []
368
+ sender_entry = next((x for x in item["tip_board"] if x["account"] == req.sender_account), None)
369
+ if sender_entry:
370
+ sender_entry["amount"] += req.amount
371
+ else:
372
+ item["tip_board"].append({"account": req.sender_account, "amount": req.amount, "is_anon": req.is_anonymous})
373
+ item["tip_board"] = sorted(item["tip_board"], key=lambda x: x["amount"], reverse=True)
374
+ json_db.save_data("items.json", items_db)
375
+ break
376
+
377
+ return {"status": "success", "balance": sender_wallet.balance}
378
+ except HTTPException:
379
+ db.rollback()
380
+ raise
381
+ except Exception as e:
382
+ db.rollback()
383
+ logger.error(f"TIP_ERROR | from={req.sender_account} | to={req.target_account} | amount={req.amount} | error={str(e)}")
384
+ raise HTTPException(status_code=500, detail="打赏处理失败,请稍后重试")
385
+
386
+ @router.post("/api/wallet/withdraw")
387
+ @limiter.limit("3/minute") # 🔒 P0安全优化:提现每分钟最多3次
388
+ async def withdraw(request: Request, req: WithdrawRequest, db: Session = Depends(get_db)):
389
+ key = f"{req.account}_withdraw"
390
+ code_data = VERIFY_CODES.get(key)
391
+ # 🔒 P0安全修复:统一使用 expires_at 字段,兼容旧版 expires
392
+ expire_time = code_data.get("expires_at", code_data.get("expires", 0)) if code_data else 0
393
+ if not code_data or code_data["code"] != req.code or time.time() > expire_time:
394
+ raise HTTPException(status_code=400, detail="验证码无效或已过期")
395
+
396
+ # 🔒 P1并发安全:使用悲观锁+异常处理+事务回滚防止并发问题
397
+ try:
398
+ wallet = db.query(Wallet).filter(Wallet.account == req.account).with_for_update().first()
399
+ if not wallet:
400
+ raise HTTPException(status_code=400, detail="钱包不存在")
401
+
402
+ # 🚀 核心新增:阶梯手续费计算 (与前端逻辑统一)
403
+ # 查询历史累计提现总额 (WITHDRAW 类型的 amount 是负数,需要取绝对值)
404
+ # 🚀 P1性能优化:使用聚合函数代替 .all() + sum
405
+ from sqlalchemy import func as sql_func
406
+ withdrawals_sum = db.query(sql_func.coalesce(sql_func.sum(Transaction.amount), 0)).filter(
407
+ Transaction.account == req.account,
408
+ Transaction.tx_type == 'WITHDRAW'
409
+ ).scalar() or 0
410
+ total_withdrawn = abs(withdrawals_sum)
411
+
412
+ # 手续费规则:100元 = 10000积分 免手续费额度,超出部分收取 10%
413
+ free_quota = max(0, 10000 - total_withdrawn) # 剩余免责额度
414
+ fee_amount = 0
415
+ if req.amount > free_quota:
416
+ fee_amount = int((req.amount - free_quota) * 0.10) # 只对超出部分收 10%
417
+
418
+ actual_withdraw = req.amount # 从账户扣除的金额
419
+ net_amount = req.amount - fee_amount # 用户实际到账金额
420
+
421
+ total_withdrawable = wallet.earn_balance + wallet.tip_balance
422
+ if actual_withdraw > total_withdrawable:
423
+ raise HTTPException(status_code=400, detail="可提现余额不足")
424
+
425
+ if actual_withdraw <= wallet.earn_balance:
426
+ wallet.earn_balance -= actual_withdraw
427
+ else:
428
+ remaining = actual_withdraw - wallet.earn_balance
429
+ wallet.earn_balance = 0
430
+ wallet.tip_balance -= remaining
431
+
432
+ wallet.frozen_balance += net_amount # 冻结的是到账金额,非手续费部分
433
+
434
+ tx_id = f"WD_{int(time.time())}_{uuid.uuid4().hex[:6]}"
435
+ last_tx = db.query(Transaction).filter(Transaction.account == req.account).order_by(Transaction.created_at.desc()).first()
436
+ prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
437
+ tx_hash = calculate_tx_hash(tx_id, req.account, "WITHDRAW", -actual_withdraw, prev_hash)
438
+
439
+ new_tx = Transaction(
440
+ tx_id=tx_id, account=req.account, tx_type="WITHDRAW", amount=-actual_withdraw,
441
+ prev_hash=prev_hash, tx_hash=tx_hash
442
+ )
443
+ db.add(new_tx)
444
+
445
+ # 🚀 如果有手续费,额外记录一笔手续费交易
446
+ if fee_amount > 0:
447
+ fee_tx_id = f"FEE_{int(time.time())}_{uuid.uuid4().hex[:6]}"
448
+ fee_tx_hash = calculate_tx_hash(fee_tx_id, req.account, "WITHDRAW_FEE", -fee_amount, tx_hash)
449
+ fee_tx = Transaction(
450
+ tx_id=fee_tx_id, account=req.account, tx_type="WITHDRAW_FEE", amount=-fee_amount,
451
+ prev_hash=tx_hash, tx_hash=fee_tx_hash
452
+ )
453
+ db.add(fee_tx)
454
+
455
+ db.commit()
456
+
457
+ # 📝 P2优化:提现审计日志
458
+ logger.info(f"WITHDRAW | account={req.account} | amount={actual_withdraw} | fee={fee_amount} | net={net_amount} | tx={tx_id}")
459
+
460
+ del VERIFY_CODES[key]
461
+ return {
462
+ "status": "success",
463
+ "withdraw_amount": actual_withdraw,
464
+ "fee_amount": fee_amount,
465
+ "net_amount": net_amount,
466
+ "free_quota_used": min(req.amount, free_quota + total_withdrawn) - total_withdrawn # 本次消耗的免责额度
467
+ }
468
+ except HTTPException:
469
+ db.rollback()
470
+ raise
471
+ except Exception as e:
472
+ db.rollback()
473
+ logger.error(f"WITHDRAW_ERROR | account={req.account} | amount={req.amount} | error={str(e)}")
474
+ raise HTTPException(status_code=500, detail="提现处理失败,请稍后重试")
475
+
476
+ # ==========================================
477
+ # 💳 P6支付增强:交易明细查询API
478
+ # ==========================================
479
+
480
+ @router.get("/api/wallet/{account}/transactions")
481
+ async def get_transactions(
482
+ account: str,
483
+ page: int = 1,
484
+ limit: int = 20,
485
+ tx_type: str = None,
486
+ db: Session = Depends(get_db)
487
+ ):
488
+ """
489
+ 获取用户交���明细(分页)
490
+ - tx_type: 可选筛选(RECHARGE/PURCHASE/TIP_OUT/TIP_IN/WITHDRAW/TASK_FREEZE/TASK_DEPOSIT/TASK_PAYMENT/TASK_INCOME/TASK_REFUND)
491
+ """
492
+ query = db.query(Transaction).filter(Transaction.account == account)
493
+
494
+ if tx_type:
495
+ query = query.filter(Transaction.tx_type == tx_type)
496
+
497
+ total = query.count()
498
+ transactions = query.order_by(Transaction.created_at.desc()).offset((page - 1) * limit).limit(limit).all()
499
+
500
+ # 格式化输出
501
+ tx_list = []
502
+ for tx in transactions:
503
+ tx_list.append({
504
+ "tx_id": tx.tx_id,
505
+ "tx_type": tx.tx_type,
506
+ "amount": tx.amount,
507
+ "related_account": tx.related_account,
508
+ "item_id": tx.item_id,
509
+ "created_at": tx.created_at.isoformat() if tx.created_at else None
510
+ })
511
+
512
+ return {
513
+ "status": "success",
514
+ "data": tx_list,
515
+ "total": total,
516
+ "page": page,
517
+ "limit": limit
518
+ }
519
+
520
+ @router.get("/api/wallet/{account}/task-stats")
521
+ async def get_task_stats(account: str, db: Session = Depends(get_db)):
522
+ """
523
+ 📊 获取用户任务收益统计
524
+ 🚀 P1性能优化:使用单次查询+分组聚合替代多次查询
525
+ """
526
+ from sqlalchemy import func as sql_func, case
527
+
528
+ # 🚀 P1性能优化:使用分组聚合一次查询多种类型的统计
529
+ stats = db.query(
530
+ Transaction.tx_type,
531
+ sql_func.count(Transaction.tx_id).label('count'),
532
+ sql_func.coalesce(sql_func.sum(Transaction.amount), 0).label('total')
533
+ ).filter(
534
+ Transaction.account == account,
535
+ Transaction.tx_type.in_(["TASK_INCOME", "TASK_FREEZE", "TASK_DEPOSIT", "TASK_PAYMENT", "TASK_REFUND"])
536
+ ).group_by(Transaction.tx_type).all()
537
+
538
+ # 解析统计结果
539
+ stats_map = {s.tx_type: {'count': s.count, 'total': s.total} for s in stats}
540
+
541
+ total_income = stats_map.get('TASK_INCOME', {}).get('total', 0) or 0
542
+ income_count = stats_map.get('TASK_INCOME', {}).get('count', 0) or 0
543
+
544
+ # 任务支出(发布任务的支付)
545
+ total_payment = abs(
546
+ (stats_map.get('TASK_FREEZE', {}).get('total', 0) or 0) +
547
+ (stats_map.get('TASK_DEPOSIT', {}).get('total', 0) or 0) +
548
+ (stats_map.get('TASK_PAYMENT', {}).get('total', 0) or 0)
549
+ )
550
+ payment_count = (
551
+ (stats_map.get('TASK_FREEZE', {}).get('count', 0) or 0) +
552
+ (stats_map.get('TASK_DEPOSIT', {}).get('count', 0) or 0) +
553
+ (stats_map.get('TASK_PAYMENT', {}).get('count', 0) or 0)
554
+ )
555
+
556
+ total_refund = stats_map.get('TASK_REFUND', {}).get('total', 0) or 0
557
+
558
+ # 最近交易(任务相关)
559
+ recent_txs = db.query(Transaction).filter(
560
+ Transaction.account == account,
561
+ Transaction.tx_type.in_(["TASK_INCOME", "TASK_PAYMENT", "TASK_DEPOSIT", "TASK_FREEZE", "TASK_REFUND"])
562
+ ).order_by(Transaction.created_at.desc()).limit(10).all()
563
+
564
+ recent_list = [{
565
+ "tx_id": tx.tx_id,
566
+ "tx_type": tx.tx_type,
567
+ "amount": tx.amount,
568
+ "item_id": tx.item_id,
569
+ "created_at": tx.created_at.isoformat() if tx.created_at else None
570
+ } for tx in recent_txs]
571
+
572
+ return {
573
+ "status": "success",
574
+ "data": {
575
+ "total_income": total_income,
576
+ "income_count": income_count,
577
+ "total_payment": total_payment,
578
+ "payment_count": payment_count,
579
+ "total_refund": total_refund,
580
+ "net_earnings": total_income - total_payment + total_refund,
581
+ "recent_transactions": recent_list
582
+ }
583
+ }
584
+
585
+ # ==========================================
586
+ # 🔄 P7后悔模式:退款API
587
+ # ==========================================
588
+
589
+ @router.get("/api/wallet/{account}/purchase/{item_id}")
590
+ async def get_purchase_status(account: str, item_id: str, db: Session = Depends(get_db)):
591
+ """
592
+ 获取购买状态(用于判断是否可退款)
593
+ """
594
+ ownership = db.query(Ownership).filter(
595
+ Ownership.account == account,
596
+ Ownership.item_id == item_id,
597
+ Ownership.is_refunded == False
598
+ ).first()
599
+
600
+ if not ownership:
601
+ return {"status": "success", "owned": False}
602
+
603
+ # 计算是否在退款窗口内
604
+ purchased_at = ownership.purchased_at
605
+ now = datetime.datetime.utcnow()
606
+ refund_deadline = purchased_at + datetime.timedelta(hours=REFUND_WINDOW_HOURS)
607
+ can_refund = now < refund_deadline
608
+ hours_left = max(0, (refund_deadline - now).total_seconds() / 3600) if can_refund else 0
609
+
610
+ return {
611
+ "status": "success",
612
+ "owned": True,
613
+ "purchased_at": purchased_at.isoformat(),
614
+ "price_paid": ownership.price_paid,
615
+ "can_refund": can_refund,
616
+ "refund_hours_left": round(hours_left, 1)
617
+ }
618
+
619
+ @router.post("/api/wallet/refund")
620
+ @limiter.limit("3/minute") # 🔒 P0安全优化:退款每分钟最多3次
621
+ async def refund_purchase(request: Request, account: str, item_id: str, db: Session = Depends(get_db)):
622
+ """
623
+ 🔄 P7后悔模式:申请退款
624
+ - 24小时内可退款
625
+ - 退款后30天内禁止再次购买
626
+ - 退款后权限回收
627
+ """
628
+ items_db = json_db.load_data("items.json", default_data=[])
629
+ item = next((i for i in items_db if i["id"] == item_id), None)
630
+
631
+ if not item:
632
+ raise HTTPException(status_code=404, detail="商品不存在")
633
+
634
+ # 查找购买记录
635
+ ownership = db.query(Ownership).filter(
636
+ Ownership.account == account,
637
+ Ownership.item_id == item_id,
638
+ Ownership.is_refunded == False
639
+ ).first()
640
+
641
+ if not ownership:
642
+ raise HTTPException(status_code=404, detail="未找到购买记录")
643
+
644
+ # 检查是否在退款窗口内
645
+ purchased_at = ownership.purchased_at
646
+ now = datetime.datetime.utcnow()
647
+ refund_deadline = purchased_at + datetime.timedelta(hours=REFUND_WINDOW_HOURS)
648
+
649
+ if now >= refund_deadline:
650
+ hours_passed = (now - purchased_at).total_seconds() / 3600
651
+ raise HTTPException(status_code=400, detail=f"已超过24小时退款窗口(已购买{hours_passed:.1f}小时)")
652
+
653
+ refund_amount = ownership.price_paid or 0
654
+ seller_account = item.get("author")
655
+
656
+ if refund_amount <= 0:
657
+ raise HTTPException(status_code=400, detail="该商品为免费资源,无需退款")
658
+
659
+ # 🔒 P1并发安全:使用悲观锁+异常处理+事务回滚防止并发问题
660
+ try:
661
+ # 执行退款
662
+ buyer_wallet = db.query(Wallet).filter(Wallet.account == account).with_for_update().first()
663
+ seller_wallet = db.query(Wallet).filter(Wallet.account == seller_account).with_for_update().first()
664
+
665
+ if seller_wallet:
666
+ # 从卖家收益中扣除(如果不足则从余额扣除)
667
+ if seller_wallet.earn_balance >= refund_amount:
668
+ seller_wallet.earn_balance -= refund_amount
669
+ else:
670
+ remaining = refund_amount - seller_wallet.earn_balance
671
+ seller_wallet.earn_balance = 0
672
+ seller_wallet.balance = max(0, seller_wallet.balance - remaining)
673
+
674
+ if buyer_wallet:
675
+ buyer_wallet.balance += refund_amount
676
+ else:
677
+ buyer_wallet = Wallet(account=account, balance=refund_amount)
678
+ db.add(buyer_wallet)
679
+
680
+ # 标记所有权为已退款
681
+ ownership.is_refunded = True
682
+ ownership.refunded_at = now
683
+
684
+ # 创建退款记录(30天禁购)
685
+ ban_until = now + datetime.timedelta(days=REFUND_BAN_DAYS)
686
+ new_refund = Refund(
687
+ account=account,
688
+ item_id=item_id,
689
+ amount=refund_amount,
690
+ ban_until=ban_until
691
+ )
692
+ db.add(new_refund)
693
+
694
+ # 记录退款交易
695
+ tx_id = f"REFUND_{int(time.time())}_{uuid.uuid4().hex[:6]}"
696
+ last_tx = db.query(Transaction).filter(Transaction.account == account).order_by(Transaction.created_at.desc()).first()
697
+ prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
698
+ tx_hash = calculate_tx_hash(tx_id, account, "REFUND", refund_amount, prev_hash)
699
+
700
+ new_tx = Transaction(
701
+ tx_id=tx_id, account=account, tx_type="REFUND", amount=refund_amount,
702
+ related_account=seller_account, item_id=item_id, prev_hash=prev_hash, tx_hash=tx_hash
703
+ )
704
+ db.add(new_tx)
705
+ db.commit()
706
+
707
+ logger.info(f"REFUND | buyer={account} | seller={seller_account} | item={item_id} | amount={refund_amount} | ban_until={ban_until.isoformat()}")
708
+
709
+ return {
710
+ "status": "success",
711
+ "message": f"退款成功,{refund_amount}积分已退还",
712
+ "refund_amount": refund_amount,
713
+ "ban_days": REFUND_BAN_DAYS
714
+ }
715
+ except HTTPException:
716
+ db.rollback()
717
+ raise
718
+ except Exception as e:
719
+ db.rollback()
720
+ logger.error(f"REFUND_ERROR | buyer={account} | item={item_id} | amount={refund_amount} | error={str(e)}")
721
  raise HTTPException(status_code=500, detail="退款处理失败,请稍后重试")