Merge remote-tracking branch 'origin/main' into feat/pay-v2-integration
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 25s
ci-pangolin / Lint — shellcheck (push) Successful in 29s
ci-pangolin / Cleartext Scan — Android 禁明文 (push) Successful in 22s
ci-pangolin / OpenAPI Sync Check (push) Successful in 40s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 19s
ci-pangolin / Flutter — analyze + test (push) Failing after 4m59s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 1m51s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (push) Successful in 5s
ci-pangolin / Go — build + test (push) Failing after 1m33s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Failing after 14s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 4m59s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (push) Failing after 4s

# Conflicts:
#	docs/index.html
#	server/cmd/server/main.go
This commit is contained in:
wangjia
2026-07-11 16:44:05 +08:00
228 changed files with 13418 additions and 1106 deletions
+9 -1
View File
@@ -40,11 +40,19 @@ class MeNotifier extends AsyncNotifier<Me> {
return ref.read(accountApiProvider).me();
}
/// 手动刷新(如连接/兑换后)。
/// 手动刷新(如兑换后 / 下拉刷新)。会短暂进 loading 态(触发整页转圈)。
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() => ref.read(accountApiProvider).me());
}
/// 静默刷新:不进 loading 态、成功才替换。供连接期/断开后刷新「今日剩余」用,
/// 避免 refresh() 的 AsyncLoading 让连接页整页闪 spinner(meLoadingProvider)。
Future<void> silentRefresh() async {
if (!ref.read(authProvider).isLoggedIn) return;
final next = await AsyncValue.guard(() => ref.read(accountApiProvider).me());
if (next is AsyncData<Me>) state = next;
}
}
final meProvider = AsyncNotifierProvider<MeNotifier, Me>(MeNotifier.new);
+62 -6
View File
@@ -1,21 +1,77 @@
// app_providers.dart — 语言 / 主题 / 套餐视角等基础状态(Riverpod)
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../l10n/app_text.dart';
import '../l10n/strings_en.dart';
import '../l10n/strings_es.dart';
import '../l10n/strings_ja.dart';
import '../l10n/strings_ko.dart';
import '../l10n/strings_ru.dart';
import '../l10n/strings_zh.dart';
import 'account_providers.dart';
import 'auth_provider.dart';
/// 当前语言(单显)。设置/账户页段控切换。
final localeProvider = StateProvider<AppLang>((ref) => AppLang.zh);
/// AppLang → 文案资源实例。供 [appTextProvider] 与「拿不到 Consumer 的场景」
/// (model / 非 Consumer 的 tile,只有 AppLang)复用,避免各处重写 switch。
AppText appTextFor(AppLang lang) {
switch (lang) {
case AppLang.zh:
return const StringsZh();
case AppLang.en:
return const StringsEn();
case AppLang.ja:
return const StringsJa();
case AppLang.ko:
return const StringsKo();
case AppLang.ru:
return const StringsRu();
case AppLang.es:
return const StringsEs();
}
}
/// 当前语言(单显)。默认英文(国际化默认语种);用户选择持久化到
/// shared_preferences(key `pg_lang`,存枚举 name),重启保留 —— 原来无持久化,
/// 切了语言重启会丢。设置/账户页经 `.notifier).set(lang)` 切换。
class LocaleNotifier extends StateNotifier<AppLang> {
LocaleNotifier() : super(AppLang.en) {
_load();
}
static const _key = 'pg_lang';
Future<void> _load() async {
try {
final saved = (await SharedPreferences.getInstance()).getString(_key);
if (saved != null) {
for (final l in AppLang.values) {
if (l.name == saved) {
state = l;
break;
}
}
}
} catch (_) {
/* 读失败保持默认 en */
}
}
Future<void> set(AppLang lang) async {
state = lang;
try {
await (await SharedPreferences.getInstance()).setString(_key, lang.name);
} catch (_) {
/* 持久化失败忽略,本次会话仍生效 */
}
}
}
final localeProvider =
StateNotifierProvider<LocaleNotifier, AppLang>((ref) => LocaleNotifier());
/// 由语言派生的文案资源——UI 一律通过它取文案,不写死字面量。
final appTextProvider = Provider<AppText>((ref) {
final lang = ref.watch(localeProvider);
return lang == AppLang.zh ? const StringsZh() : const StringsEn();
});
final appTextProvider = Provider<AppText>((ref) => appTextFor(ref.watch(localeProvider)));
/// 主题模式。默认跟随系统;设置页可显式切深色。
final themeModeProvider = StateProvider<ThemeMode>((ref) => ThemeMode.system);
+142 -20
View File
@@ -19,9 +19,11 @@ import '../services/api_config.dart';
import '../services/auth_api.dart';
import '../services/connect_api.dart';
import '../services/device_identity.dart';
import 'account_providers.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 +37,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 +50,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);
}
// ── 连通看门狗 ─────────────────────────────────────────────────────
@@ -76,6 +92,8 @@ const _kUrltestStale = Duration(seconds: 45);
// stats 帧在此时长内到过即视为「在流」。超过(app 挂起/唤醒未恢复)时看门狗路径 A 不判活,
// 避免把 app 被挂起的空档错算成节点死。须 > 原生 stats 轮询间隔(~1s),留足余量。
const _kStatsLive = Duration(seconds: 10);
// 无备用节点时,弱网抖动判「节点异常」先自动重连当前节点的最大次数;超过才真报错。#18
const _kMaxAutoReconnect = 3;
// ── 状态机 ───────────────────────────────────────────────────────
@@ -92,7 +110,8 @@ class ConnectionController extends StateNotifier<ConnectionState> {
_authSub = _ref.listen<AuthState>(authProvider, (prev, next) {
if ((prev?.isLoggedIn ?? false) && !next.isLoggedIn) {
_userDisconnect = true; // 视为「非节点异常」的主动断开,不弹「节点异常」
unawaited(_disconnect());
// 登出也尝试吊销凭证(F4);token 可能已失效,best-effort 吞错。
unawaited(_disconnect(revokeCredential: true));
}
});
// 生命周期闸:切后台停看门狗,回前台再开。原因:后台(尤其 Android Doze)会把 urltest
@@ -140,6 +159,12 @@ class ConnectionController extends StateNotifier<ConnectionState> {
ConnectApi? _api;
// 实际所连节点(连接时锁定):看门狗探测/判活针对它,而非会随 ping 漂移的 effectiveNode。
Node? _connectedNode;
// 无备用节点时「先自动重连当前节点」的连续尝试计数;连接恢复健康(urltest 成功)或
// 用户主动连接时清零,只有持续失败才耗尽额度后真报「节点异常」。#18
int _autoReconnectAttempts = 0;
// 免费版:连接时锁定的剩余额度(秒);连接期按「_freeRemainingSec 已用秒」倒计时,
// 归零即自动切断(_onFreeQuotaExhausted)。null = 会员/额度不限,不倒计时。
int? _freeRemainingSec;
// ── 公有 API ───────────────────────────────────────────────────
@@ -147,10 +172,11 @@ class ConnectionController extends StateNotifier<ConnectionState> {
void toggle() {
switch (state.phase) {
case VpnPhase.off:
_autoReconnectAttempts = 0; // 用户主动连接:重置弱网自动重连额度(#18)
_connect();
case VpnPhase.on:
_userDisconnect = true; // 用户主动断开:其 kernel off 不当作节点异常
_disconnect();
_disconnect(revokeCredential: true); // 服务端同步吊销本设备凭证(F4)
case VpnPhase.connecting:
break; // 握手进行中,不响应
}
@@ -170,10 +196,15 @@ class ConnectionController extends StateNotifier<ConnectionState> {
_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);
final zh = _ref.read(localeProvider) == AppLang.zh;
final lang = _ref.read(localeProvider);
final zh = lang == AppLang.zh; // 仅用于服务端 e.messageZh/En 的二选一(下方)
logLine('Connect', '_connect node=${node.code} uuid=${node.uuid.isEmpty ? "EMPTY" : "ok"} '
'selected=${_ref.read(selectedNodeCodeProvider)} nodes=${(_ref.read(nodesProvider).valueOrNull ?? const []).length}');
@@ -182,7 +213,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
if (mounted) {
state = ConnectionState(
phase: VpnPhase.off,
error: zh ? '节点尚未就绪,请稍候重试' : 'Nodes not ready, please retry',
error: lang.nodesNotReady,
);
}
return;
@@ -202,13 +233,19 @@ class ConnectionController extends StateNotifier<ConnectionState> {
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) {
if (mounted) {
state = ConnectionState(
phase: VpnPhase.off,
error: zh ? '连接失败,请重试' : 'Connection failed, please retry',
error: lang.connectFailed,
);
}
}
@@ -242,7 +279,23 @@ class ConnectionController extends StateNotifier<ConnectionState> {
}
}
Future<void> _disconnect() async {
/// [revokeCredential]:同时通知控制面吊销本设备在该节点的数据面凭证(F4)。
/// 仅在「不会紧接着重连同一节点」的路径置 true(用户主动断开/额度耗尽/登出)——
/// 看门狗「断开→立刻重连」若也吊销,revoke 可能在新 connect 推完凭证后才到达、
/// 把新会话杀掉。fire-and-forget:不阻塞本地拆隧道与 UI 回 off。
Future<void> _disconnect({bool revokeCredential = false}) async {
if (revokeCredential) {
final api = _api;
final node = _connectedNode;
if (api != null && node != null && node.uuid.isNotEmpty) {
unawaited(() async {
try {
final deviceId = await _ref.read(deviceIdentityProvider).deviceId();
await api.disconnect(nodeId: node.uuid, deviceId: deviceId);
} catch (_) {/* best-effort */}
}());
}
}
_stopElapsed();
_stopWatchdog();
try {
@@ -250,12 +303,26 @@ class ConnectionController extends StateNotifier<ConnectionState> {
} catch (_) {}
// 携带 _offNotice(节点异常/null);与 kernel off 读同一字段,不互相覆盖。
if (mounted) state = ConnectionState(phase: VpnPhase.off, error: _offNotice);
_refreshQuota(); // 会话结束 → 刷新「今日剩余」反映本次消耗
}
// 静默刷新 /me(不闪整页 spinner),让免费额度「今日剩余」跨会话/跨设备更新。
void _refreshQuota() {
if (_ref.read(authProvider).isLoggedIn && _ref.read(isFreePlanProvider)) {
unawaited(_ref.read(meProvider.notifier).silentRefresh());
}
}
void _onKernelStatus(VpnStatus s) {
if (!mounted) return;
switch (s) {
case VpnStatus.on:
// 免费版:若本次「on」不是经 _connect 而来(如 Android 常驻隧道、app 重启后
// 自动同步到已在跑的隧道),_freeRemainingSec 还是 null → 倒计时不启。这里兜底
// 按当前额度补锁定,保证连接页显示的是倒计时(会变)而非静态「今日剩余」。
if (_freeRemainingSec == null && _ref.read(isFreePlanProvider)) {
_freeRemainingSec = _ref.read(quotaProvider).remainingMinutes * 60;
}
state = state.copyWith(phase: VpnPhase.on);
_startElapsed();
_startWatchdog();
@@ -269,15 +336,20 @@ class ConnectionController extends StateNotifier<ConnectionState> {
// 必须排除 connecting 握手期的瞬态 off——macOS NE 连接序列是 off→connecting→off→
// connecting→on,connecting 期的 off 是握手抖动、不是异常(否则一连接就误报「节点异常」)。
final wasConnected = state.phase == VpnPhase.on;
_stopElapsed();
_stopWatchdog();
if (!_userDisconnect && wasConnected && _offNotice == null) {
_offNotice = _ref.read(appTextProvider).nodeUnhealthyError;
// 意外掉线(非用户主动、曾连上;弱网抖动最常见的就是这条 kernel off)。先自动重连
// **当前节点**(不换节点——弱网是本地网络问题,换节点无益还会来回横跳),重试用尽才
// 报「节点异常」。(wasConnected 闸:重连握手期再掉线时 phase 已非 on,不自我循环触发。)#18
logLine('Watchdog', 'unexpected kernel ${s.name} after connected → node interrupted');
unawaited(_ref.read(nodesProvider.notifier).refresh());
_userDisconnect = false;
unawaited(_handleUnexpectedOff());
return;
}
state = ConnectionState(phase: VpnPhase.off, error: _offNotice);
_userDisconnect = false;
_stopElapsed();
_stopWatchdog();
_refreshQuota(); // 内核掉线也算会话结束 → 刷新「今日剩余」
}
}
@@ -300,6 +372,7 @@ class ConnectionController extends StateNotifier<ConnectionState> {
if (ds.isEmpty) return;
// urltest 成功:经 proxy 出站(REALITY 到节点)真实可达 → 记录时刻(供路径 A 判活)+ 回写延迟。
_lastUrltestOk = now;
_autoReconnectAttempts = 0; // 节点已恢复健康 → 清零自动重连计数,下次抖动重获满额重试。#18
final best = ds.reduce((a, b) => a < b ? a : b);
_ref.read(nodesProvider.notifier).setLivePing(node.uuid, best);
}
@@ -370,12 +443,38 @@ class ConnectionController extends StateNotifier<ConnectionState> {
}
return;
}
// 手动选定节点(或智能模式无其他可用节点):断开并提示,尊重用户选择、不自动换
// 提示走 _offNotice,由 _disconnect/_onKernelStatus 应用,避免被 kernel off 覆盖。
// 无备用节点(手动选定节点 / 智能但无其他可用):弱网抖动别一判死就断开+要用户手动重连
// 先自动重连**当前节点**,连续失败超过上限再真报「节点异常」。#18
if (await _tryAutoReconnectCurrent()) return;
// 重试用尽:断开并提示「节点异常」。提示走 _offNotice,由 _disconnect/_onKernelStatus 应用。
_offNotice = t.nodeUnhealthyError;
await _disconnect();
}
/// 弱网抖动:自动重连**当前节点**(不换节点、不改 selectedNode),bounded。
/// 返回 true = 已发起重连(额度内);false = 已用尽额度,调用方应改报「节点异常」。#18
/// urltest 成功(_onStats)或用户主动连接(toggle)会把计数清零 → 只有持续失败才耗尽。
Future<bool> _tryAutoReconnectCurrent() async {
if (_autoReconnectAttempts >= _kMaxAutoReconnect) return false;
_autoReconnectAttempts++;
final Node node = _connectedNode ?? _ref.read(effectiveNodeProvider);
logLine('Watchdog', 'auto-reconnect current node ${node.code} (attempt $_autoReconnectAttempts/$_kMaxAutoReconnect)');
await _disconnect();
await _connect();
// 重连握手中给「网络波动,正在重连…」瞬态提示(连上后随 copyWith 清除)。
if (mounted && state.phase != VpnPhase.off) {
state = ConnectionState(phase: state.phase, error: _ref.read(appTextProvider).nodeReconnecting);
}
return true;
}
/// 意外内核掉线(弱网)的处理:先自动重连当前节点,额度用尽才断开并报「节点异常」。#18
Future<void> _handleUnexpectedOff() async {
if (await _tryAutoReconnectCurrent()) return;
_offNotice = _ref.read(appTextProvider).nodeUnhealthyError;
await _disconnect();
}
/// 选延迟最优、可用(status up)、非当前节点的 code;无则 null。
String? _pickAlternativeCode(String excludeCode) {
final nodes = (_ref.read(nodesProvider).valueOrNull ?? const [])
@@ -397,18 +496,41 @@ class ConnectionController extends StateNotifier<ConnectionState> {
_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<void> _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(revokeCredential: true); // 额度耗尽:服务端即刻吊销(F4)
}
void _stopElapsed() {
_elapsed?.cancel();
_elapsed = null;
_connectedAt = null;
_freeRemainingSec = null;
}
@override
+43 -18
View File
@@ -1,11 +1,16 @@
// 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';
/// 免费额度快照。
@@ -13,18 +18,14 @@ 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 +33,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 +55,41 @@ class QuotaController extends StateNotifier<FreeQuotaState> {
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);
_ref.read(meProvider.notifier).silentRefresh();
}
/// 看广告加时:调 /v1/ads/unlock 累加分钟,成功后刷新 me 拿最新额度。
/// 返回本次加时分钟(null = 失败)。占位阶段用客户端生成的 ad_token。
Future<int?> 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 校准(账户共享,以服务端为准;不闪整页 spinner)。
state = state.copyWith(remainingMinutes: res.minutesRemaining);
await _ref.read(meProvider.notifier).silentRefresh();
return res.grantedMinutes;
} catch (_) {
return null;
}
}
}
final quotaProvider = StateNotifierProvider<QuotaController, FreeQuotaState>(
+148
View File
@@ -0,0 +1,148 @@
// update_provider.dart — 应用更新检查(启动自动检查 + 定时轮询)。
//
// 对照 jiu client/lib/providers/update_provider.dart:用 AsyncNotifier 的惰性
// build() 做「启动后延迟首查 + 每小时轮询」;shell 里 watch 一次即拉起整条流程。
// `_dismissed` 是本次进程内存标志(用户点「稍后」置真,防反复弹),每轮轮询重置。
// 强制更新(force_update)不受 dismiss 影响,由 UI 走不可关闭弹窗。
//
// 拿到更新后,下载安装走 core/update/app_updater.dart(App 内下载,不再开浏览器)。
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:package_info_plus/package_info_plus.dart';
import '../services/api_config.dart';
/// 启动后首次检查的延迟(避开登录/首屏竞争)。
const _kInitialDelay = Duration(seconds: 3);
/// 轮询间隔。
const _kPollInterval = Duration(hours: 1);
/// 单次检查网络超时。
const _kCheckTimeout = Duration(seconds: 8);
/// 一次更新检查的结果。
class AppUpdateInfo {
const AppUpdateInfo({
required this.latestVersion,
required this.buildNumber,
required this.forceUpdate,
required this.releaseNotes,
required this.downloadUrls,
required this.hasUpdate,
});
final String latestVersion;
final int buildNumber;
final bool forceUpdate;
final String releaseNotes;
final Map<String, String> downloadUrls;
final bool hasUpdate;
}
/// 更新检查 Notifier。build() 惰性触发:延迟首查 + 每小时轮询。
class UpdateNotifier extends AsyncNotifier<AppUpdateInfo?> {
Timer? _timer;
Timer? _initialTimer;
@override
Future<AppUpdateInfo?> build() async {
// 两个 timer 都在 onDispose 取消。初始延迟用可取消的 Timer(而非
// Future.delayed:其内部 timer 无法取消,provider 在延迟期间被 dispose
// 时会悬挂 → widget 测试报 pending timer、生产留资源)。
ref.onDispose(() {
_initialTimer?.cancel();
_timer?.cancel();
});
final ready = Completer<void>();
_initialTimer = Timer(_kInitialDelay, ready.complete);
await ready.future; // 若延迟期间被 dispose,_initialTimer 取消 → 永不 complete,build 中止
final first = await _check();
_timer = Timer.periodic(_kPollInterval, (_) async {
state = AsyncValue.data(await _check());
});
return first;
}
/// 设置页「检查更新」手动触发:立即查一次并回结果。
Future<AppUpdateInfo?> forceCheck() async {
final info = await _check();
state = AsyncValue.data(info);
return info;
}
/// 拉取 `$kApiBaseUrl/version` 并与本地版本比较。网络/解析失败返回 null(静默)。
Future<AppUpdateInfo?> _check() async {
try {
final resp = await http
.get(Uri.parse('$kApiBaseUrl/version'))
.timeout(_kCheckTimeout);
if (resp.statusCode != 200) return null;
final data = jsonDecode(resp.body) as Map<String, dynamic>;
final latestVersion = data['version'] as String? ?? '0.0.0';
final buildNumber = (data['build_number'] as num?)?.toInt() ?? 0;
final forceUpdate = data['force_update'] as bool? ?? false;
final releaseNotes = data['release_notes'] as String? ?? '';
final rawUrls = data['download_urls'] as Map<String, dynamic>? ?? const {};
final downloadUrls = rawUrls.map((k, v) => MapEntry(k, v?.toString() ?? ''));
final pkg = await PackageInfo.fromPlatform();
final hasUpdate = _isNewer(latestVersion, pkg.version);
return AppUpdateInfo(
latestVersion: latestVersion,
buildNumber: buildNumber,
forceUpdate: forceUpdate,
releaseNotes: releaseNotes,
downloadUrls: downloadUrls,
hasUpdate: hasUpdate,
);
} catch (_) {
return null;
}
}
/// 语义化版本比较:latest > current → true。容忍 1.1.4-dev / 1.1.4+7 等后缀。
bool _isNewer(String latest, String current) {
final l = _parse(latest);
final c = _parse(current);
for (var i = 0; i < 3; i++) {
if (l[i] > c[i]) return true;
if (l[i] < c[i]) return false;
}
return false;
}
List<int> _parse(String v) {
final parts = v.split('.').map((s) {
final m = RegExp(r'^\d+').firstMatch(s);
return m == null ? 0 : int.parse(m.group(0)!);
}).toList();
while (parts.length < 3) {
parts.add(0);
}
return parts;
}
}
final updateProvider =
AsyncNotifierProvider<UpdateNotifier, AppUpdateInfo?>(UpdateNotifier.new);
/// 用户在更新 banner 点「稍后再说」时忽略的版本号。忽略后 banner 隐藏;出现号
/// 不同的更新版本会重新显示;设置页手动「检查更新」会清空以重新提示。响应式,
/// 供 shell 顶部 banner 的显隐判断。
final dismissedUpdateVersionProvider = StateProvider<String?>((ref) => null);
/// 按当前平台从服务端 download_urls 里取对应下载直链。
String? platformDownloadUrl(Map<String, String> downloadUrls) {
if (Platform.isMacOS) return downloadUrls['macos'];
if (Platform.isWindows) return downloadUrls['windows'];
if (Platform.isIOS) return downloadUrls['ios'];
if (Platform.isAndroid) return downloadUrls['android'];
return downloadUrls['web'];
}