From b461a476a2298ec7550102ccfddfae9a4939ee65 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Thu, 2 Jul 2026 00:33:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(client):=20=E5=85=8D=E8=B4=B9=E7=89=88?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E5=80=92=E8=AE=A1=E6=97=B6=20+=20=E5=88=B0?= =?UTF-8?q?=E7=82=B9=E5=88=87=E6=96=AD=20+=20=E7=81=B0=E6=8C=89=E9=92=AE?= =?UTF-8?q?=20+=20=E7=9C=8B=E5=B9=BF=E5=91=8A=E5=8A=A0=E6=97=B6(#21=20?= =?UTF-8?q?=E5=89=8D=E7=AB=AF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 四端共享 Dart 实现,配合 #21 后端账户级卡控: - me.dart: 加 quota_cap_min(当日总额度 = daily + 看广告 bonus,进度条分母)。 - quota_provider: total 取 quotaCapMin;isExhausted;markExhausted(倒计时归零本地置耗尽 + 登录态拉 me 校准);watchAd() async 调 /ads/unlock(占位 ad_token=uuid)→ 刷新 me,返回 granted。 - connection_provider: 连接时锁定 _freeRemainingSec(会员 null 不倒计时);复用 elapsed 计时器 每 tick 算 freeCountdown,归零 → _onFreeQuotaExhausted(主动切断不报节点异常 + markExhausted); 倒计时用墙上时钟(后台漏跳回前台补上、准时切);连接遇后端 QUOTA_EXHAUSTED 兜底置耗尽。 - connect_button: enabled/onDisabledTap —— 额度耗尽 off 态灰化(锁图标),点击弹加时。 - quota_card: 三态(连接倒计时 mm:ss / 未连接剩余分钟 / 耗尽今日已用完);移动「看广告加时」、桌面「升级会员」。 - ad_reward_dialog(新): 移动端占位广告(播放中→3s→加时→奖励);桌面版硬 10 分钟提示去移动端/升级。 - l10n(zh/en): 倒计时/已用完/看广告加时/占位广告/桌面升级 双语。 - 测试: quota isExhausted/markExhausted;连接倒计时归零自动切断+置耗尽(注入时钟); 额度卡三态 widget;/me 契约含 quota_cap_min;golden 更新(Linux 权威基线 quota_low/exhausted)。 - 文档: docs/free-quota-ad.html + 登记 docs/index.html。 Co-Authored-By: Claude Opus 4.8 --- client/lib/l10n/app_text.dart | 11 ++ client/lib/l10n/strings_en.dart | 20 +++ client/lib/l10n/strings_zh.dart | 20 +++ client/lib/models/me.dart | 8 +- client/lib/screens/connect_page.dart | 26 ++- client/lib/services/account_api.dart | 22 ++- client/lib/state/connection_provider.dart | 67 +++++++- client/lib/state/quota_provider.dart | 64 +++++-- client/lib/widgets/ad_reward_dialog.dart | 156 ++++++++++++++++++ client/lib/widgets/connect_button.dart | 40 +++-- client/lib/widgets/quota_card.dart | 108 ++++++++---- client/test/contract/api_contract_test.dart | 2 + .../test/golden/components_golden_test.dart | 6 +- .../golden/goldens/quota_exhausted_dark.png | Bin 0 -> 9035 bytes .../golden/goldens/quota_exhausted_light.png | Bin 0 -> 9508 bytes client/test/golden/goldens/quota_low_dark.png | Bin 9746 -> 9649 bytes .../test/golden/goldens/quota_low_light.png | Bin 10202 -> 10054 bytes .../test/unit/connection_watchdog_test.dart | 33 ++++ client/test/unit/quota_controller_test.dart | 17 +- client/test/widget/cards_test.dart | 22 ++- docs/free-quota-ad.html | 127 ++++++++++++++ docs/index.html | 5 + 22 files changed, 661 insertions(+), 93 deletions(-) create mode 100644 client/lib/widgets/ad_reward_dialog.dart create mode 100644 client/test/golden/goldens/quota_exhausted_dark.png create mode 100644 client/test/golden/goldens/quota_exhausted_light.png create mode 100644 docs/free-quota-ad.html 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 0000000000000000000000000000000000000000..9c3f9782730995ea1102d796e32add40e6d99dc4 GIT binary patch literal 9035 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 K@bZ^{KmH5NB4W@0 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..62243a63b730448afb41ff402229e317bc636c28 GIT binary patch literal 9508 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 literal 0 HcmV?d00001 diff --git a/client/test/golden/goldens/quota_low_dark.png b/client/test/golden/goldens/quota_low_dark.png index 2130fc2f3f8d2d197aab27b4f7f00b07cc85c876..32b77a04b6260bcd23ac1a3dd7d7ad87ced884f5 100644 GIT binary patch literal 9649 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 literal 9746 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 diff --git a/client/test/golden/goldens/quota_low_light.png b/client/test/golden/goldens/quota_low_light.png index 81df106a64542d470a101272040c33448999e085..43243da8f4644081f52fe7e4fda4ee8b301c5356 100644 GIT binary patch literal 10054 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 BgVF#1 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。