From 074229f4178084b9eee5a21d9f7a7839e737fc2f Mon Sep 17 00:00:00 2001 From: lz_db Date: Sun, 25 Oct 2026 20:30:33 +0800 Subject: [PATCH] 1 --- admin/src/views/OrderList.vue | 6 + app/lib/providers/feed_provider.dart | 13 +++ app/lib/screens/feed/feed_screen.dart | 31 +++-- .../screens/feed/widgets/feed_overlays.dart | 44 +++++++ .../screens/search/search_feed_screen.dart | 20 +++- app/pubspec.yaml | 2 +- server/.env | 3 +- .../payment/__pycache__/usdt.cpython-311.pyc | Bin 1437 -> 1508 bytes server/app/payment/usdt.py | 7 +- .../__pycache__/auth_service.cpython-311.pyc | Bin 26919 -> 27143 bytes .../settle_service.cpython-311.pyc | Bin 11061 -> 17308 bytes server/app/services/auth_service.py | 5 + server/app/services/settle_service.py | 108 +++++++++++++++++- server/config.yaml | 2 +- 14 files changed, 222 insertions(+), 19 deletions(-) diff --git a/admin/src/views/OrderList.vue b/admin/src/views/OrderList.vue index 2701ca4..6182e70 100644 --- a/admin/src/views/OrderList.vue +++ b/admin/src/views/OrderList.vue @@ -50,6 +50,12 @@ + + + + + + diff --git a/app/lib/providers/feed_provider.dart b/app/lib/providers/feed_provider.dart index 0b57c15..3aba4df 100644 --- a/app/lib/providers/feed_provider.dart +++ b/app/lib/providers/feed_provider.dart @@ -37,6 +37,10 @@ final activeFeedStrategyProvider = /// (否则在"我的"等页面退后台再回来会误恢复声音) final feedTabVisibleProvider = StateProvider((_) => true); +/// 游客今日免费额度已用尽(全局):所有 Feed 策略/搜索播放统一锁死播放, +/// 直到登录成功才解除——避免切换 Tab 或点「再逛逛」后继续免费观看 +final guestLockedProvider = StateProvider((_) => false); + /// Feed 页 UI 事件(Notifier 不持有 BuildContext,通过事件流驱动弹窗/Toast) sealed class FeedEvent { const FeedEvent(); @@ -208,6 +212,11 @@ class FeedNotifier extends StateNotifier { /// 翻页:滑出即暂停、滑入自动播放并上报观看计费(《01-开发文档》5.3) void onPageChanged(int index) { if (index < 0 || index >= state.items.length) return; + if (_ref.read(guestLockedProvider)) { + // 游客已锁:只记位置,不绑定播放 + state = state.copyWith(currentIndex: index); + return; + } final item = state.items[index]; // 同一视频重新绑定(冷启动跳页)时保留播放位置,否则新页从 0 开始 final sameVideo = pool.currentVideoId == item.videoId; @@ -245,6 +254,8 @@ class FeedNotifier extends StateNotifier { /// 绑定播放窗口 + 观看计费上报 Future _bindAndReport(int index) async { if (index < 0 || index >= state.items.length) return; + // 游客免费额度已用尽:锁死播放(不绑定、不播放),直到登录成功 + if (_ref.read(guestLockedProvider)) return; final items = state.items; await pool.bindWindow( index, @@ -267,6 +278,8 @@ class FeedNotifier extends StateNotifier { } on ApiException catch (e) { pool.pauseCurrent(); if (e.isGuestLimit) { + // 全局锁:此后任何策略/搜索播放都不再起播,点「再逛逛」也看不了 + _ref.read(guestLockedProvider.notifier).state = true; _eventController.add(const FeedGuestLimitEvent()); } else if (e.isPointsInsufficient) { final points = (e.data?['points'] as num?)?.toInt() ?? 0; diff --git a/app/lib/screens/feed/feed_screen.dart b/app/lib/screens/feed/feed_screen.dart index 5733e65..1093155 100644 --- a/app/lib/screens/feed/feed_screen.dart +++ b/app/lib/screens/feed/feed_screen.dart @@ -8,6 +8,7 @@ import '../../core/log/action_logger.dart'; import '../../core/storage/feed_progress_store.dart'; import '../../core/theme/app_theme.dart'; import '../../models/video_item.dart'; +import '../../providers/auth_provider.dart'; import '../../providers/feed_provider.dart'; import '../../router.dart'; import 'widgets/comment_sheet.dart'; @@ -85,6 +86,8 @@ class _FeedScreenState extends ConsumerState void didPopNext() { if (!mounted) return; if (!ref.read(feedTabVisibleProvider)) return; + // 游客额度已用尽:保持锁定,不恢复播放 + if (ref.read(guestLockedProvider)) return; ActionLogger.I.log('feed_did_pop_next', page: '/home', detail: { 'strategy': _strategy.name, }); @@ -124,8 +127,8 @@ class _FeedScreenState extends ConsumerState notifier.pool.pauseCurrent(); } else if (state == AppLifecycleState.resumed) { _appActive = true; - // 仅在首页 Tab 可见时恢复(在"我的"等页面回前台不播) - if (ref.read(feedTabVisibleProvider)) { + // 仅在首页 Tab 可见时恢复(在"我的"等页面回前台不播);游客锁定时不恢复 + if (ref.read(feedTabVisibleProvider) && !ref.read(guestLockedProvider)) { unawaited(() async { await notifier.pool.resume(); await notifier.rebindCurrent(); @@ -183,11 +186,13 @@ class _FeedScreenState extends ConsumerState } // jumpToPage 后 resume + rebindCurrent:suspend 已 stop 槽位,仅 play // 播不起来,必须 rebind 重新 open(有断点记录则 seek 续播) - unawaited(() async { - final n = ref.read(feedProvider(_strategy).notifier); - await n.pool.resume(); - await n.rebindCurrent(); - }()); + if (!ref.read(guestLockedProvider)) { + unawaited(() async { + final n = ref.read(feedProvider(_strategy).notifier); + await n.pool.resume(); + await n.rebindCurrent(); + }()); + } }); } @@ -208,6 +213,15 @@ class _FeedScreenState extends ConsumerState Widget build(BuildContext context) { final state = ref.watch(feedProvider(_strategy)); final notifier = ref.read(feedProvider(_strategy).notifier); + final guestLocked = ref.watch(guestLockedProvider); + + // 登录成功后解除游客锁并恢复当前视频播放(登录态下改为积分/会员计费) + ref.listen(authProvider, (previous, next) { + if (next.isLoggedIn && ref.read(guestLockedProvider)) { + ref.read(guestLockedProvider.notifier).state = false; + unawaited(ref.read(feedProvider(_strategy).notifier).rebindCurrent()); + } + }); // 冷启动续播:列表加载完成后把 PageView 定位到恢复的下标(每个策略一次)。 // Notifier 会在 refresh 时按上次 videoId 恢复 currentIndex,此处只负责跳页, @@ -304,6 +318,9 @@ class _FeedScreenState extends ConsumerState bottom: 0, child: FreeQuotaBanner(remaining: state.freeRemainingToday), ), + + // 游客免费额度已用尽:全屏锁(必须登录,点「再逛逛」也无法继续观看) + if (guestLocked) const Positioned.fill(child: GuestLockOverlay()), ], ), ); diff --git a/app/lib/screens/feed/widgets/feed_overlays.dart b/app/lib/screens/feed/widgets/feed_overlays.dart index 9193830..74603cf 100644 --- a/app/lib/screens/feed/widgets/feed_overlays.dart +++ b/app/lib/screens/feed/widgets/feed_overlays.dart @@ -59,6 +59,50 @@ void showGuestLimitDialog(BuildContext context) { ); } +/// 游客免费额度用完后的全屏锁:盖住播放区并吞掉手势, +/// 用户点「再逛逛」关闭弹窗后依然无法继续观看(必须登录) +class GuestLockOverlay extends StatelessWidget { + const GuestLockOverlay({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + color: Colors.black.withValues(alpha: 0.92), + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.lock_outline, size: 48, color: AppColors.gold), + const SizedBox(height: 16), + const Text( + '今日免费次数已用完', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: Colors.white), + ), + const SizedBox(height: 8), + const Text( + '注册或登录后继续观看,登录即可解锁全部视频', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, color: AppColors.textSecondary, height: 1.6), + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () => context.go('/login'), + child: const Text('去登录/注册'), + ), + ], + ), + ), + ), + ); + } +} + /// 积分不足:引导充值 / 开通会员(2001) void showPointsInsufficientDialog(BuildContext context, int points) { showDialog( diff --git a/app/lib/screens/search/search_feed_screen.dart b/app/lib/screens/search/search_feed_screen.dart index c54bb07..e15f2ea 100644 --- a/app/lib/screens/search/search_feed_screen.dart +++ b/app/lib/screens/search/search_feed_screen.dart @@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart'; import '../../core/log/action_logger.dart'; import '../../core/theme/app_theme.dart'; import '../../models/video_item.dart'; +import '../../providers/auth_provider.dart'; import '../../providers/feed_provider.dart'; import '../../providers/search_provider.dart'; import '../../router.dart'; @@ -66,6 +67,8 @@ class _SearchFeedScreenState extends ConsumerState @override void didPopNext() { if (!mounted) return; + // 游客额度已用尽:保持锁定,不恢复播放 + if (ref.read(guestLockedProvider)) return; final notifier = ref.read(feedProvider(_strategy).notifier); unawaited(() async { await notifier.pool.resume(); @@ -94,8 +97,9 @@ class _SearchFeedScreenState extends ConsumerState } else if (state == AppLifecycleState.inactive) { notifier.pool.pauseCurrent(); } else if (state == AppLifecycleState.resumed) { - // 本页不可见时(压在登录页/评论页下)不恢复播放 - if (ModalRoute.of(context)?.isCurrent ?? false) { + // 本页不可见时(压在登录页/评论页下)不恢复播放;游客锁定时也不恢复 + if ((ModalRoute.of(context)?.isCurrent ?? false) && + !ref.read(guestLockedProvider)) { unawaited(() async { await notifier.pool.resume(); await notifier.rebindCurrent(); @@ -134,6 +138,15 @@ class _SearchFeedScreenState extends ConsumerState final state = ref.watch(feedProvider(_strategy)); final notifier = ref.read(feedProvider(_strategy).notifier); final keyword = ref.watch(searchProvider).keyword; + final guestLocked = ref.watch(guestLockedProvider); + + // 登录成功后解除游客锁并恢复当前视频 + ref.listen(authProvider, (previous, next) { + if (next.isLoggedIn && ref.read(guestLockedProvider)) { + ref.read(guestLockedProvider.notifier).state = false; + unawaited(ref.read(feedProvider(_strategy).notifier).rebindCurrent()); + } + }); return PopScope( // 系统返回键/手势也要先停搜索池再放行 pop @@ -222,6 +235,9 @@ class _SearchFeedScreenState extends ConsumerState bottom: 0, child: FreeQuotaBanner(remaining: state.freeRemainingToday), ), + + // 游客免费额度已用尽:全屏锁(必须登录,点「再逛逛」也无法继续观看) + if (guestLocked) const Positioned.fill(child: GuestLockOverlay()), ], ), ), diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 7faf3a9..b64737c 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -2,7 +2,7 @@ name: video_app description: "短视频应用(Flutter,iOS/Android 双端)" publish_to: 'none' -version: 1.3.32+48 +version: 1.3.33+49 environment: sdk: ^3.9.2 diff --git a/server/.env b/server/.env index e2b6c15..1c2c242 100644 --- a/server/.env +++ b/server/.env @@ -29,7 +29,8 @@ WECHAT_MCHID= WECHAT_APP_ID= WECHAT_API_V3_KEY= WECHAT_PRIVATE_KEY= -TRON_API_KEY= +# TronGrid API Key(在 trongrid.io 免费申请,用于提高链上查询配额;不是钱包地址,收款地址在 config.yaml 的 recharge.usdt.address) +TRON_API_KEY=85350f6d-1739-4d13-935e-8803b5ca6fa5 # ===== 短信(短信宝)/ 邮件(未配置时验证码走开发模式)===== SMSBAO_USERNAME=followwin diff --git a/server/app/payment/__pycache__/usdt.cpython-311.pyc b/server/app/payment/__pycache__/usdt.cpython-311.pyc index e55d339bcfe9cd43e94db1997c47b7721d34a8db..a13f3de12c11a226ac1a5106ce066a7a99d7d900 100644 GIT binary patch delta 296 zcmV+@0oVSW3*-w8rwt7X00000J;vQ?rI8I*0sE0;Q*GyzvF4nO<%*%_tFPv-k03EM zb0FxwuIR6#AXIX1Zbx!yWFY0PjOK`t3h%s#b7gdNY-L|_WpZ|DV`VOIZ)|K|b#r8N zUu0!)Z*ysMbML&2>6(uqZDDC{E^v7uY-wg?b8um9Amxy+<%+10-)|k{x}@fqljXOv z<(s>2LS}|5C8!VfDM2RfDPD_Q3Vu}Vg);&SeX3) delta 212 zcmV;_04x9G3!MuMrwt7X00000YuwOkUXcw}0kn~1Qz+!Vo#?fe=A4b?ilOJLujsw5 z=&zz6RB~@_M{;Ro@4SfSua6)xHFJ|B0dEiIp|0qzq~?{dAU-`HF*%d40bE|aljVtx z@%iv?LzHg9?W diff --git a/server/app/payment/usdt.py b/server/app/payment/usdt.py index d361d7b..1662726 100644 --- a/server/app/payment/usdt.py +++ b/server/app/payment/usdt.py @@ -1,9 +1,10 @@ """USDT (TRC20) 渠道:唯一金额生成。 -USDT 无支付平台回调,到账依赖服务端轮询 TronGrid(每 15s): -匹配条件 = 收款地址一致 + 金额(含尾差)精确相等 + 转账时间在订单有效期内 + 确认数 >= 19; +USDT 无支付平台回调,到账由服务端每 15s 轮询 TronGrid 完成 +(settle_service.poll_usdt_deposits,随 main.py lifespan 启动): +匹配条件 = 收款地址一致 + 金额(含尾差)精确相等 + 转账时间在订单有效期内 + 交易已固化(≥19 确认); 匹配成功即入账(tx_hash 写入 third_trade_no,唯一索引保证幂等)。 -当前以 Celery worker 部署后实现轮询任务;开发期可用后台"人工补单"入账。 +后台另有"人工补单"入口兜底(链上查询异常时手动核实入账)。 """ import random from decimal import ROUND_HALF_UP, Decimal diff --git a/server/app/services/__pycache__/auth_service.cpython-311.pyc b/server/app/services/__pycache__/auth_service.cpython-311.pyc index 1bd373f283b58fd2ff49f7cb16503e4ce00fbec5..4974ae17842d018e12cd4469989bc4d7d1ed3a20 100644 GIT binary patch delta 270 zcmZ2}iLw0(Bj0jfUM>b8=(=z>>xknj1GB{NGW;{xf)8$(1{ z#cJ5HWG26j)0lkATY@b`9L8lTv7Q{@Ybq`Q5&#(m0-20897`EfBui{3&+*k~l-hj8 zw~k445m2URF^E_IB0_+Kru^j4fF0akApRB*v3{~}U_6s8^X8Jk7ADpYTnyX-lg}hX zGv1y2-%4q-bjV&7#){21BaSdL_HLdRQ_9RZWwS_v5r>rlqd(&WpAQTmdWFOqISA{3 SGK4;%0->>xS(6(wmIDC!b8;1{}`mFBRKZw9N3xqeZ8eu;iTQGQlxa*2LfW=d+lesOA1 zS!$8~=H;x5oEeigdwITOW-2kCe9+faT&#vIO9o^H2xKzWa4cm^5ihZx%fzVjUgvkk+%K<5vN?iZ| diff --git a/server/app/services/__pycache__/settle_service.cpython-311.pyc b/server/app/services/__pycache__/settle_service.cpython-311.pyc index 2ed5cca2c43fae1afc83df48963e5feca9c73623..cee4c4d71b242b8cc663ab08d918cd3dd8b2ed4a 100644 GIT binary patch delta 7487 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 delta 2031 zcmZ`(U2GIp6rMXfv)$j>?smJ~-IjJ+3uTLRL4+2lEyY%>U8`xa(TO@)_D;*RvpeO^ zEG1f~2vK617`Qg&frLI7G~ofw#z%ihFwwVac)-yIV`5_BOB3P?4~_R+Xj6#Z-TTe? zyFcfgd(O2F(N`17XW?)_U}NUD)#=sbWhIGTzueKYAe%KMpXw|5RX-9i!d^2_3aUX) z*O;MFSPgU9XDX$L8sW6xtSv>=Xep+~I3F1VWO<*kO+ioC1xXI?jp*G{N+JY4|gClAyu2tJ`RDWtx#IcK#+Wv^H zWAt_$*Mppwa6RL9Y~d4(@7TgOFn%Xa>RFu9J8=|?lU|%=vR$|%sz0rx>D?R0r#1+I z9ofiCy0ADdWSgp=qka@dZs3IRj#jkFlg1QX7q9zVFVUR}DbtUoJWWg6nhxfOWjVQ7 zVomEsCwJPwx|Oqaa#|-j+9P$z;ChD6NEwu-i_*mQ2F4ROmZSk18MpzhVB5N*6(^@i z3lp@{8kyNwcgjg$5H@fIhvl-(#wyOc&0qc16Y_Gh>; z?6VW7;9S8eo@5SG$_`w8u0Sw(1~d|}=4_H~wBST~atn#3dCrzv1WCJ7v zqL=APD(!t0NR@t?-PkZP1oCU~QoPJb(QfHTF-N5>c+n-|!;FN%UNA|PQ|m={*) zZ8;tl91l}G8ji%GaK5>LRyyjB0<$ruH))J!*dBuLm=?U^!wGLtf-qe}zidf_X1L@A zTXa*T=jvL*n_DpxKR@l)_5@1MTg};^_^xL{SoVA%t%PW+zl|<8C+I6drTHwHM}xv! z{<9+X%!}IQ7K;JqhXSViF9>*ZH7icZfm}Tr^XTz9G!T3Ca%Hzb|7giA5O8ReWEr~N zqG{PW&em?pZICI*vjF?S#IK*3HHfYi90H-^FN~+2bOZZeS>H@0dq^2Dg6z4-vSEXL znM~$kWyJa6+Qa>^W)Z9to19`c@3U$D$>1;cdr$bCPPJ{1e*fZ~{O!>@qd#|!{p26N z>mR4fZG}{9O+a!zhfC;QUHlSS^j-4Z3xuc=+m^T&iZ2zfwqG_rF;;__RVl*@v%22> z4~jttb-kuFHKh|*GRl({MTb(V>mNE()Mp*TDueGEq->kIKAV*Zfo0-knb?fr?m_X{ z71OcdT`=!9{PSz1Qr1k%nkDC$=1TSCp&wDe&q#>FeWiN1H;lYf9+n)5-gs_lBEM=5MBTo0;3BhLVbj*>df(ekYc*2HyCz7omq#U=b89pWTSZMHb9AY{oqp3lxZs8g zv$I{e;1o_2SnK0Ki-D+QVcoR5P8gLnJY}a3r-*0Re9==YfI;LM||HC2SNO0)3QsR~{)I7XFaP z5m4|B1}ShQ)36;^Iu3z_XgtxZANYEiTNP99BTGyRJ;m-|<+4V142;sV1D&<4tHNV{ z`t`tdY8?HjEr?oIde;Q@`C^cNo`eh?d_J4=BJWka(*0@g-R3=O0^|M{cuxKWJy_d4 diff --git a/server/app/services/auth_service.py b/server/app/services/auth_service.py index af2ef12..391c353 100644 --- a/server/app/services/auth_service.py +++ b/server/app/services/auth_service.py @@ -362,6 +362,11 @@ async def login(db: AsyncSession, redis: aioredis.Redis, req, ip: str) -> dict: user.last_login_ip = ip await db.commit() + # 单点登录:新设备登录即踢掉其它设备。 + # 递增令牌纪元使旧 access token 立即失效(deps 每次请求校验 ep), + # 清空 refresh 白名单使旧 refresh 无法刷新;随后签发的新 Token 带新纪元。 + await redis.incr(f"auth:epoch:{user.id}") + await redis.delete(f"auth:refresh:{user.id}") tokens = await _issue_tokens(redis, user.id) return {"user_id": user.id, **tokens, "user": await _user_brief(db, user)} diff --git a/server/app/services/settle_service.py b/server/app/services/settle_service.py index 4cc6b9c..63b77f4 100644 --- a/server/app/services/settle_service.py +++ b/server/app/services/settle_service.py @@ -8,10 +8,11 @@ import logging from datetime import datetime, timedelta from decimal import Decimal +import httpx from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from app.config import biz_config +from app.config import biz_config, settings from app.database import SessionFactory from app.models.billing import RechargeOrder, WatchRecord from app.models.incentive import Referral, VideoEarning @@ -27,6 +28,10 @@ BATCH_SIZE = 500 INTERVAL_REFERRAL = 600 # 推荐激活结算 10min INTERVAL_EARNINGS = 300 # 创作收益结算 5min INTERVAL_ORDER_CLOSE = 60 # 订单超时关单 1min +INTERVAL_USDT_POLL = 15 # USDT 链上流水轮询 15s + +# USDT-TRC20 主网合约地址 +USDT_CONTRACT = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" def _referral_cfg() -> dict: @@ -128,17 +133,111 @@ async def settle_video_earnings(db: AsyncSession) -> dict: # ---------- 订单超时关单 ---------- async def close_expired_orders(db: AsyncSession) -> dict: - """待支付订单超过 expire_at → 已关闭""" + """待支付订单超过 expire_at → 已关闭。 + + USDT 订单额外留 10 分钟宽限:用户在有效期末尾转账时,区块固化可能晚于 + 订单过期时间,若立即关单会导致已付款无法自动入账(只能人工补单)。 + """ + usdt_cfg = (biz_config.get("recharge") or {}).get("usdt") or {} + grace_min = int(usdt_cfg.get("confirm_grace_min", 10)) + now = datetime.now() rows = (await db.execute( select(RechargeOrder) - .where(RechargeOrder.status == 0, RechargeOrder.expire_at < datetime.now()) + .where(RechargeOrder.status == 0, RechargeOrder.expire_at < now) .order_by(RechargeOrder.id) .limit(BATCH_SIZE) )).scalars().all() + closed = 0 for o in rows: + if o.pay_method == 3 and o.expire_at > now - timedelta(minutes=grace_min): + continue # USDT 待确认宽限期 o.status = 2 + closed += 1 await db.commit() - return {"closed": len(rows)} + return {"closed": closed} + + +# ---------- USDT 链上自动对账 ---------- + +async def poll_usdt_deposits(db: AsyncSession) -> dict: + """USDT 自动对账:轮询收款地址的 TRC20 流水,四要素匹配后幂等入账。 + + 匹配条件:to=收款地址 + 金额(含尾差)精确相等 + 转账时间在订单有效期内 + + 交易已固化(only_confirmed,波场固化块即已被 ≥19 个 SR 确认)。 + 入账走 payment_service.settle_order(tx_hash 写 third_trade_no,唯一索引幂等)。 + 双 worker 会同时跑本任务:settle_order 的行锁保证只有一方入账成功。 + """ + from app.services import payment_service + + cfg = (biz_config.get("recharge") or {}).get("usdt") or {} + address = cfg.get("address") or "" + if not address: + return {} + expire_min = int(cfg.get("order_expire_min", 30)) + now = datetime.now() + + # 有效期内的待支付 USDT 订单 + orders = (await db.execute( + select(RechargeOrder).where( + RechargeOrder.pay_method == 3, + RechargeOrder.status == 0, + RechargeOrder.created_at >= now - timedelta(minutes=expire_min), + ) + )).scalars().all() + if not orders: + return {} + + min_ts = int(min(o.created_at for o in orders).timestamp() * 1000) + headers = {"TRON-PRO-API-KEY": settings.tron_api_key} if settings.tron_api_key else {} + async with httpx.AsyncClient(timeout=20) as client: + resp = await client.get( + f"https://api.trongrid.io/v1/accounts/{address}/transactions/trc20", + params={ + "only_to": "true", # 只要转入 + "only_confirmed": "true", # 只要已固化(等价确认数≥19) + "contract_address": USDT_CONTRACT, + "min_timestamp": min_ts, + "limit": 200, + }, + headers=headers, + ) + resp.raise_for_status() + txs = resp.json().get("data") or [] + + matched = 0 + used_txs: set[str] = set() # 同一笔流水只允许命中一个订单(防同金额订单重复入账) + # 先快照订单关键字段:settle_order 失败时 rollback 会让 ORM 对象过期, + # 继续遍历原对象会触发异步懒加载错误 + snapshots = [ + (o.order_no, o.usdt_amount, o.created_at) + for o in orders if o.usdt_amount is not None + ] + for order_no, usdt_amount, created_at in snapshots: + start_ms = created_at.timestamp() * 1000 + end_ms = (created_at + timedelta(minutes=expire_min)).timestamp() * 1000 + for tx in txs: + tx_hash = tx.get("transaction_id") or "" + if not tx_hash or tx_hash in used_txs: + continue + if tx.get("to") != address: + continue + # USDT 6 位小数:链上整数 → Decimal 精确比对(含随机尾差) + if Decimal(tx.get("value") or "0") / Decimal(1_000_000) != usdt_amount: + continue + ts = int(tx.get("block_timestamp") or 0) + if not (start_ms <= ts <= end_ms): + continue + try: + await payment_service.settle_order(db, order_no, tx_hash) + used_txs.add(tx_hash) + matched += 1 + logger.info("USDT 订单 %s 自动到账: tx=%s amount=%s", + order_no, tx_hash, usdt_amount) + except Exception: + await db.rollback() + logger.exception("USDT 订单 %s 入账失败", order_no) + break + return {"usdt_orders": len(snapshots), "matched": matched} if matched else {} # ---------- 周期协程(lifespan 挂载) ---------- @@ -163,4 +262,5 @@ def start_periodic_tasks() -> list[asyncio.Task]: asyncio.create_task(_run_loop("推荐激活结算", settle_referral_activations, INTERVAL_REFERRAL)), asyncio.create_task(_run_loop("创作收益结算", settle_video_earnings, INTERVAL_EARNINGS)), asyncio.create_task(_run_loop("订单超时关单", close_expired_orders, INTERVAL_ORDER_CLOSE)), + asyncio.create_task(_run_loop("USDT 链上对账", poll_usdt_deposits, INTERVAL_USDT_POLL)), ] diff --git a/server/config.yaml b/server/config.yaml index 90a020e..f7591a9 100644 --- a/server/config.yaml +++ b/server/config.yaml @@ -14,7 +14,7 @@ membership: year: { days: 365, points: 3650, enabled: true } lifetime: { days: 0, points: 10000, enabled: true } # days=0 表示永久 recharge: - usdt: { rate: 7.2, network: TRC20, address: "", confirmations: 19, order_expire_min: 30 } + usdt: { rate: 7.2, network: TRC20, address: "TYoufZFGwG8JAUuyGG2xtgjRtkczTXycuG", confirmations: 19, order_expire_min: 30, confirm_grace_min: 10 } referral: register_reward_points: 10 # 邀请有效新用户奖励积分,不限量 rebate_percent: 5 # 被推荐人充值积分返利比例→资产