diff --git a/client/lib/l10n/app_text.dart b/client/lib/l10n/app_text.dart index 0f41e82..1a9e6b9 100644 --- a/client/lib/l10n/app_text.dart +++ b/client/lib/l10n/app_text.dart @@ -47,6 +47,17 @@ abstract class AppText { String get quotaFree; String get watchAd; String get adUnlocked; + // 免费版 10 分钟卡控 + 看广告加时(累加式) + String get quotaLeftLabel; // 连接期倒计时前缀「剩余」 + String get quotaUsedUp; // 额度卡:今日已用完 + String get watchAdMore; // 「看广告加时」CTA + String get quotaExhaustedNotice; // 倒计时归零自动切断提示 + String get adPlaying; // 占位广告:播放中 + String adRewarded(int minutes); // 占位广告:已加 N 分钟 + String get adFailed; // 看广告加时失败 + String get quotaDesktopTitle; // 桌面版额度耗尽弹窗标题 + String get quotaDesktopBody; // 桌面版额度耗尽弹窗正文 + String get gotIt; // 知道了 // ── 节点页 ── String get chooseNode; diff --git a/client/lib/l10n/strings_en.dart b/client/lib/l10n/strings_en.dart index 79a4947..7d97b84 100644 --- a/client/lib/l10n/strings_en.dart +++ b/client/lib/l10n/strings_en.dart @@ -56,6 +56,26 @@ class StringsEn extends AppText { String get watchAd => 'Watch ad to start'; @override String get adUnlocked => 'Unlocked for today'; + @override + String get quotaLeftLabel => 'Left'; + @override + String get quotaUsedUp => 'Daily free time used up'; + @override + String get watchAdMore => 'Watch ad for more time'; + @override + String get quotaExhaustedNotice => 'Daily free time used up. Watch an ad to add time or upgrade.'; + @override + String get adPlaying => 'Ad playing…'; + @override + String adRewarded(int minutes) => '+$minutes min added'; + @override + String get adFailed => 'Failed to add time, please retry'; + @override + String get quotaDesktopTitle => 'Daily free time used up'; + @override + String get quotaDesktopBody => 'Watch ads in the mobile app to add time, or upgrade for unlimited access.'; + @override + String get gotIt => 'Got it'; @override String get chooseNode => 'Choose server'; diff --git a/client/lib/l10n/strings_zh.dart b/client/lib/l10n/strings_zh.dart index df4d680..5081a4e 100644 --- a/client/lib/l10n/strings_zh.dart +++ b/client/lib/l10n/strings_zh.dart @@ -55,6 +55,26 @@ class StringsZh extends AppText { String get watchAd => '看广告开始使用'; @override String get adUnlocked => '已解锁 · 今日可用'; + @override + String get quotaLeftLabel => '剩余'; + @override + String get quotaUsedUp => '今日免费时长已用完'; + @override + String get watchAdMore => '看广告加时'; + @override + String get quotaExhaustedNotice => '今日免费时长已用完,看广告加时或升级会员'; + @override + String get adPlaying => '广告播放中…'; + @override + String adRewarded(int minutes) => '已加 $minutes 分钟'; + @override + String get adFailed => '加时失败,请重试'; + @override + String get quotaDesktopTitle => '今日免费时长已用完'; + @override + String get quotaDesktopBody => '前往移动端 App 看广告加时,或升级会员畅享无限时长。'; + @override + String get gotIt => '知道了'; @override String get chooseNode => '选择节点'; diff --git a/client/lib/models/me.dart b/client/lib/models/me.dart index dc581da..4657f07 100644 --- a/client/lib/models/me.dart +++ b/client/lib/models/me.dart @@ -12,6 +12,7 @@ class Me { this.devicesUsed = 0, this.devicesMax = 1, this.quotaTodayMin, + this.quotaCapMin, this.dataTodayGb = 0, this.weeklyGb = const [], this.totpEnabled = false, @@ -29,9 +30,13 @@ class Me { final int devicesMax; /// 今日**剩余**额度(分钟);null = 不限(pro/team)。 - /// 后端已算好 = 套餐每日上限 − 今日已用。 + /// 后端已算好 = 当日额度 − 今日已用(账户共享,非每设备)。 final int? quotaTodayMin; + /// 今日**总额度**(分钟)= 套餐每日上限 + 看广告累加分钟;null = 不限。 + /// 供进度条分母使用(剩余 / 总额度)。 + final int? quotaCapMin; + /// 今日已用流量(GB)。 final double dataTodayGb; @@ -51,6 +56,7 @@ class Me { devicesUsed: (m['devices_used'] as num?)?.toInt() ?? 0, devicesMax: (m['devices_max'] as num?)?.toInt() ?? 1, quotaTodayMin: (m['quota_today_min'] as num?)?.toInt(), + quotaCapMin: (m['quota_cap_min'] as num?)?.toInt(), dataTodayGb: (m['data_today_gb'] as num?)?.toDouble() ?? 0, weeklyGb: ((m['weekly_gb'] as List?) ?? const []) .map((e) => (e as num).toDouble()) diff --git a/client/lib/screens/connect_page.dart b/client/lib/screens/connect_page.dart index 9974abf..657c928 100644 --- a/client/lib/screens/connect_page.dart +++ b/client/lib/screens/connect_page.dart @@ -11,6 +11,7 @@ import '../state/app_providers.dart'; import '../state/connection_provider.dart'; import '../state/nodes_provider.dart'; import '../state/quota_provider.dart'; +import '../widgets/ad_reward_dialog.dart'; import '../widgets/app_top_bar.dart'; import '../widgets/connect_button.dart'; import '../widgets/country_code.dart'; @@ -52,11 +53,18 @@ class ConnectPage extends ConsumerWidget { VpnPhase.on => t.capOn, }; + // 免费额度耗尽:off 态连接键灰化不可点,点击弹加时(移动看广告 / 桌面升级)。 + final isDesktop = context.formFactor == FormFactor.desktop; + final connectEnabled = !(isFree && quota.isExhausted && conn.phase == VpnPhase.off); + void onQuotaBlockedTap() => showQuotaAdFlow(context, ref, isDesktop: isDesktop); + final button = ConnectButton( phase: conn.phase, elapsed: conn.elapsed, offLabel: t.connectNow, secureLabel: t.secure, + enabled: connectEnabled, + onDisabledTap: onQuotaBlockedTap, onTap: () => ref.read(connectionProvider.notifier).toggle(), ); @@ -75,7 +83,13 @@ class ConnectPage extends ConsumerWidget { final infoChildren = [ if (isFree) - QuotaCard(quota: quota, t: t, onWatchAd: () => ref.read(quotaProvider.notifier).watchAd()), + QuotaCard( + quota: quota, + t: t, + countdown: conn.freeCountdown, + isDesktop: isDesktop, + onWatchAd: onQuotaBlockedTap, + ), if (conn.phase == VpnPhase.on) _SpeedRow(t: t, down: down, up: up, latency: latencyValue), _CurrentNodeCard(t: t, node: node, smart: smart, pingLabel: pingLabel, showLabel: isWide, onTap: onOpenNodes), ]; @@ -100,6 +114,8 @@ class ConnectPage extends ConsumerWidget { offLabel: t.connectNow, secureLabel: t.secure, size: 176, + enabled: connectEnabled, + onDisabledTap: onQuotaBlockedTap, onTap: () => ref.read(connectionProvider.notifier).toggle(), ), const SizedBox(height: 24), @@ -120,7 +136,13 @@ class ConnectPage extends ConsumerWidget { const SizedBox(height: 24), SizedBox( width: 340, - child: QuotaCard(quota: quota, t: t, onWatchAd: () => ref.read(quotaProvider.notifier).watchAd()), + child: QuotaCard( + quota: quota, + t: t, + countdown: conn.freeCountdown, + isDesktop: isDesktop, + onWatchAd: onQuotaBlockedTap, + ), ), ], if (conn.phase == VpnPhase.on) ...[ diff --git a/client/lib/services/account_api.dart b/client/lib/services/account_api.dart index f416fd8..9736e4d 100644 --- a/client/lib/services/account_api.dart +++ b/client/lib/services/account_api.dart @@ -99,7 +99,23 @@ class AccountApi { Future redeem(String code) async => RedeemResult.fromJson(await _c.postJson('/v1/redeem', {'code': code})); - /// POST /v1/ads/unlock — 看广告解锁今日免费额度。 - Future adUnlock({required String deviceId, required String adToken}) => - _c.postJson('/v1/ads/unlock', {'device_id': deviceId, 'ad_token': adToken}); + /// POST /v1/ads/unlock — 看广告加时(累加式)。返回本次加时分钟与最新剩余分钟。 + Future adUnlock({required String deviceId, required String adToken}) async { + final body = await _c.postJson('/v1/ads/unlock', {'device_id': deviceId, 'ad_token': adToken}); + return AdUnlockResult( + grantedMinutes: (body['granted_minutes'] as num?)?.toInt() ?? 0, + minutesRemaining: (body['minutes_remaining'] as num?)?.toInt() ?? 0, + ); + } +} + +/// 看广告加时结果(POST /v1/ads/unlock 响应)。 +class AdUnlockResult { + const AdUnlockResult({required this.grantedMinutes, required this.minutesRemaining}); + + /// 本次广告实际加时分钟(已达每日封顶时为 0)。 + final int grantedMinutes; + + /// 加时后账户当日剩余分钟(全账户共享)。 + final int minutesRemaining; } diff --git a/client/lib/state/connection_provider.dart b/client/lib/state/connection_provider.dart index 3e9f907..560cdf0 100644 --- a/client/lib/state/connection_provider.dart +++ b/client/lib/state/connection_provider.dart @@ -22,6 +22,7 @@ import '../services/device_identity.dart'; import 'app_providers.dart'; import 'auth_provider.dart'; import 'nodes_provider.dart'; +import 'quota_provider.dart'; import 'settings_provider.dart'; // 设备 ID 由 deviceIdentityProvider 提供(secure storage 持久化的稳定 UUID)。 @@ -35,7 +36,12 @@ enum VpnPhase { off, connecting, on } // ── 连接状态快照 ────────────────────────────────────────────────── class ConnectionState { - const ConnectionState({required this.phase, this.elapsed = Duration.zero, this.error}); + const ConnectionState({ + required this.phase, + this.elapsed = Duration.zero, + this.error, + this.freeCountdown, + }); final VpnPhase phase; final Duration elapsed; @@ -43,18 +49,27 @@ class ConnectionState { /// 连接失败/中断原因(已本地化);null = 无错误。供 UI 提示,不再静默吞掉。 final String? error; - ConnectionState copyWith({VpnPhase? phase, Duration? elapsed}) => - ConnectionState(phase: phase ?? this.phase, elapsed: elapsed ?? this.elapsed); + /// 免费版连接期剩余额度(倒计时);null = 不适用(会员/未连接/额度不限)。 + /// 由状态机按「连接时锁定的剩余额度 − 已用时长」本地推算,归零即自动切断。 + final Duration? freeCountdown; + + ConnectionState copyWith({VpnPhase? phase, Duration? elapsed, Duration? freeCountdown}) => + ConnectionState( + phase: phase ?? this.phase, + elapsed: elapsed ?? this.elapsed, + freeCountdown: freeCountdown ?? this.freeCountdown, + ); @override bool operator ==(Object other) => other is ConnectionState && other.phase == phase && other.elapsed == elapsed && - other.error == error; + other.error == error && + other.freeCountdown == freeCountdown; @override - int get hashCode => Object.hash(phase, elapsed, error); + int get hashCode => Object.hash(phase, elapsed, error, freeCountdown); } // ── 连通看门狗 ───────────────────────────────────────────────────── @@ -140,6 +155,9 @@ class ConnectionController extends StateNotifier { ConnectApi? _api; // 实际所连节点(连接时锁定):看门狗探测/判活针对它,而非会随 ping 漂移的 effectiveNode。 Node? _connectedNode; + // 免费版:连接时锁定的剩余额度(秒);连接期按「_freeRemainingSec − 已用秒」倒计时, + // 归零即自动切断(_onFreeQuotaExhausted)。null = 会员/额度不限,不倒计时。 + int? _freeRemainingSec; // ── 公有 API ─────────────────────────────────────────────────── @@ -170,6 +188,10 @@ class ConnectionController extends StateNotifier { _userDisconnect = false; _lastUrltestOk = null; // 重置 urltest 判活基准(连上后由 _onStats 首次成功置位) _lastStatsAt = null; // 重置 stats 在流基准(连上后由 _onStats 首帧置位) + // 免费版:锁定本次连接可用的剩余额度(账户共享,权威取自 me)。会员为 null 不倒计时。 + _freeRemainingSec = _ref.read(isFreePlanProvider) + ? _ref.read(quotaProvider).remainingMinutes * 60 + : null; state = const ConnectionState(phase: VpnPhase.connecting); final node = _ref.read(effectiveNodeProvider); @@ -202,6 +224,12 @@ class ConnectionController extends StateNotifier { if (mounted) state = const ConnectionState(phase: VpnPhase.off); return; } + // 免费额度已用完(服务端兜底):本地置耗尽 → 按钮灰化、点击弹广告/升级。回 off。 + if (e.code == 'QUOTA_EXHAUSTED') { + _ref.read(quotaProvider.notifier).markExhausted(); + if (mounted) state = ConnectionState(phase: VpnPhase.off, error: zh ? e.messageZh : e.messageEn); + return; + } // 把后端/网络错误冒泡到 UI(原静默回 off,用户不知所以)。 if (mounted) state = ConnectionState(phase: VpnPhase.off, error: zh ? e.messageZh : e.messageEn); } catch (e) { @@ -397,18 +425,41 @@ class ConnectionController extends StateNotifier { _elapsed = Timer.periodic(const Duration(seconds: 1), (_) => _refreshElapsed()); } - /// 按墙上时钟把 elapsed 刷成 now - _connectedAt(切后台回来也准)。 + /// 按墙上时钟把 elapsed 刷成 now - _connectedAt(切后台回来也准);免费版顺带推算 + /// 倒计时,归零即自动切断。倒计时用墙上时钟,后台/锁屏漏跳也会在回前台补上、准时切。 void _refreshElapsed() { final at = _connectedAt; - if (mounted && state.phase == VpnPhase.on && at != null) { - state = state.copyWith(elapsed: _now().difference(at)); + if (!mounted || state.phase != VpnPhase.on || at == null) return; + final elapsed = _now().difference(at); + + Duration? countdown; + final capSec = _freeRemainingSec; + if (capSec != null) { + final leftSec = capSec - elapsed.inSeconds; + if (leftSec <= 0) { + unawaited(_onFreeQuotaExhausted()); + return; + } + countdown = Duration(seconds: leftSec); } + state = ConnectionState(phase: VpnPhase.on, elapsed: elapsed, freeCountdown: countdown); + } + + /// 免费额度耗尽:主动切断隧道(不报节点异常),本地置耗尽让按钮灰化,并拉 me 校准。 + Future _onFreeQuotaExhausted() async { + _freeRemainingSec = null; + _userDisconnect = true; // 视为主动断开,不触发「节点异常」 + _offNotice = _ref.read(appTextProvider).quotaExhaustedNotice; + _ref.read(quotaProvider.notifier).markExhausted(); + logLine('Quota', 'free daily minutes used up → auto disconnect'); + await _disconnect(); } void _stopElapsed() { _elapsed?.cancel(); _elapsed = null; _connectedAt = null; + _freeRemainingSec = null; } @override diff --git a/client/lib/state/quota_provider.dart b/client/lib/state/quota_provider.dart index fe2ab24..8adf924 100644 --- a/client/lib/state/quota_provider.dart +++ b/client/lib/state/quota_provider.dart @@ -1,30 +1,32 @@ // quota_provider.dart — 免费版每日额度状态(Riverpod) // -// 设计约定(design/CLAUDE.md §7 / §2):免费额度权威在服务端。 -// 总额度取自 plans 的 free.daily_minutes,今日剩余取自 me.quota_today_min -// (后端已算好 = 上限 − 今日已用)。adUnlocked 为本地会话态(看广告需 ad SDK, -// 尚未接入,保留本地乐观置位)。 +// 设计约定(design/CLAUDE.md §7 / §2):免费额度权威在服务端,且**全账户共享** +// (非每设备)。总额度取自 me.quota_cap_min(= 套餐每日上限 + 看广告累加分钟), +// 今日剩余取自 me.quota_today_min(后端已算好 = 额度 − 今日已用)。 +// +// 看广告加时(累加式):watchAd() 调 /v1/ads/unlock,服务端校验后 +N 分钟并回传最新 +// 剩余,客户端随即刷新 me 让额度权威同步。占位广告 SDK 阶段用客户端生成的 ad_token, +// 服务端 DevVerifier 放行(nonce 仍防重放)。 import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:uuid/uuid.dart'; +import '../services/device_identity.dart'; import 'account_providers.dart'; +import 'auth_provider.dart'; /// 免费额度快照。 class FreeQuotaState { const FreeQuotaState({ this.totalMinutes = 10, this.remainingMinutes = 10, - this.adUnlocked = false, }); - /// 每日总额度(分钟)。§7:免费版每日 10 分钟。 + /// 今日总额度(分钟)= 套餐每日上限 + 看广告累加。§7:免费版基础每日 10 分钟。 final int totalMinutes; - /// 今日剩余分钟(展示值,权威以服务端为准)。 + /// 今日剩余分钟(权威以服务端为准;连接期倒计时由连接状态机本地推算)。 final int remainingMinutes; - /// 今日是否已观看激励视频解锁。 - final bool adUnlocked; - /// 进度(0–1),用于进度条宽度。 double get progress => totalMinutes == 0 ? 0 : (remainingMinutes / totalMinutes).clamp(0.0, 1.0); @@ -32,11 +34,12 @@ class FreeQuotaState { /// 是否进入低额度警示(≤3 分钟切 warning 色)。 bool get isLow => remainingMinutes <= 3; - FreeQuotaState copyWith({int? totalMinutes, int? remainingMinutes, bool? adUnlocked}) => - FreeQuotaState( + /// 今日额度是否已耗尽(剩余 0):连接按钮据此灰化,点击弹广告/升级。 + bool get isExhausted => remainingMinutes <= 0; + + FreeQuotaState copyWith({int? totalMinutes, int? remainingMinutes}) => FreeQuotaState( totalMinutes: totalMinutes ?? this.totalMinutes, remainingMinutes: remainingMinutes ?? this.remainingMinutes, - adUnlocked: adUnlocked ?? this.adUnlocked, ); } @@ -53,18 +56,43 @@ class QuotaController extends StateNotifier { void _sync() { final me = _ref.read(meProvider).valueOrNull; final plans = _ref.read(plansProvider).valueOrNull; - var total = 10; // §7 默认免费 10 分钟,plans 就绪后以其为准 + var base = 10; // §7 默认免费基础 10 分钟,plans 就绪后以其为准 if (plans != null) { for (final p in plans) { - if (p.code == 'free' && p.dailyMinutes != null) total = p.dailyMinutes!; + if (p.code == 'free' && p.dailyMinutes != null) base = p.dailyMinutes!; } } + // 总额度优先取服务端 quota_cap_min(含看广告加时);缺省回退基础额度。 + final total = me?.quotaCapMin ?? base; final remaining = (me?.quotaTodayMin ?? total).clamp(0, total); - state = state.copyWith(totalMinutes: total, remainingMinutes: remaining); + state = FreeQuotaState(totalMinutes: total, remainingMinutes: remaining); } - /// 观看激励视频后解锁今日使用(本地乐观;真实 ad 校验待 ad SDK 接入)。 - void watchAd() => state = state.copyWith(adUnlocked: true); + /// 连接期倒计时归零 → 本地立即置耗尽(按钮随即灰化);登录态下再拉 me 让服务端权威同步。 + void markExhausted() { + state = state.copyWith(remainingMinutes: 0); + if (_ref.read(authProvider).isLoggedIn) { + _ref.read(meProvider.notifier).refresh(); + } + } + + /// 看广告加时:调 /v1/ads/unlock 累加分钟,成功后刷新 me 拿最新额度。 + /// 返回本次加时分钟(null = 失败)。占位阶段用客户端生成的 ad_token。 + Future watchAd() async { + try { + final deviceId = await _ref.read(deviceIdentityProvider).deviceId(); + final res = await _ref.read(accountApiProvider).adUnlock( + deviceId: deviceId, + adToken: const Uuid().v4(), // 占位 ad_token(DevVerifier 放行) + ); + // 乐观置位剩余,再拉 me 校准(账户共享,以服务端为准)。 + state = state.copyWith(remainingMinutes: res.minutesRemaining); + await _ref.read(meProvider.notifier).refresh(); + return res.grantedMinutes; + } catch (_) { + return null; + } + } } final quotaProvider = StateNotifierProvider( diff --git a/client/lib/widgets/ad_reward_dialog.dart b/client/lib/widgets/ad_reward_dialog.dart new file mode 100644 index 0000000..3581ac3 --- /dev/null +++ b/client/lib/widgets/ad_reward_dialog.dart @@ -0,0 +1,156 @@ +// ad_reward_dialog.dart — 免费额度耗尽后的「加时」入口(占位广告流程 + 桌面升级提示) +// +// 移动端:弹占位广告(「广告播放中…」→ 3s 假播放 → 调 /v1/ads/unlock 加时 → 显示奖励)。 +// 桌面端(Windows/macOS):免费版硬 10 分钟/天不可延,无广告——弹「去移动端看广告或升级」。 +// 接真广告 SDK(AdMob 激励视频)时,只需把 onWatch 换成「真播完再回调」即可,UI 不变。 +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../l10n/app_text.dart'; +import '../pangolin_theme.dart'; +import '../state/app_providers.dart'; +import '../state/quota_provider.dart'; +import 'pangolin_icons.dart'; + +/// 展示加时流程。isDesktop=true 走升级提示(无广告),否则走占位广告加时。 +Future showQuotaAdFlow(BuildContext context, WidgetRef ref, {required bool isDesktop}) async { + final t = ref.read(appTextProvider); + if (isDesktop) { + await showDialog(context: context, builder: (_) => _DesktopUpgradeDialog(t: t)); + return; + } + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => _PlaceholderAdDialog( + t: t, + onWatch: () => ref.read(quotaProvider.notifier).watchAd(), + ), + ); +} + +/// 桌面版:免费硬 10 分钟不可延,提示去移动端加时或升级会员。 +class _DesktopUpgradeDialog extends StatelessWidget { + const _DesktopUpgradeDialog({required this.t}); + final AppText t; + + @override + Widget build(BuildContext context) { + final c = context.pangolin; + return AlertDialog( + backgroundColor: c.surface, + title: Text(t.quotaDesktopTitle, + style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w700)), + content: Text(t.quotaDesktopBody, style: PangolinText.sm.copyWith(color: c.fg2)), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(t.gotIt, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w700)), + ), + ], + ); + } +} + +enum _AdPhase { playing, done, failed } + +/// 移动端占位激励广告:播放中 → 加时成功/失败。成功后短暂展示奖励再自动关闭。 +class _PlaceholderAdDialog extends StatefulWidget { + const _PlaceholderAdDialog({required this.t, required this.onWatch}); + final AppText t; + final Future Function() onWatch; + + @override + State<_PlaceholderAdDialog> createState() => _PlaceholderAdDialogState(); +} + +class _PlaceholderAdDialogState extends State<_PlaceholderAdDialog> { + _AdPhase _phase = _AdPhase.playing; + int _granted = 0; + Timer? _closeTimer; + + @override + void initState() { + super.initState(); + _run(); + } + + Future _run() async { + // 占位「播放」3s(接真 SDK 后由激励视频完成回调替代)。 + await Future.delayed(const Duration(seconds: 3)); + if (!mounted) return; + final granted = await widget.onWatch(); + if (!mounted) return; + setState(() { + if (granted != null && granted > 0) { + _phase = _AdPhase.done; + _granted = granted; + } else { + _phase = _AdPhase.failed; + } + }); + if (_phase == _AdPhase.done) { + _closeTimer = Timer(const Duration(milliseconds: 1300), () { + if (mounted) Navigator.of(context).pop(); + }); + } + } + + @override + void dispose() { + _closeTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final c = context.pangolin; + final t = widget.t; + + Widget body; + switch (_phase) { + case _AdPhase.playing: + body = Column(mainAxisSize: MainAxisSize.min, children: [ + SizedBox( + width: 34, + height: 34, + child: CircularProgressIndicator(color: c.accent, strokeWidth: 3), + ), + const SizedBox(height: 16), + Text(t.adPlaying, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w600)), + ]); + case _AdPhase.done: + body = Column(mainAxisSize: MainAxisSize.min, children: [ + Icon(PangolinIcons.checkCircle, size: 40, color: c.success), + const SizedBox(height: 14), + Text(t.adRewarded(_granted), + style: PangolinText.body.copyWith(color: c.success, fontWeight: FontWeight.w700)), + ]); + case _AdPhase.failed: + body = Column(mainAxisSize: MainAxisSize.min, children: [ + Icon(PangolinIcons.alertTriangle, size: 36, color: c.danger), + const SizedBox(height: 14), + Text(t.adFailed, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w600)), + const SizedBox(height: 14), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(t.gotIt, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w700)), + ), + ]); + } + + return Dialog( + backgroundColor: c.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.lg)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 28), + child: AnimatedSize( + duration: PangolinMotion.base, + child: body, + ), + ), + ); + } +} diff --git a/client/lib/widgets/connect_button.dart b/client/lib/widgets/connect_button.dart index d2de8d6..20abc9d 100644 --- a/client/lib/widgets/connect_button.dart +++ b/client/lib/widgets/connect_button.dart @@ -24,11 +24,19 @@ class ConnectButton extends StatefulWidget { required this.secureLabel, this.elapsed = Duration.zero, this.size = 208, + this.enabled = true, + this.onDisabledTap, }); final VpnPhase phase; final VoidCallback onTap; + /// 是否可点。免费额度耗尽时置 false → 灰化不可连,点击走 onDisabledTap。 + final bool enabled; + + /// 灰化态被点击的回调(如弹看广告加时/升级)。enabled=false 时生效。 + final VoidCallback? onDisabledTap; + /// off 态圆内文字(如「点击连接」/「CONNECT」)。 final String offLabel; @@ -55,18 +63,26 @@ class _ConnectButtonState extends State with SingleTickerProvider Widget build(BuildContext context) { final c = context.pangolin; final s = widget.phase; + // 免费额度耗尽:off 态灰化不可连(锁图标 + 柔和阴影),点击走 onDisabledTap。 + final disabled = !widget.enabled && s == VpnPhase.off; - final Color fill = switch (s) { - VpnPhase.off => c.bgSubtle, - VpnPhase.connecting => c.accent, - VpnPhase.on => c.success, - }; - final Color fg = s == VpnPhase.off ? c.accent : PangolinColors.white; - final IconData icon = switch (s) { - VpnPhase.off => PangolinIcons.power, - VpnPhase.connecting => PangolinIcons.loader, - VpnPhase.on => PangolinIcons.shieldCheck, - }; + final Color fill = disabled + ? c.bgSubtle + : switch (s) { + VpnPhase.off => c.bgSubtle, + VpnPhase.connecting => c.accent, + VpnPhase.on => c.success, + }; + final Color fg = disabled + ? c.fg3 + : (s == VpnPhase.off ? c.accent : PangolinColors.white); + final IconData icon = disabled + ? PangolinIcons.lock + : switch (s) { + VpnPhase.off => PangolinIcons.power, + VpnPhase.connecting => PangolinIcons.loader, + VpnPhase.on => PangolinIcons.shieldCheck, + }; // off 用柔和阴影;connecting/on 增加同色光晕环(box-shadow,不动背景计算)。 final List glow = s == VpnPhase.off @@ -84,7 +100,7 @@ class _ConnectButtonState extends State with SingleTickerProvider button: true, label: widget.offLabel, child: GestureDetector( - onTap: widget.onTap, + onTap: disabled ? widget.onDisabledTap : widget.onTap, child: AnimatedContainer( duration: PangolinMotion.slow, curve: PangolinMotion.easeOut, diff --git a/client/lib/widgets/quota_card.dart b/client/lib/widgets/quota_card.dart index 4e908f2..83f008f 100644 --- a/client/lib/widgets/quota_card.dart +++ b/client/lib/widgets/quota_card.dart @@ -1,7 +1,10 @@ -// quota_card.dart — 免费版每日额度卡(纯展示) +// quota_card.dart — 免费版每日额度卡 // -// 今日剩余分钟 + 进度条(≤3 分钟切 warning 色)+「看广告开始使用」; -// 解锁后变绿「已解锁 · 今日可用」。状态由 quota_provider 注入,本地仅展示。 +// 三态展示(额度全账户共享): +// ① 连接中(countdown != null):显示剩余倒计时 mm:ss + 进度条随之收缩。 +// ② 未连接·有余额:显示今日剩余分钟 + 进度条;移动端附「看广告加时」。 +// ③ 未连接·已耗尽:显示「今日已用完」+ 移动端「看广告加时」/ 桌面「升级会员」。 +// 状态由 quota_provider(剩余)+ connection_provider(倒计时)注入,本地仅展示。 import 'package:flutter/material.dart'; import '../l10n/app_text.dart'; @@ -10,16 +13,55 @@ import '../state/quota_provider.dart'; import 'pangolin_icons.dart'; class QuotaCard extends StatelessWidget { - const QuotaCard({super.key, required this.quota, required this.t, required this.onWatchAd}); + const QuotaCard({ + super.key, + required this.quota, + required this.t, + required this.onWatchAd, + this.countdown, + this.isDesktop = false, + }); final FreeQuotaState quota; final AppText t; + + /// 额度耗尽时的加时入口(移动端占位广告 / 桌面升级提示)。 final VoidCallback onWatchAd; + /// 连接期剩余倒计时;null = 未连接(展示今日剩余分钟)。 + final Duration? countdown; + + /// 桌面端(Windows/macOS):免费硬 10 分钟不可延,不显示看广告按钮。 + final bool isDesktop; + + static String _mmss(Duration d) { + final m = d.inMinutes.remainder(60).toString().padLeft(2, '0'); + final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$m:$s'; + } + @override Widget build(BuildContext context) { final c = context.pangolin; - final barColor = quota.isLow ? c.warning : c.accent; + final connected = countdown != null; + final exhausted = quota.isExhausted && !connected; + + // 进度与主值:连接期用倒计时,未连接用剩余分钟。 + final double progress; + final String label; + final String value; + if (connected) { + final totalSec = quota.totalMinutes * 60; + progress = totalSec == 0 ? 0 : (countdown!.inSeconds / totalSec).clamp(0.0, 1.0); + label = t.quotaLeftLabel; + value = _mmss(countdown!); + } else { + progress = quota.progress; + label = exhausted ? t.quotaUsedUp : t.quotaToday; + value = exhausted ? '' : '${quota.remainingMinutes} ${t.minutes}'; + } + final low = connected ? countdown!.inMinutes < 3 : quota.isLow; + final barColor = exhausted ? c.danger : (low ? c.warning : c.accent); return Container( padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), @@ -33,13 +75,18 @@ class QuotaCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row(children: [ - Icon(PangolinIcons.clock, size: 15, color: c.accent), + Icon(PangolinIcons.clock, size: 15, color: exhausted ? c.danger : c.accent), const SizedBox(width: 7), - Text(t.quotaToday, - style: PangolinText.caption.copyWith(color: c.fg2, fontWeight: FontWeight.w600, fontSize: 12)), - const SizedBox(width: 7), - Text('${quota.remainingMinutes} ${t.minutes}', - style: PangolinText.mono.copyWith(color: c.fg1, fontSize: 14, fontWeight: FontWeight.w600)), + Flexible( + child: Text(label, + overflow: TextOverflow.ellipsis, + style: PangolinText.caption.copyWith(color: c.fg2, fontWeight: FontWeight.w600, fontSize: 12)), + ), + if (value.isNotEmpty) ...[ + const SizedBox(width: 7), + Text(value, + style: PangolinText.mono.copyWith(color: c.fg1, fontSize: 14, fontWeight: FontWeight.w600)), + ], const Spacer(), Container( padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3), @@ -49,7 +96,7 @@ class QuotaCard extends StatelessWidget { ), ]), const SizedBox(height: 9), - // 进度条:剩余比例;≤3 分钟切 warning 色(满宽轨道 + 比例填充) + // 进度条:剩余比例;≤3 分钟 warning、耗尽 danger(满宽轨道 + 比例填充)。 ClipRRect( borderRadius: BorderRadius.circular(3), child: SizedBox( @@ -58,7 +105,7 @@ class QuotaCard extends StatelessWidget { Positioned.fill(child: ColoredBox(color: c.bgSubtle)), FractionallySizedBox( alignment: Alignment.centerLeft, - widthFactor: quota.progress, + widthFactor: progress, child: AnimatedContainer( duration: PangolinMotion.base, curve: PangolinMotion.easeOut, @@ -68,30 +115,25 @@ class QuotaCard extends StatelessWidget { ]), ), ), - const SizedBox(height: 11), - if (quota.adUnlocked) - SizedBox( - height: 38, - child: Center( - child: Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(PangolinIcons.checkCircle, size: 16, color: c.success), - const SizedBox(width: 7), - Text(t.adUnlocked, - style: PangolinText.sm.copyWith(color: c.success, fontWeight: FontWeight.w700, fontSize: 13)), - ]), - ), - ) - else - _WatchAdButton(t: t, onTap: onWatchAd), + // 加时按钮:未连接时显示。移动端「看广告加时」;桌面仅耗尽时给「升级会员」。 + if (!connected && (!isDesktop || exhausted)) ...[ + const SizedBox(height: 11), + _ActionButton( + label: isDesktop ? t.upgrade : t.watchAdMore, + icon: isDesktop ? PangolinIcons.zap : PangolinIcons.playCircle, + onTap: onWatchAd, + ), + ], ], ), ); } } -class _WatchAdButton extends StatelessWidget { - const _WatchAdButton({required this.t, required this.onTap}); - final AppText t; +class _ActionButton extends StatelessWidget { + const _ActionButton({required this.label, required this.icon, required this.onTap}); + final String label; + final IconData icon; final VoidCallback onTap; @override @@ -107,9 +149,9 @@ class _WatchAdButton extends StatelessWidget { height: 38, child: Center( child: Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(PangolinIcons.playCircle, size: 16, color: c.accent), + Icon(icon, size: 16, color: c.accent), const SizedBox(width: 7), - Text(t.watchAd, + Text(label, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w700, fontSize: 13)), ]), ), diff --git a/client/test/contract/api_contract_test.dart b/client/test/contract/api_contract_test.dart index da6dbf1..c85921a 100644 --- a/client/test/contract/api_contract_test.dart +++ b/client/test/contract/api_contract_test.dart @@ -20,6 +20,7 @@ const _frozenMeKeys = { 'devices_used', 'devices_max', 'quota_today_min', + 'quota_cap_min', 'data_today_gb', 'weekly_gb', 'totp_enabled', @@ -37,6 +38,7 @@ Map _meSample() => { 'devices_used': 2, 'devices_max': 5, 'quota_today_min': null, // pro/team 不限 → null + 'quota_cap_min': null, // pro/team 不限 → null 'data_today_gb': 1.5, 'weekly_gb': [0.1, 0.2, 0.0, 1.0, 2.0, 0.5, 1.5], 'totp_enabled': true, diff --git a/client/test/golden/components_golden_test.dart b/client/test/golden/components_golden_test.dart index d06598a..016663c 100644 --- a/client/test/golden/components_golden_test.dart +++ b/client/test/golden/components_golden_test.dart @@ -76,12 +76,12 @@ void main() { ); }); - testWidgets('额度卡(已解锁)· $suffix', (tester) async { + testWidgets('额度卡(已耗尽)· $suffix', (tester) async { await goldenOf( tester, - QuotaCard(quota: const FreeQuotaState(adUnlocked: true), t: t, onWatchAd: () {}), + QuotaCard(quota: const FreeQuotaState(remainingMinutes: 0), t: t, onWatchAd: () {}), find.byType(QuotaCard), - 'quota_unlocked_$suffix', + 'quota_exhausted_$suffix', dark: dark, ); }); diff --git a/client/test/golden/goldens/quota_exhausted_dark.png b/client/test/golden/goldens/quota_exhausted_dark.png new file mode 100644 index 0000000..9c3f978 Binary files /dev/null and b/client/test/golden/goldens/quota_exhausted_dark.png differ diff --git a/client/test/golden/goldens/quota_exhausted_light.png b/client/test/golden/goldens/quota_exhausted_light.png new file mode 100644 index 0000000..62243a6 Binary files /dev/null and b/client/test/golden/goldens/quota_exhausted_light.png differ diff --git a/client/test/golden/goldens/quota_low_dark.png b/client/test/golden/goldens/quota_low_dark.png index 2130fc2..32b77a0 100644 Binary files a/client/test/golden/goldens/quota_low_dark.png and b/client/test/golden/goldens/quota_low_dark.png differ diff --git a/client/test/golden/goldens/quota_low_light.png b/client/test/golden/goldens/quota_low_light.png index 81df106..43243da 100644 Binary files a/client/test/golden/goldens/quota_low_light.png and b/client/test/golden/goldens/quota_low_light.png differ diff --git a/client/test/unit/connection_watchdog_test.dart b/client/test/unit/connection_watchdog_test.dart index 7485082..e81556d 100644 --- a/client/test/unit/connection_watchdog_test.dart +++ b/client/test/unit/connection_watchdog_test.dart @@ -13,6 +13,7 @@ import 'package:pangolin_vpn/models/node.dart'; import 'package:pangolin_vpn/services/connect_api.dart'; import 'package:pangolin_vpn/state/connection_provider.dart'; import 'package:pangolin_vpn/state/nodes_provider.dart'; +import 'package:pangolin_vpn/state/quota_provider.dart'; // 可控假桥:能手动推 VpnStatus,无内部计时器(避免 pending timer)。 class _FakeBridge implements VpnBridge { @@ -279,4 +280,36 @@ void main() { bridge.emit(VpnStatus.off); // 收尾:停看门狗/计时器 await tester.pump(); }); + + // 免费版 10 分钟卡控:连接期倒计时,墙上时钟越过额度即自动切断 + 本地置耗尽(#21)。 + testWidgets('免费额度倒计时归零 → 自动切断 + 置耗尽', (tester) async { + final bridge = _FakeBridge(); + var fake = DateTime(2026, 7, 1, 12); + final c = makeContainer(bridge, now: () => fake); // 未登录默认免费,剩余 10 分钟 + addTearDown(bridge.dispose); + addTearDown(c.dispose); + await tester.pumpWidget(UncontrolledProviderScope(container: c, child: const SizedBox())); + c.read(nodesProvider); + await tester.pump(); + + // 免费默认剩余 10 分钟 → toggle 触发 _connect 锁定 600s 倒计时(fake api 抛错不影响锁定)。 + expect(c.read(quotaProvider).remainingMinutes, 10); + c.read(connectionProvider.notifier).toggle(); + await tester.pump(); + bridge.emit(VpnStatus.on); // _startElapsed:_connectedAt = fake(T0) + await tester.pump(); + await tester.pump(); + expect(c.read(connectionProvider).phase, VpnPhase.on); + expect(c.read(connectionProvider).freeCountdown, isNotNull, reason: '连接期应有倒计时'); + + // 快进墙上时钟越过 10 分钟 → 下一个 1s tick 触发 _refreshElapsed → 切断。 + fake = fake.add(const Duration(seconds: 601)); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(); + await tester.pump(); + + expect(c.read(connectionProvider).phase, VpnPhase.off, reason: '额度用完应自动切断'); + expect(c.read(connectionProvider).error, t.quotaExhaustedNotice, reason: '应给「已用完」提示'); + expect(c.read(quotaProvider).isExhausted, true, reason: '切断后本地置耗尽 → 按钮灰化'); + }); } diff --git a/client/test/unit/quota_controller_test.dart b/client/test/unit/quota_controller_test.dart index e0da4b7..b83aa39 100644 --- a/client/test/unit/quota_controller_test.dart +++ b/client/test/unit/quota_controller_test.dart @@ -41,24 +41,29 @@ void main() { expect(const FreeQuotaState(remainingMinutes: 3).isLow, true); expect(const FreeQuotaState(remainingMinutes: 4).isLow, false); }); + test('isExhausted:剩余 ≤0 为真', () { + expect(const FreeQuotaState(remainingMinutes: 0).isExhausted, true); + expect(const FreeQuotaState(remainingMinutes: 1).isExhausted, false); + }); }); - // 未登录默认态:总额 10 / 剩余 10 / 未解锁;watchAd 本地置位。 + // 未登录默认态:总额 10 / 剩余 10 / 未耗尽。 group('quotaProvider', () { - test('默认 10/10 未解锁', () { + test('默认 10/10 未耗尽', () { final c = _container(); addTearDown(c.dispose); final q = c.read(quotaProvider); expect(q.totalMinutes, 10); expect(q.remainingMinutes, 10); - expect(q.adUnlocked, false); + expect(q.isExhausted, false); }); - test('watchAd 解锁今日使用', () { + test('markExhausted 本地立即置剩余 0', () { final c = _container(); addTearDown(c.dispose); - c.read(quotaProvider.notifier).watchAd(); - expect(c.read(quotaProvider).adUnlocked, true); + c.read(quotaProvider.notifier).markExhausted(); + expect(c.read(quotaProvider).remainingMinutes, 0); + expect(c.read(quotaProvider).isExhausted, true); }); }); } diff --git a/client/test/widget/cards_test.dart b/client/test/widget/cards_test.dart index bb8d0fc..59c0fea 100644 --- a/client/test/widget/cards_test.dart +++ b/client/test/widget/cards_test.dart @@ -12,24 +12,32 @@ void main() { setUpAll(disableGoogleFontsFetching); const t = StringsZh(); - testWidgets('额度卡:未解锁显示看广告按钮,点击回调', (tester) async { + testWidgets('额度卡(移动·有余额):显示看广告加时按钮,点击回调', (tester) async { var watched = 0; await tester.pumpWidget(wrapThemed( QuotaCard(quota: const FreeQuotaState(), t: t, onWatchAd: () => watched++), )); await tester.pump(); - expect(find.text(t.watchAd), findsOneWidget); - await tester.tap(find.text(t.watchAd)); + expect(find.text(t.watchAdMore), findsOneWidget); + await tester.tap(find.text(t.watchAdMore)); expect(watched, 1); }); - testWidgets('额度卡:已解锁显示已解锁文案', (tester) async { + testWidgets('额度卡(耗尽):显示今日已用完 + 看广告加时', (tester) async { await tester.pumpWidget(wrapThemed( - QuotaCard(quota: const FreeQuotaState(adUnlocked: true), t: t, onWatchAd: () {}), + QuotaCard(quota: const FreeQuotaState(remainingMinutes: 0), t: t, onWatchAd: () {}), )); await tester.pump(); - expect(find.text(t.adUnlocked), findsOneWidget); - expect(find.byIcon(PangolinIcons.checkCircle), findsOneWidget); + expect(find.text(t.quotaUsedUp), findsOneWidget); + expect(find.text(t.watchAdMore), findsOneWidget); + }); + + testWidgets('额度卡(桌面·有余额):不显示加时按钮', (tester) async { + await tester.pumpWidget(wrapThemed( + QuotaCard(quota: const FreeQuotaState(), t: t, isDesktop: true, onWatchAd: () {}), + )); + await tester.pump(); + expect(find.text(t.watchAdMore), findsNothing); }); testWidgets('推荐卡:展示推荐胶囊与文案,选中显示对勾', (tester) async { diff --git a/docs/free-quota-ad.html b/docs/free-quota-ad.html new file mode 100644 index 0000000..43819c7 --- /dev/null +++ b/docs/free-quota-ad.html @@ -0,0 +1,127 @@ + + + + + +免费版 10 分钟卡控 + 累加式看广告加时(设计 · #21) + + + +
+← 返回文档索引 + +

免费版 10 分钟卡控 + 累加式看广告加时

+

设计 · todo #21 · 后端 + 前端 + 数据库 · 额度全账户共享

+ +
+免费版此前形同虚设:连接页无倒计时、时间到不卡控、按钮永远可点。根因在服务端—— +ConnectNode 免费门每次连接都发固定 daily_minutes×1min TTL、从不扣减已用, +重连即崭新 10 分钟,日额度从未真正强制。本次将其改为账户级(全设备共享)真卡控 + +累加式看广告加时:连接期倒计时、到点自动切断、耗尽按钮灰化、点击弹广告、看完 +N 分钟。 +
+ +

目标与口径(已确认)

+
    +
  • 连接期倒计时:连上后显示剩余 mm:ss,到 0 自动切断隧道。
  • +
  • 耗尽卡控:额度用完 → 连接按钮变灰不可点。
  • +
  • 看广告加时(累加式):点灰按钮弹广告,看完 +10 分钟(可重复,每日封顶 120 分钟)。
  • +
  • 占位广告 SDK:先跑通假流程(dialog→“播放中”→奖励),服务端用放行式 DevVerifier(nonce 仍防重放),接真 AdMob 时替换。
  • +
  • 桌面(Windows/macOS):免费 = 硬 10 分钟/天不可延,无广告;到点→切断+灰按钮+提示“去移动端看广告或升级”。
  • +
  • 额度全账户共享:非每设备。服务端 usage_daily 本就按 user_id 聚合所有设备的 minutes_used,天然账户级;客户端倒计时仅本地近似,权威始终以 quota_today_min 为准。
  • +
+ +

数据模型

+
+

migration 000020_ad_bonus_minutes(sqlite + mysql)

+
ALTER TABLE usage_daily ADD COLUMN ad_bonus_minutes INT NOT NULL DEFAULT 0;
+

当日免费额度 = plans.daily_minutes(free=10)+ usage_daily.ad_bonus_minutes(看广告累加); +剩余 = 额度 − minutes_used。历史列 ad_unlocked_at(布尔式当日解锁)保留但不再用于卡控。

+
+ +

后端

+ + + + + + + + +
改动
usage/store.goAddAdBonusMinutes(uid,day,add,ceiling):事务内 LockForUpdate 读旧值 → 累加封顶 → upsert,返回新总额 + 本次实际加时DailyUsage/GetDay/GetUsageRange 补读 ad_bonus_minutesMarkAdUnlocked 标 deprecated。
usage/service.go常量 adBonusPerAd=10 / adDailyBonusCeiling=120TodaySummaryMinutesCap(=daily+bonus)/ AdBonusMinutesremaining=cap−usedUnlockAd 改累加:verify+nonce 后 +adBonusPerAd 封顶,返回 (granted, remaining)
usage/ads.go + main.go放行式 DevVerifierVerify 恒 nil)。main.goADS_DEV_MODE/默认装配(替换现在的 nil——否则 UnlockAd 直接 ErrInternal,占位流程走不通)。nonce 防重放仍生效。
httpapi/nodes.go ConnectNode新增 nodes.store.AccountDayMinutes(uid,day)→(used,bonus)。免费门:allowance=daily+bonusremaining=allowance−usedremaining≤0→拒 QUOTA_EXHAUSTED;否则 TTL=remaining×1min(凭证到点硬切断兜底,防绕过客户端)。
httpapi/account.go /me补读 ad_bonus_minutesquota_today_min 分母改 daily+bonus;新增 quota_cap_min(=allowance,客户端进度条分母)。
usage/handler.goPOST /v1/ads/unlock:204 → 200 返回 {granted_minutes, minutes_remaining}
+ +

