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 e55d339..a13f3de 100644 Binary files a/server/app/payment/__pycache__/usdt.cpython-311.pyc and b/server/app/payment/__pycache__/usdt.cpython-311.pyc differ 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 1bd373f..4974ae1 100644 Binary files a/server/app/services/__pycache__/auth_service.cpython-311.pyc and b/server/app/services/__pycache__/auth_service.cpython-311.pyc differ diff --git a/server/app/services/__pycache__/settle_service.cpython-311.pyc b/server/app/services/__pycache__/settle_service.cpython-311.pyc index 2ed5cca..cee4c4d 100644 Binary files a/server/app/services/__pycache__/settle_service.cpython-311.pyc and b/server/app/services/__pycache__/settle_service.cpython-311.pyc differ 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 # 被推荐人充值积分返利比例→资产