1
This commit is contained in:
@@ -50,6 +50,12 @@
|
||||
<el-table-column prop="order_no" label="订单号" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="user_id" label="用户 ID" width="90" />
|
||||
<el-table-column prop="amount_yuan" label="金额(元)" width="100" />
|
||||
<el-table-column label="USDT 应付" width="110">
|
||||
<template #default="{ row }">{{ row.usdt_amount ? `${row.usdt_amount} USDT` : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="汇率" width="80">
|
||||
<template #default="{ row }">{{ row.usdt_rate ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="points" label="积分" width="90" />
|
||||
<el-table-column label="支付方式" width="100">
|
||||
<template #default="{ row }">{{ PAY_TEXT[row.pay_method] || '-' }}</template>
|
||||
|
||||
@@ -37,6 +37,10 @@ final activeFeedStrategyProvider =
|
||||
/// (否则在"我的"等页面退后台再回来会误恢复声音)
|
||||
final feedTabVisibleProvider = StateProvider<bool>((_) => true);
|
||||
|
||||
/// 游客今日免费额度已用尽(全局):所有 Feed 策略/搜索播放统一锁死播放,
|
||||
/// 直到登录成功才解除——避免切换 Tab 或点「再逛逛」后继续免费观看
|
||||
final guestLockedProvider = StateProvider<bool>((_) => false);
|
||||
|
||||
/// Feed 页 UI 事件(Notifier 不持有 BuildContext,通过事件流驱动弹窗/Toast)
|
||||
sealed class FeedEvent {
|
||||
const FeedEvent();
|
||||
@@ -208,6 +212,11 @@ class FeedNotifier extends StateNotifier<FeedState> {
|
||||
/// 翻页:滑出即暂停、滑入自动播放并上报观看计费(《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<FeedState> {
|
||||
/// 绑定播放窗口 + 观看计费上报
|
||||
Future<void> _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<FeedState> {
|
||||
} 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;
|
||||
|
||||
@@ -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<FeedScreen>
|
||||
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<FeedScreen>
|
||||
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<FeedScreen>
|
||||
}
|
||||
// jumpToPage 后 resume + rebindCurrent:suspend 已 stop 槽位,仅 play
|
||||
// 播不起来,必须 rebind 重新 open(有断点记录则 seek 续播)
|
||||
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<FeedScreen>
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(feedProvider(_strategy));
|
||||
final notifier = ref.read(feedProvider(_strategy).notifier);
|
||||
final guestLocked = ref.watch(guestLockedProvider);
|
||||
|
||||
// 登录成功后解除游客锁并恢复当前视频播放(登录态下改为积分/会员计费)
|
||||
ref.listen<AuthState>(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<FeedScreen>
|
||||
bottom: 0,
|
||||
child: FreeQuotaBanner(remaining: state.freeRemainingToday),
|
||||
),
|
||||
|
||||
// 游客免费额度已用尽:全屏锁(必须登录,点「再逛逛」也无法继续观看)
|
||||
if (guestLocked) const Positioned.fill(child: GuestLockOverlay()),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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<void>(
|
||||
|
||||
@@ -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<SearchFeedScreen>
|
||||
@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<SearchFeedScreen>
|
||||
} 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<SearchFeedScreen>
|
||||
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<AuthState>(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<SearchFeedScreen>
|
||||
bottom: 0,
|
||||
child: FreeQuotaBanner(remaining: state.freeRemainingToday),
|
||||
),
|
||||
|
||||
// 游客免费额度已用尽:全屏锁(必须登录,点「再逛逛」也无法继续观看)
|
||||
if (guestLocked) const Positioned.fill(child: GuestLockOverlay()),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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)}
|
||||
|
||||
|
||||
@@ -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)),
|
||||
]
|
||||
|
||||
+1
-1
@@ -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 # 被推荐人充值积分返利比例→资产
|
||||
|
||||
Reference in New Issue
Block a user