前端(四端共享 Dart)

+ + + + + + + + + +
改动
models/me.dartquotaCapMin(当日总额度)。
state/quota_provider.darttotal=me.quotaCapMinisExhaustedmarkExhausted()(倒计时归零本地置耗尽 + 登录态拉 me 校准);watchAd() async 调 /ads/unlock(占位 ad_token=uuid)→ 刷新 me,返回 granted。
state/connection_provider.dart连接时锁定 _freeRemainingSec(会员为 null 不倒计时)。复用 elapsed 计时器每 tick 算 countdown=cap−elapsed,写入 ConnectionState.freeCountdown归零→自动切断_onFreeQuotaExhausted:主动断开不报节点异常 + markExhausted)。倒计时用墙上时钟,后台漏跳回前台补上、准时切。连接遇后端 QUOTA_EXHAUSTED 兜底置耗尽。
widgets/connect_button.dartenabled/onDisabledTap:off 态额度耗尽 → 灰化(锁图标),点击走加时流程。
widgets/quota_card.dart三态:连接中显示倒计时 mm:ss + 进度收缩;未连接有余额显示剩余分钟;耗尽显示“今日已用完”。移动端「看广告加时」/ 桌面「升级会员」。
widgets/ad_reward_dialog.dart(新)移动端占位广告:“广告播放中…”→3s→调 watchAd→显示“已加 N 分钟”自动关闭。桌面版:升级/移动端提示弹窗(无广告)。
l10n(zh/en)倒计时/今日已用完/看广告加时/占位广告播放/奖励/桌面升级提示 双语文案。
+ +

时序

+
+

移动端典型流

+
连接 → /me remaining=10 → 倒计时 10:00 …… 00:00
+  → 客户端 _onFreeQuotaExhausted:切断隧道 + 按钮灰化 + markExhausted
+点灰按钮 → 占位广告 dialog(3s)→ POST /ads/unlock(DevVerifier 放行 + nonce)
+  → 服务端 ad_bonus_minutes += 10(封顶 120)→ 返回 granted=10, remaining=10
+  → 刷新 /me(quota_cap_min=20, quota_today_min=10)→ 按钮恢复可连
+再次连接 → ConnectNode remaining=allowance−used → TTL=remaining(服务端硬切断兜底)
+
+

桌面:同样倒计时 + 到点切断 + 灰按钮,但点击弹“去移动端看广告或升级会员”,无加时路径(硬 10 分钟/天)。

+ +

验证

+
    +
  • 后端单测:AddAdBonusMinutes 累加+封顶(SQLite 实库);TodaySummary cap/remaining;UnlockAd 走 DevVerifier 加时;ConnectNode remaining≤0 拒 / TTL=remaining(集成测试)。
  • +
  • 客户端:flutter analyze + flutter test(quota 倒计时/耗尽/加时;额度卡三态;/me 契约含 quota_cap_min)。
  • +
  • 真机:免费连接→倒计时→到 0 自动断+灰按钮;点灰→移动弹占位广告→+10 分钟→恢复可连;桌面到点→灰+升级提示(无广告);同账户两设备共享同一剩余。
  • +
+ +

不在本轮

+
    +
  • 真 AdMob/激励视频 SDK 接入(DevVerifier 占位替换)。
  • +
  • 广告频次风控细化(现仅每日封顶 adDailyBonusCeiling)。
  • +
  • 客户端倒计时与服务端 minutes_used 聚合延迟的精确对账(以服务端 TTL 硬切断兜底)。
  • +
+ +
+ + diff --git a/docs/index.html b/docs/index.html index ed10a7d..30fb1ae 100644 --- a/docs/index.html +++ b/docs/index.html @@ -44,6 +44,11 @@

设计方案 / Specs

+ +
免费版 10 分钟卡控 + 累加式看广告加时 HTML
+
免费版真卡控(账户级/全设备共享):连接期倒计时 + 到点自动切断 + 耗尽按钮灰化 + 点击弹广告看完 +10 分钟(累加,每日封顶 120)。桌面硬 10 分钟不可延。服务端 ad_bonus_minutes 累加模型 + ConnectNode 按 remaining 卡控 + TTL 硬切断;占位 DevVerifier。#21。
+
docs/free-quota-ad.html
+
设备数量限制 + 超限 UX HTML
真正启用套餐设备上限(free 1 / pro 3 / team 10):卡在登录(非硬拒登,返回 device_limit 信号)+ 选择移除/一键踢最旧 + 服务端按 last_seen 自动清理久不活跃。复用现成 DeleteDevice/ResolvePlan。无 DB schema 变更。#16。