diff --git a/.gitignore b/.gitignore index 3306398..1c2928a 100644 --- a/.gitignore +++ b/.gitignore @@ -90,3 +90,4 @@ client/windows/installer/Output/ /server/cover.out /server/coverage.txt /client/coverage/ +local-l13-start.sh diff --git a/client/android/app/src/main/AndroidManifest.xml b/client/android/app/src/main/AndroidManifest.xml index 2a81d58..ba725ae 100644 --- a/client/android/app/src/main/AndroidManifest.xml +++ b/client/android/app/src/main/AndroidManifest.xml @@ -29,6 +29,28 @@ --> + + + + + + + + + + + + + + + + + _install(String path) async { exit(0); // 退出让安装器覆盖 } if (Platform.isMacOS) { - // 不自动替换 bundle(带系统扩展,风险高):解压 + 访达高亮,提示手动拖入。 - await Process.run('open', [path]); // Archive Utility 解压 - await Process.run('open', ['-R', path]); // 访达定位 + await _macInstall(path); // 成功则 exit(0) 不返回;回退则内部走访达定位后返回 } } +/// macOS 自动安装:解压 → 分离 helper 换 /Applications 的 .app + 重启。 +/// 成功路径 exit(0)(不返回);不满足条件/失败则回退访达定位(现状半自动流程)后正常返回, +/// 由调用方提示手动拖入。 +Future _macInstall(String zipPath) async { + final exe = Platform.resolvedExecutable; // …/pangolin_vpn.app/Contents/MacOS/pangolin_vpn + const marker = '/Contents/MacOS/'; + final mi = exe.indexOf(marker); + final appPath = mi > 0 ? exe.substring(0, mi) : ''; // …/pangolin_vpn.app + // 仅当能定位到 .app bundle 才尝试自动装;异常布局(如无 bundle 运行)回退。 + if (appPath.isEmpty || !appPath.toLowerCase().endsWith('.app')) { + await _macReveal(zipPath); + return; + } + + try { + final tmp = await getTemporaryDirectory(); + final staging = '${tmp.path}/pangolin-update-${DateTime.now().millisecondsSinceEpoch}'; + final extractDir = '$staging/new'; + await Directory(extractDir).create(recursive: true); + + // ditto 解 PKZip(zip 由 --keepParent 打,顶层含 pangolin_vpn.app);失败即回退。 + final ex = await Process.run('/usr/bin/ditto', ['-x', '-k', zipPath, extractDir]); + if (ex.exitCode != 0) { + await _macReveal(zipPath); + return; + } + final newApp = await _findDotApp(extractDir); + if (newApp == null) { + await _macReveal(zipPath); + return; + } + + // 写 helper、分离启动、退出让其换装重启。参数:PID/现.app/新.app/暂存/zip。 + final helper = '$staging/pangolin-update.sh'; + await File(helper).writeAsString(_macUpdateHelper); + final chmod = await Process.run('/bin/chmod', ['+x', helper]); + if (chmod.exitCode != 0) { + // helper 跑不起来:清掉半截暂存(解压出的新 app + 不可执行的 helper),回退访达手动流程。 + try { + await Directory(staging).delete(recursive: true); + } catch (_) {} + await _macReveal(zipPath); + return; + } + await Process.start( + '/bin/sh', + [helper, '$pid', appPath, newApp, staging, zipPath], + mode: ProcessStartMode.detached, + ); + await Future.delayed(const Duration(milliseconds: 300)); + exit(0); // helper 等本进程退出后原子换 bundle + open 重启 + } catch (_) { + await _macReveal(zipPath); // 任何意外 → 保底手动流程 + } +} + +/// 回退:Archive Utility 解压 zip + 访达高亮(与旧版一致的半自动流程)。 +Future _macReveal(String zipPath) async { + await Process.run('open', [zipPath]); // 解压 + await Process.run('open', ['-R', zipPath]); // 访达定位 +} + +/// 在解压目录里找顶层 .app(ditto --keepParent 打的 zip 解出 pangolin_vpn.app)。 +Future _findDotApp(String dir) async { + final d = Directory(dir); + if (!await d.exists()) return null; + await for (final e in d.list(followLinks: false)) { + if (e is Directory && e.path.toLowerCase().endsWith('.app')) return e.path; + } + return null; +} + +/// macOS 自更新 helper 脚本(分离进程跑):等主 app 完全退出(超时+宽限后仍未退出则放弃, +/// 不硬动运行中的 bundle)→ codesign 验签新版(不过拒绝换装,Developer-ID 完整性闸)→ +/// 原子换 bundle(失败必回滚并校验回滚是否真的成功,不假设 mv 一定成功;仍失败则 osascript +/// 提权,提权脚本自身具备「从 $BACKUP/$NEW_APP 自愈」逻辑,不假设 $APP 一定还在)→ 终检 +/// $APP 确实存在才继续 → 清 quarantine → open 重启(检退出码,失败记日志尽力而为)。 +/// 任何失败组合下都保证 /Applications 不会出现「app 消失、零提示」。 +const String _macUpdateHelper = r'''#!/bin/sh +# Pangolin macOS 自更新 helper —— 由 app_updater.dart 生成、分离进程启动。 +# 参数:PID(主app进程) APP(现.app) NEW_APP(解压出的新.app) STAGING(暂存目录) ZIP(下载的zip,清理用) +PID="$1"; APP="$2"; NEW_APP="$3"; STAGING="$4"; ZIP="$5" +exec >>"$STAGING/update.log" 2>&1 +echo "[helper] start pid=$PID app=$APP new=$NEW_APP" + +BACKUP="${APP}.pangolin-old" + +# 放弃自动换装:访达定位新版供用户手动拖,保留现场(STAGING/BACKUP)便于排查/手动恢复。 +fallback_reveal() { + echo "[helper] fallback: $1" + /usr/bin/open -R "$NEW_APP" 2>/dev/null +} + +# 1. 等主 app 完全退出(最多 ~30s;超时再给 5s 宽限;仍未退出则放弃,不硬动运行中的 bundle) +i=0 +while kill -0 "$PID" 2>/dev/null; do + sleep 0.3; i=$((i+1)) + [ "$i" -gt 100 ] && break +done +if kill -0 "$PID" 2>/dev/null; then + echo "[helper] wait timeout at ~30s, grace +5s" + sleep 5 + if kill -0 "$PID" 2>/dev/null; then + fallback_reveal "app still running after grace period, refusing to touch bundle" + exit 1 + fi +fi +sleep 0.5 + +# 2. 签名验签闸:换装前必须验证新版签名完整(Developer-ID 分发完整性),不过拒绝换装 +if ! /usr/bin/codesign --verify --deep --strict "$NEW_APP" 2>/dev/null; then + fallback_reveal "codesign verify failed on new app, refusing swap" + exit 1 +fi + +# 3. 静默换装(无提权):自愈式 swap —— 若 $APP 因上次失败缺失但 $BACKUP 还在,先自愈复原再换; +# 换装失败一律回滚,并校验回滚是否真的成功(不假设 mv 一定成功)。 +# 返回码:0=成功 1=失败但 $APP 完好(未动/已回滚) 2=$APP 缺失且无 $BACKUP 可自愈 3=回滚也失败($APP 缺失,严重) +swap_plain() { + if [ ! -d "$APP" ] && [ -d "$BACKUP" ]; then + /bin/mv "$BACKUP" "$APP" 2>/dev/null + fi + if [ ! -d "$APP" ]; then + return 2 + fi + /bin/rm -rf "$BACKUP" 2>/dev/null + /bin/mv "$APP" "$BACKUP" 2>/dev/null || return 1 + if /bin/mv "$NEW_APP" "$APP" 2>/dev/null; then + /bin/rm -rf "$BACKUP" 2>/dev/null + return 0 + fi + /bin/mv "$BACKUP" "$APP" 2>/dev/null + if [ -d "$APP" ]; then + return 1 + fi + return 3 +} + +swap_plain +rc=$? +if [ "$rc" -eq 0 ]; then + echo "[helper] swap_plain ok" +else + echo "[helper] swap_plain failed (rc=$rc) -> osascript 提权自愈" + # 4. 提权兜底:ROOT_SH 自身自愈,不假设 $APP 一定存在—— + # 优先从 $BACKUP 复原、再正常 swap;$APP/$BACKUP 都没了则以 $NEW_APP 直接就位兜底。 + ROOT_SH="$STAGING/swap-root.sh" + { + echo '#!/bin/sh' + echo "APP=\"$APP\"" + echo "NEW_APP=\"$NEW_APP\"" + echo "BACKUP=\"$BACKUP\"" + echo 'if [ ! -d "$APP" ] && [ -d "$BACKUP" ]; then /bin/mv "$BACKUP" "$APP"; fi' + echo 'if [ ! -d "$APP" ] && [ -d "$NEW_APP" ]; then' + echo ' /bin/mv "$NEW_APP" "$APP" || exit 1' + echo ' exit 0' + echo 'fi' + echo 'if [ ! -d "$APP" ]; then exit 1; fi' + echo '/bin/rm -rf "$BACKUP"' + echo '/bin/mv "$APP" "$BACKUP" || exit 1' + echo '/bin/mv "$NEW_APP" "$APP" || { /bin/mv "$BACKUP" "$APP" 2>/dev/null; exit 1; }' + echo '/bin/rm -rf "$BACKUP"' + echo 'exit 0' + } > "$ROOT_SH" + if ! /bin/chmod +x "$ROOT_SH"; then + fallback_reveal "chmod ROOT_SH failed" + exit 1 + fi + if ! /usr/bin/osascript -e "do shell script \"/bin/sh '$ROOT_SH'\" with administrator privileges"; then + echo "[helper] osascript failed/canceled -> 回退访达定位" + fallback_reveal "osascript failed or canceled" + exit 1 + fi +fi + +# 5. 终检:/Applications 里必须有可用的 app 才继续,否则宁可停手也不再往下动(不清 quarantine、不 open) +if [ ! -d "$APP" ]; then + fallback_reveal "final check: app still missing after all recovery attempts" + exit 1 +fi + +# 6. 清 quarantine(已公证+staple,防御性)+ open 重启新版;open 失败(Gatekeeper/TCC 拦截)记日志,尽力而为 +/usr/bin/xattr -dr com.apple.quarantine "$APP" 2>/dev/null +if /usr/bin/open "$APP"; then + echo "[helper] relaunched" +else + echo "[helper] open \"$APP\" failed, app updated on disk but not launched (Gatekeeper/TCC?); user needs to open manually" +fi + +# 7. 清理(仅在换装成功、$APP 确认可用后才清;失败路径保留现场供排查) +/bin/rm -f "$ZIP" 2>/dev/null +/bin/rm -rf "$STAGING/new" 2>/dev/null +exit 0 +'''; + Future _openInBrowser(String url) async { final uri = Uri.parse(url); if (await canLaunchUrl(uri)) { diff --git a/client/lib/l10n/app_text.dart b/client/lib/l10n/app_text.dart index 3ffa1d7..f171cf6 100644 --- a/client/lib/l10n/app_text.dart +++ b/client/lib/l10n/app_text.dart @@ -4,8 +4,8 @@ // `localeProvider` 段控切换。所有界面文案必须经此处取用,禁止在组件内 // 写死中/英字面量。zh / en 两份资源分别在 strings_zh.dart / strings_en.dart。 // -// 红线词约束(设计铁律 §13):资源文件不得出现 VPN / 翻墙 / 科学上网 / -// 突破封锁 / 自由穿越 / Go anywhere。CI `ci/scan-redline.sh` 对本目录做扫描。 +// 红线词约束(设计铁律 §13):资源文件不得出现审查规避/敏感定位词, +// 具体清单见 design/CLAUDE.md §1 铁律 13;CI `ci/scan-redline.sh` 对本目录做扫描。 // // 注意:这是手写资源(非 flutter gen-l10n 代码生成),保证离线可编译; // 接口形态与 .arb 一致,后续可平滑迁移到官方 l10n 工具链。 @@ -473,7 +473,6 @@ abstract class AppText { String get updateCheckFailed; // 检查失败(网络异常) // ── 套餐选择 ── - String get choosePlan; String get freePlan; String get proPlan; String get teamPlan; @@ -502,6 +501,7 @@ abstract class AppText { String get contactIntro; String get contactEmail; String get contactStore; + String get contactWebsite; String get contactHoursTitle; String get contactHours; @@ -564,5 +564,68 @@ abstract class AppText { String get payRetry; // 重试 / Retry String get switchPayMethod; // 换一种支付方式 / Switch payment method String get cancelOrder; // 取消订单 / Cancel order - String get qrNotSupported; // 请复制内容后在支付宝内打开 / Copy and open in Alipay + String get qrNotSupported; + String get orderCreateFailed; // 下单失败,请稍后重试 / Failed to create order, please retry(errorZh/En 为空时兜底) + String get payUnsupportedRenderType; // 暂不支持,请换支付方式 / Unsupported, switch payment method + String get payNoActiveOrder; // 暂无进行中的订单 / No active order + + // ── 支付渠道 switch + 订单 ── + String get payChannel; + String get payChannelUsdt; + String get payChannelCny; + String get perMonthSuffix; + String get ordersTitle; + String get orderDetailTitle; + String get ordersEmpty; + String get orderNoLabel; + String get orderPlanLabel; + String get orderMethodLabel; + String get orderAmountLabel; + String get orderCreatedLabel; + String get orderPaidLabel; + String get orderStatusPaid; + String get orderStatusCanceled; + String get orderContinuePay; + String get orderRefresh; + String get orderRebuy; + String get payMethodNezha; // 请复制内容后在支付宝内打开 / Copy and open in Alipay + String get orderStatusExpired; // 已过期 / Expired + String get promoBadge; // 超值月付 / Best value + String get promoOriginalPrice; // 原价 / Was + + // ── 邀请好友(Spec ②占位) ── + String get inviteEntry; // 邀请好友得会员 / Invite friends, earn membership + String get inviteEntrySub; // 好友注册 · 双方都得会员天数 / Friend signs up · both earn days + String get inviteTitle; // 邀请好友 / Invite friends + String get inviteCodeLabel; // 邀请码 / Invite code + String get inviteLinkLabel; // 邀请链接 / Invite link + String get inviteCopyCode; // 复制邀请码 / Copy code + String get inviteCopyLink; // 复制链接 / Copy link + String get inviteRewardsTitle; // 奖励规则 / Rewards + String get inviteRewardRegister; // 好友注册 · 双方各得 3 天 / Sign up · +3 days each + String get inviteRewardFirstBuy; // 好友首购 · 双方各得 7 天 / First purchase · +7 days each + String get inviteRewardJoinTg; // 加入 TG 频道 · 得 3 天 / Join TG · +3 days + String get inviteProgressTitle; // 我的奖励 / My rewards + String get inviteTasksTitle; // 更多得会员 / More ways to earn + String get inviteJoinTgTitle; // 加入 Telegram 频道 +3 天 / Join Telegram channel + String get inviteJoinTgBtn; // 加入频道 / Join channel + String get inviteVerifyBtn; // 验证领取 / Verify & claim + String get inviteTaskDone; // 已领 +3 天 / Claimed +3 days + String get inviteStatInvited; // 已邀请 / Invited + String get inviteStatConverted; // 已转化 / Converted + String get inviteStatDays; // 累计获赠(天) / Days earned + String get inviteCodeFieldLabel; // 邀请码(选填) / Invite code (optional) + // ── 通知(Spec ③占位) ── + String get notifTitle; // 通知 / Notifications + String get notifEmpty; // 暂无通知 / No notifications + String get notifTypeNews; // 新闻 / News + String get notifTypeFeature; // 功能 / Feature + String get notifTypeImportant; // 重要 / Important + String get notifTypeReward; // 到账 / Credited + String get notifTypeVersion; // 版本 / Update + String get notifTypePromo; // 活动 / Promo + // ── 设置分组 ── + String get settingsGroupConn; // 连接 / Connection + String get settingsGroupAppearance;// 外观 / Appearance + String get settingsGroupAbout; // 关于 / About } diff --git a/client/lib/l10n/strings_en.dart b/client/lib/l10n/strings_en.dart index 6057be8..a521855 100644 --- a/client/lib/l10n/strings_en.dart +++ b/client/lib/l10n/strings_en.dart @@ -1,6 +1,6 @@ // strings_en.dart — English copy resources (single-language display) // -// Red-line words forbidden: VPN / 翻墙 / 科学上网 / 突破封锁 / 自由穿越 / Go anywhere. +// Red-line words forbidden — see design/CLAUDE.md §1 rule 13 (no censorship-circumvention terms). // Positioning: network acceleration / experience optimization + privacy. // Plan numbers follow design/CLAUDE.md §7. import 'app_text.dart'; @@ -65,7 +65,7 @@ class StringsEn extends AppText { @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.'; + String get quotaExhaustedNotice => "You've used today's free time. Upgrade to keep connecting."; @override String get adPlaying => 'Ad playing…'; @override @@ -198,7 +198,7 @@ class StringsEn extends AppText { @override String get sessionRevokedOk => 'Sign in again'; @override - String get redeemEntry => 'Redeem & buy'; + String get redeemEntry => 'Redeem'; @override String get contactEntry => 'Contact us'; @override @@ -250,8 +250,6 @@ class StringsEn extends AppText { @override String get updateCheckFailed => 'Update check failed, try again later'; - @override - String get choosePlan => 'Choose plan'; @override String get freePlan => 'Free'; @override @@ -311,6 +309,8 @@ class StringsEn extends AppText { @override String get contactStore => 'Self-serve store'; @override + String get contactWebsite => 'Website'; + @override String get contactHoursTitle => 'Support hours'; @override String get contactHours => 'Daily 9:00 – 24:00 (GMT+8)'; @@ -427,4 +427,122 @@ class StringsEn extends AppText { String get cancelOrder => 'Cancel order'; @override String get qrNotSupported => 'Copy and open in Alipay'; + @override + String get orderCreateFailed => 'Failed to create order, please retry'; + @override + String get payUnsupportedRenderType => 'Unsupported, switch payment method'; + @override + String get payNoActiveOrder => 'No active order'; + + // ── 支付渠道 switch + 订单 ── + @override + String get payChannel => 'Payment channel'; + @override + String get payChannelUsdt => 'USDT · Crypto'; + @override + String get payChannelCny => 'CNY · Alipay'; + @override + String get perMonthSuffix => ' /mo'; + @override + String get ordersTitle => 'My orders'; + @override + String get orderDetailTitle => 'Order details'; + @override + String get ordersEmpty => 'No orders yet'; + @override + String get orderNoLabel => 'Order no.'; + @override + String get orderPlanLabel => 'Plan'; + @override + String get orderMethodLabel => 'Payment method'; + @override + String get orderAmountLabel => 'Amount'; + @override + String get orderCreatedLabel => 'Created'; + @override + String get orderPaidLabel => 'Paid at'; + @override + String get orderStatusPaid => 'Activated'; + @override + String get orderStatusCanceled => 'Canceled'; + @override + String get orderContinuePay => 'Continue payment'; + @override + String get orderRefresh => 'Refresh'; + @override + String get orderRebuy => 'Buy again'; + @override + String get payMethodNezha => 'Nazhapay'; + + // ── 邀请 / 通知 / 设置分组 / 订单过期(Spec ②③占位) ── + @override + String get inviteEntry => 'Invite friends, earn membership'; + @override + String get inviteEntrySub => 'Friend signs up · both earn days'; + @override + String get inviteTitle => 'Invite friends'; + @override + String get inviteCodeLabel => 'Invite code'; + @override + String get inviteLinkLabel => 'Invite link'; + @override + String get inviteCopyCode => 'Copy code'; + @override + String get inviteCopyLink => 'Copy link'; + @override + String get inviteRewardsTitle => 'Rewards'; + @override + String get inviteRewardRegister => 'Sign up · +3 days each'; + @override + String get inviteRewardFirstBuy => 'First purchase · +7 days each'; + @override + String get inviteRewardJoinTg => 'Join TG · +3 days'; + @override + String get inviteProgressTitle => 'My rewards'; + @override + String get inviteTasksTitle => 'More ways to earn'; + @override + String get inviteJoinTgTitle => 'Join Telegram channel'; + @override + String get inviteJoinTgBtn => 'Join channel'; + @override + String get inviteVerifyBtn => 'Verify & claim'; + @override + String get inviteTaskDone => 'Claimed +3 days'; + @override + String get inviteStatInvited => 'Invited'; + @override + String get inviteStatConverted => 'Converted'; + @override + String get inviteStatDays => 'Days earned'; + @override + String get inviteCodeFieldLabel => 'Invite code (optional)'; + @override + String get notifTitle => 'Notifications'; + @override + String get notifEmpty => 'No notifications'; + @override + String get notifTypeNews => 'News'; + @override + String get notifTypeFeature => 'Feature'; + @override + String get notifTypeImportant => 'Important'; + @override + String get notifTypeReward => 'Credited'; + @override + String get notifTypeVersion => 'Update'; + @override + String get notifTypePromo => 'Promo'; + @override + String get settingsGroupConn => 'Connection'; + @override + String get settingsGroupAppearance => 'Appearance'; + @override + String get settingsGroupAbout => 'About'; + @override + String get orderStatusExpired => 'Expired'; + @override + String get promoBadge => 'Limited-time offer'; + @override + String get promoOriginalPrice => 'Was'; } diff --git a/client/lib/l10n/strings_es.dart b/client/lib/l10n/strings_es.dart index 53cf6ff..d20fe0e 100644 --- a/client/lib/l10n/strings_es.dart +++ b/client/lib/l10n/strings_es.dart @@ -1,6 +1,6 @@ // strings_es.dart — Recursos de texto en español (visualización monolingüe) // -// Palabras prohibidas: VPN / traspasar muros / red científica / romper el bloqueo / cruce libre / Go anywhere. +// Palabras prohibidas: términos de evasión de censura. Lista en design/CLAUDE.md §1 regla 13. // Posicionamiento: aceleración de red / optimización de la experiencia + privacidad. // La numeración de planes sigue design/CLAUDE.md §7. import 'app_text.dart'; @@ -65,7 +65,7 @@ class StringsEs extends AppText { @override String get watchAdMore => 'Mira un anuncio para más tiempo'; @override - String get quotaExhaustedNotice => 'Tiempo gratis diario agotado. Mira un anuncio para añadir tiempo o mejora tu plan.'; + String get quotaExhaustedNotice => 'Tiempo gratis diario agotado. Mejora tu plan para seguir conectado.'; @override String get adPlaying => 'Reproduciendo anuncio…'; @override @@ -198,7 +198,7 @@ class StringsEs extends AppText { @override String get sessionRevokedOk => 'Iniciar sesión de nuevo'; @override - String get redeemEntry => 'Canjear y comprar'; + String get redeemEntry => 'Canjear'; @override String get contactEntry => 'Contáctanos'; @override @@ -250,8 +250,6 @@ class StringsEs extends AppText { @override String get updateCheckFailed => 'Falló la búsqueda de actualizaciones, inténtalo más tarde'; - @override - String get choosePlan => 'Elegir plan'; @override String get freePlan => 'Gratis'; @override @@ -311,6 +309,8 @@ class StringsEs extends AppText { @override String get contactStore => 'Tienda autoservicio'; @override + String get contactWebsite => 'Sitio web'; + @override String get contactHoursTitle => 'Horario de soporte'; @override String get contactHours => 'Todos los días 9:00 – 24:00 (GMT+8)'; @@ -370,4 +370,180 @@ class StringsEs extends AppText { String get ob3Title => 'Privado por diseño'; @override String get ob3Sub => 'Cifrado de extremo a extremo. No guardamos ningún registro de navegación.'; + + // ── 购买 / 支付 ── + @override + String get purchaseTitle => 'Comprar plan'; + @override + String get paymentTitle => 'Pago'; + @override + String get buyNow => 'Comprar ahora'; + @override + String get payMethodAlipay => 'Alipay'; + @override + String get payMethodCrypto => 'USDT (TRC20)'; + @override + String get choosePayMethod => 'Elige el método de pago'; + @override + String get proMonthly => 'Pro · Mensual'; + @override + String get proQuarterly => 'Pro · Trimestral'; + @override + String get proYearly => 'Pro · Anual'; + @override + String get perQuarter => '/trimestre'; + @override + String get perYear => '/año'; + @override + String get payAmountLabel => 'Importe a transferir'; + @override + String get payAddressLabel => 'Dirección de pago'; + @override + String get payNetworkLabel => 'Red'; + @override + String get payExactAmountHint => 'Envía el importe exacto; se activa automáticamente'; + @override + String get copied => 'Copiado'; + @override + String get openAlipay => 'Pagar con Alipay'; + @override + String get openAlipayHint => 'Vuelve tras pagar; esta página se actualiza automáticamente'; + @override + String get openAlipayFailed => 'No se pudo abrir Alipay; comprueba que esté instalado o inténtalo de nuevo'; + @override + String get awaitingPayment => 'Esperando el pago'; + @override + String get paySucceeded => 'Activado'; + @override + String get payExpiresAt => 'Válido hasta'; + @override + String get payDone => 'Listo'; + @override + String get payFailed => 'El pago falló'; + @override + String get payRetry => 'Reintentar'; + @override + String get switchPayMethod => 'Cambiar método de pago'; + @override + String get cancelOrder => 'Cancelar pedido'; + @override + String get qrNotSupported => 'Copia el contenido y ábrelo en Alipay'; + @override + String get orderCreateFailed => 'Error al crear el pedido, inténtalo de nuevo más tarde'; + @override + String get payUnsupportedRenderType => 'No compatible, cambia el método de pago'; + @override + String get payNoActiveOrder => 'No hay pedidos activos'; + + // ── 支付渠道 switch + 订单 ── + @override + String get payChannel => 'Canal de pago'; + @override + String get payChannelUsdt => 'USDT · Cripto'; + @override + String get payChannelCny => 'CNY · Alipay'; + @override + String get perMonthSuffix => ' /mes'; + @override + String get ordersTitle => 'Mis pedidos'; + @override + String get orderDetailTitle => 'Detalles del pedido'; + @override + String get ordersEmpty => 'Aún no hay pedidos'; + @override + String get orderNoLabel => 'N.º de pedido'; + @override + String get orderPlanLabel => 'Plan'; + @override + String get orderMethodLabel => 'Método de pago'; + @override + String get orderAmountLabel => 'Importe'; + @override + String get orderCreatedLabel => 'Creado'; + @override + String get orderPaidLabel => 'Pagado'; + @override + String get orderStatusPaid => 'Activado'; + @override + String get orderStatusCanceled => 'Cancelado'; + @override + String get orderContinuePay => 'Continuar pago'; + @override + String get orderRefresh => 'Actualizar'; + @override + String get orderRebuy => 'Comprar de nuevo'; + @override + String get payMethodNezha => 'Nazhapay'; + + // ── 邀请 / 通知 / 设置分组 / 订单过期(Spec ②③占位) ── + @override + String get inviteEntry => 'Invita a amigos y gana membresía'; + @override + String get inviteEntrySub => 'El amigo se registra · ambos ganan días'; + @override + String get inviteTitle => 'Invitar amigos'; + @override + String get inviteCodeLabel => 'Código de invitación'; + @override + String get inviteLinkLabel => 'Enlace de invitación'; + @override + String get inviteCopyCode => 'Copiar código'; + @override + String get inviteCopyLink => 'Copiar enlace'; + @override + String get inviteRewardsTitle => 'Reglas de recompensas'; + @override + String get inviteRewardRegister => 'Registro de amigo · +3 días cada uno'; + @override + String get inviteRewardFirstBuy => 'Primera compra del amigo · +7 días cada uno'; + @override + String get inviteRewardJoinTg => 'Unirse al canal de TG · +3 días'; + @override + String get inviteProgressTitle => 'Mis recompensas'; + @override + String get inviteTasksTitle => 'Más formas de ganar'; + @override + String get inviteJoinTgTitle => 'Unirse al canal de Telegram'; + @override + String get inviteJoinTgBtn => 'Unirse al canal'; + @override + String get inviteVerifyBtn => 'Verificar y reclamar'; + @override + String get inviteTaskDone => 'Reclamado +3 días'; + @override + String get inviteStatInvited => 'Invitados'; + @override + String get inviteStatConverted => 'Convertidos'; + @override + String get inviteStatDays => 'Días acumulados'; + @override + String get inviteCodeFieldLabel => 'Código de invitación (opcional)'; + @override + String get notifTitle => 'Notificaciones'; + @override + String get notifEmpty => 'Aún no hay notificaciones'; + @override + String get notifTypeNews => 'Noticias'; + @override + String get notifTypeFeature => 'Función'; + @override + String get notifTypeImportant => 'Importante'; + @override + String get notifTypeReward => 'Acreditado'; + @override + String get notifTypeVersion => 'Actualización'; + @override + String get notifTypePromo => 'Promoción'; + @override + String get settingsGroupConn => 'Conexión'; + @override + String get settingsGroupAppearance => 'Apariencia'; + @override + String get settingsGroupAbout => 'Acerca de'; + @override + String get orderStatusExpired => 'Caducado'; + @override + String get promoBadge => 'Oferta especial'; + @override + String get promoOriginalPrice => 'Antes'; } diff --git a/client/lib/l10n/strings_ja.dart b/client/lib/l10n/strings_ja.dart index 14268b8..f5762bc 100644 --- a/client/lib/l10n/strings_ja.dart +++ b/client/lib/l10n/strings_ja.dart @@ -1,6 +1,6 @@ // strings_ja.dart — 日本語の文言リソース(単一言語表示) // -// 禁止ワード: VPN / 翻墙 / 科学上网 / 突破封锁 / 自由穿越 / Go anywhere。 +// 禁止ワード: 検閲回避・センシティブな表現。一覧は design/CLAUDE.md §1 ルール13を参照。 // ポジショニング: ネットワーク高速化 / 体験最適化 + プライバシー。 // プラン番号は design/CLAUDE.md §7 に準拠。 import 'app_text.dart'; @@ -65,7 +65,7 @@ class StringsJa extends AppText { @override String get watchAdMore => '広告を見て時間を追加'; @override - String get quotaExhaustedNotice => '本日の無料時間を使い切りました。広告を見て時間を追加するか、アップグレードしてください。'; + String get quotaExhaustedNotice => '本日の無料時間を使い切りました。アップグレードすると続けて接続できます。'; @override String get adPlaying => '広告を再生中…'; @override @@ -198,7 +198,7 @@ class StringsJa extends AppText { @override String get sessionRevokedOk => '再度サインイン'; @override - String get redeemEntry => 'コード引き換え・購入'; + String get redeemEntry => 'コード引き換え'; @override String get contactEntry => 'お問い合わせ'; @override @@ -250,8 +250,6 @@ class StringsJa extends AppText { @override String get updateCheckFailed => 'アップデートの確認に失敗しました。後でもう一度お試しください'; - @override - String get choosePlan => 'プランを選択'; @override String get freePlan => '無料'; @override @@ -311,6 +309,8 @@ class StringsJa extends AppText { @override String get contactStore => 'セルフサービスストア'; @override + String get contactWebsite => '公式サイト'; + @override String get contactHoursTitle => 'サポート時間'; @override String get contactHours => '毎日 9:00 – 24:00 (GMT+8)'; @@ -370,4 +370,180 @@ class StringsJa extends AppText { String get ob3Title => '設計段階からプライバシー重視'; @override String get ob3Sub => 'エンドツーエンドで暗号化。閲覧ログは一切保持しません。'; + + // ── 购买 / 支付 ── + @override + String get purchaseTitle => 'プランを購入'; + @override + String get paymentTitle => 'お支払い'; + @override + String get buyNow => '今すぐ購入'; + @override + String get payMethodAlipay => 'Alipay'; + @override + String get payMethodCrypto => 'USDT (TRC20)'; + @override + String get choosePayMethod => 'お支払い方法を選択'; + @override + String get proMonthly => 'Pro · 月額'; + @override + String get proQuarterly => 'Pro · 3か月'; + @override + String get proYearly => 'Pro · 年額'; + @override + String get perQuarter => '/3か月'; + @override + String get perYear => '/年'; + @override + String get payAmountLabel => '送金額'; + @override + String get payAddressLabel => '送金先アドレス'; + @override + String get payNetworkLabel => 'ネットワーク'; + @override + String get payExactAmountHint => '正確な金額をお送りください。着金後、自動で有効になります'; + @override + String get copied => 'コピーしました'; + @override + String get openAlipay => 'Alipayで支払う'; + @override + String get openAlipayHint => 'お支払い後に戻ると、このページは自動的に更新されます'; + @override + String get openAlipayFailed => 'Alipayを開けませんでした。インストール済みか確認するか、後でもう一度お試しください'; + @override + String get awaitingPayment => 'お支払い待ち'; + @override + String get paySucceeded => '有効になりました'; + @override + String get payExpiresAt => '有効期限'; + @override + String get payDone => '完了'; + @override + String get payFailed => 'お支払いに失敗しました'; + @override + String get payRetry => '再試行'; + @override + String get switchPayMethod => '別のお支払い方法に変更'; + @override + String get cancelOrder => '注文をキャンセル'; + @override + String get qrNotSupported => '内容をコピーしてAlipayで開いてください'; + @override + String get orderCreateFailed => '注文の作成に失敗しました。しばらくして再試行してください'; + @override + String get payUnsupportedRenderType => '未対応です。支払い方法を変更してください'; + @override + String get payNoActiveOrder => '進行中の注文はありません'; + + // ── 支付渠道 switch + 订单 ── + @override + String get payChannel => '支払いチャネル'; + @override + String get payChannelUsdt => 'USDT · 暗号資産'; + @override + String get payChannelCny => '人民元 · Alipay'; + @override + String get perMonthSuffix => ' /月'; + @override + String get ordersTitle => '注文履歴'; + @override + String get orderDetailTitle => '注文の詳細'; + @override + String get ordersEmpty => '注文はありません'; + @override + String get orderNoLabel => '注文番号'; + @override + String get orderPlanLabel => 'プラン'; + @override + String get orderMethodLabel => '支払い方法'; + @override + String get orderAmountLabel => '金額'; + @override + String get orderCreatedLabel => '作成日時'; + @override + String get orderPaidLabel => '支払日時'; + @override + String get orderStatusPaid => '有効化済み'; + @override + String get orderStatusCanceled => 'キャンセル済み'; + @override + String get orderContinuePay => '支払いを続ける'; + @override + String get orderRefresh => '更新'; + @override + String get orderRebuy => '再購入'; + @override + String get payMethodNezha => 'Nazhapay'; + + // ── 邀请 / 通知 / 设置分组 / 订单过期(Spec ②③占位) ── + @override + String get inviteEntry => '友達を招待してメンバーシップを獲得'; + @override + String get inviteEntrySub => '友達が登録 · 双方に会員日数を付与'; + @override + String get inviteTitle => '友達を招待'; + @override + String get inviteCodeLabel => '招待コード'; + @override + String get inviteLinkLabel => '招待リンク'; + @override + String get inviteCopyCode => 'コードをコピー'; + @override + String get inviteCopyLink => 'リンクをコピー'; + @override + String get inviteRewardsTitle => '特典ルール'; + @override + String get inviteRewardRegister => '友達が登録 · 双方に3日間付与'; + @override + String get inviteRewardFirstBuy => '友達が初回購入 · 双方に7日間付与'; + @override + String get inviteRewardJoinTg => 'TGチャンネルに参加 · 3日間付与'; + @override + String get inviteProgressTitle => '獲得した特典'; + @override + String get inviteTasksTitle => 'もっと会員特典を得る'; + @override + String get inviteJoinTgTitle => 'Telegramチャンネルに参加'; + @override + String get inviteJoinTgBtn => 'チャンネルに参加'; + @override + String get inviteVerifyBtn => '確認して受け取る'; + @override + String get inviteTaskDone => '受け取り済み +3日'; + @override + String get inviteStatInvited => '招待した人数'; + @override + String get inviteStatConverted => '転換した人数'; + @override + String get inviteStatDays => '累計獲得日数'; + @override + String get inviteCodeFieldLabel => '招待コード(任意)'; + @override + String get notifTitle => '通知'; + @override + String get notifEmpty => '通知はありません'; + @override + String get notifTypeNews => 'ニュース'; + @override + String get notifTypeFeature => '新機能'; + @override + String get notifTypeImportant => '重要'; + @override + String get notifTypeReward => '入金'; + @override + String get notifTypeVersion => 'アップデート'; + @override + String get notifTypePromo => 'キャンペーン'; + @override + String get settingsGroupConn => '接続'; + @override + String get settingsGroupAppearance => '外観'; + @override + String get settingsGroupAbout => 'このアプリについて'; + @override + String get orderStatusExpired => '期限切れ'; + @override + String get promoBadge => '期間限定オファー'; + @override + String get promoOriginalPrice => '通常価格'; } diff --git a/client/lib/l10n/strings_ko.dart b/client/lib/l10n/strings_ko.dart index 8b37b5e..78da5bc 100644 --- a/client/lib/l10n/strings_ko.dart +++ b/client/lib/l10n/strings_ko.dart @@ -1,6 +1,6 @@ // strings_ko.dart — 한국어 문구 리소스 (단일 언어 표시) // -// 금지어: VPN / 翻墙 / 科学上网 / 突破封锁 / 自由穿越 / Go anywhere. +// 금지어: 검열 우회·민감 표현. 목록은 design/CLAUDE.md §1 규칙 13 참조. // 포지셔닝: 네트워크 가속 / 경험 최적화 + 프라이버시. // 요금제 번호는 design/CLAUDE.md §7 을 따름. import 'app_text.dart'; @@ -65,7 +65,7 @@ class StringsKo extends AppText { @override String get watchAdMore => '광고 시청으로 시간 추가'; @override - String get quotaExhaustedNotice => '오늘 무료 시간을 모두 사용했습니다. 광고를 시청해 시간을 추가하거나 업그레이드하세요.'; + String get quotaExhaustedNotice => '오늘 무료 시간을 모두 사용했습니다. 업그레이드하면 계속 연결할 수 있습니다.'; @override String get adPlaying => '광고 재생 중…'; @override @@ -198,7 +198,7 @@ class StringsKo extends AppText { @override String get sessionRevokedOk => '다시 로그인'; @override - String get redeemEntry => '코드 등록 및 구매'; + String get redeemEntry => '코드 등록'; @override String get contactEntry => '문의하기'; @override @@ -250,8 +250,6 @@ class StringsKo extends AppText { @override String get updateCheckFailed => '업데이트 확인에 실패했습니다. 잠시 후 다시 시도하세요'; - @override - String get choosePlan => '요금제 선택'; @override String get freePlan => '무료'; @override @@ -311,6 +309,8 @@ class StringsKo extends AppText { @override String get contactStore => '셀프 스토어'; @override + String get contactWebsite => '공식 웹사이트'; + @override String get contactHoursTitle => '지원 시간'; @override String get contactHours => '매일 9:00 – 24:00 (GMT+8)'; @@ -370,4 +370,180 @@ class StringsKo extends AppText { String get ob3Title => '설계부터 프라이버시'; @override String get ob3Sub => '종단 간 암호화. 브라우징 기록을 전혀 저장하지 않습니다.'; + + // ── 购买 / 支付 ── + @override + String get purchaseTitle => '요금제 구매'; + @override + String get paymentTitle => '결제'; + @override + String get buyNow => '지금 구매'; + @override + String get payMethodAlipay => 'Alipay'; + @override + String get payMethodCrypto => 'USDT (TRC20)'; + @override + String get choosePayMethod => '결제 수단 선택'; + @override + String get proMonthly => 'Pro · 월간'; + @override + String get proQuarterly => 'Pro · 분기'; + @override + String get proYearly => 'Pro · 연간'; + @override + String get perQuarter => '/분기'; + @override + String get perYear => '/년'; + @override + String get payAmountLabel => '송금액'; + @override + String get payAddressLabel => '입금 주소'; + @override + String get payNetworkLabel => '네트워크'; + @override + String get payExactAmountHint => '정확한 금액을 보내주세요. 입금 확인 후 자동으로 활성화됩니다'; + @override + String get copied => '복사됨'; + @override + String get openAlipay => 'Alipay로 결제'; + @override + String get openAlipayHint => '결제 후 돌아오면 이 페이지가 자동으로 새로고침됩니다'; + @override + String get openAlipayFailed => 'Alipay를 열 수 없습니다. 설치되어 있는지 확인하거나 다시 시도하세요'; + @override + String get awaitingPayment => '결제 대기 중'; + @override + String get paySucceeded => '활성화됨'; + @override + String get payExpiresAt => '유효기간'; + @override + String get payDone => '완료'; + @override + String get payFailed => '결제 실패'; + @override + String get payRetry => '다시 시도'; + @override + String get switchPayMethod => '다른 결제 수단으로 변경'; + @override + String get cancelOrder => '주문 취소'; + @override + String get qrNotSupported => '내용을 복사해 Alipay에서 여세요'; + @override + String get orderCreateFailed => '주문 생성에 실패했습니다. 잠시 후 다시 시도하세요'; + @override + String get payUnsupportedRenderType => '지원되지 않습니다. 결제 방법을 변경하세요'; + @override + String get payNoActiveOrder => '진행 중인 주문이 없습니다'; + + // ── 支付渠道 switch + 订单 ── + @override + String get payChannel => '결제 채널'; + @override + String get payChannelUsdt => 'USDT · 암호화폐'; + @override + String get payChannelCny => '위안화 · Alipay'; + @override + String get perMonthSuffix => ' /월'; + @override + String get ordersTitle => '내 주문'; + @override + String get orderDetailTitle => '주문 상세'; + @override + String get ordersEmpty => '주문이 없습니다'; + @override + String get orderNoLabel => '주문 번호'; + @override + String get orderPlanLabel => '플랜'; + @override + String get orderMethodLabel => '결제 수단'; + @override + String get orderAmountLabel => '금액'; + @override + String get orderCreatedLabel => '생성 시각'; + @override + String get orderPaidLabel => '결제 시각'; + @override + String get orderStatusPaid => '활성화됨'; + @override + String get orderStatusCanceled => '취소됨'; + @override + String get orderContinuePay => '결제 계속'; + @override + String get orderRefresh => '새로고침'; + @override + String get orderRebuy => '다시 구매'; + @override + String get payMethodNezha => 'Nazhapay'; + + // ── 邀请 / 通知 / 设置分组 / 订单过期(Spec ②③占位) ── + @override + String get inviteEntry => '친구 초대하고 멤버십 받기'; + @override + String get inviteEntrySub => '친구가 가입하면 · 양쪽 모두 회원 일수 획득'; + @override + String get inviteTitle => '친구 초대'; + @override + String get inviteCodeLabel => '초대 코드'; + @override + String get inviteLinkLabel => '초대 링크'; + @override + String get inviteCopyCode => '코드 복사'; + @override + String get inviteCopyLink => '링크 복사'; + @override + String get inviteRewardsTitle => '보상 규칙'; + @override + String get inviteRewardRegister => '친구 가입 · 양쪽 각 3일 지급'; + @override + String get inviteRewardFirstBuy => '친구 첫 구매 · 양쪽 각 7일 지급'; + @override + String get inviteRewardJoinTg => 'TG 채널 가입 · 3일 지급'; + @override + String get inviteProgressTitle => '내 보상'; + @override + String get inviteTasksTitle => '더 많은 혜택 받기'; + @override + String get inviteJoinTgTitle => 'Telegram 채널 가입'; + @override + String get inviteJoinTgBtn => '채널 가입'; + @override + String get inviteVerifyBtn => '확인하고 받기'; + @override + String get inviteTaskDone => '수령 완료 +3일'; + @override + String get inviteStatInvited => '초대 수'; + @override + String get inviteStatConverted => '전환 수'; + @override + String get inviteStatDays => '누적 획득(일)'; + @override + String get inviteCodeFieldLabel => '초대 코드(선택)'; + @override + String get notifTitle => '알림'; + @override + String get notifEmpty => '알림이 없습니다'; + @override + String get notifTypeNews => '소식'; + @override + String get notifTypeFeature => '기능'; + @override + String get notifTypeImportant => '중요'; + @override + String get notifTypeReward => '적립'; + @override + String get notifTypeVersion => '업데이트'; + @override + String get notifTypePromo => '프로모션'; + @override + String get settingsGroupConn => '연결'; + @override + String get settingsGroupAppearance => '화면'; + @override + String get settingsGroupAbout => '정보'; + @override + String get orderStatusExpired => '만료됨'; + @override + String get promoBadge => '한정 특가'; + @override + String get promoOriginalPrice => '정가'; } diff --git a/client/lib/l10n/strings_ru.dart b/client/lib/l10n/strings_ru.dart index 74094d1..f421ebc 100644 --- a/client/lib/l10n/strings_ru.dart +++ b/client/lib/l10n/strings_ru.dart @@ -1,6 +1,6 @@ // strings_ru.dart — русские текстовые ресурсы (отображение на одном языке) // -// Запрещённые слова: VPN /翻墙 / 科学上网 / 突破封锁 / 自由穿越 / Go anywhere / ВПН. +// Запрещённые слова: термины обхода цензуры. Список см. design/CLAUDE.md §1 правило 13. // Позиционирование: ускорение сети / оптимизация опыта + приватность. // Номера тарифов соответствуют design/CLAUDE.md §7. import 'app_text.dart'; @@ -65,7 +65,7 @@ class StringsRu extends AppText { @override String get watchAdMore => 'Смотрите рекламу, чтобы добавить время'; @override - String get quotaExhaustedNotice => 'Дневной бесплатный лимит исчерпан. Посмотрите рекламу, чтобы добавить время, или оформите подписку.'; + String get quotaExhaustedNotice => 'Дневной бесплатный лимит исчерпан. Оформите подписку, чтобы продолжить подключение.'; @override String get adPlaying => 'Реклама воспроизводится…'; @override @@ -198,7 +198,7 @@ class StringsRu extends AppText { @override String get sessionRevokedOk => 'Войти снова'; @override - String get redeemEntry => 'Активация и покупка'; + String get redeemEntry => 'Активация'; @override String get contactEntry => 'Связаться с нами'; @override @@ -250,8 +250,6 @@ class StringsRu extends AppText { @override String get updateCheckFailed => 'Не удалось проверить обновления, попробуйте позже'; - @override - String get choosePlan => 'Выбор тарифа'; @override String get freePlan => 'Бесплатный'; @override @@ -311,6 +309,8 @@ class StringsRu extends AppText { @override String get contactStore => 'Магазин самообслуживания'; @override + String get contactWebsite => 'Веб-сайт'; + @override String get contactHoursTitle => 'Часы поддержки'; @override String get contactHours => 'Ежедневно 9:00 – 24:00 (GMT+8)'; @@ -370,4 +370,180 @@ class StringsRu extends AppText { String get ob3Title => 'Приватность по умолчанию'; @override String get ob3Sub => 'Сквозное шифрование. Мы не храним журналы просмотров.'; + + // ── 购买 / 支付 ── + @override + String get purchaseTitle => 'Покупка тарифа'; + @override + String get paymentTitle => 'Оплата'; + @override + String get buyNow => 'Купить'; + @override + String get payMethodAlipay => 'Alipay'; + @override + String get payMethodCrypto => 'USDT (TRC20)'; + @override + String get choosePayMethod => 'Выберите способ оплаты'; + @override + String get proMonthly => 'Pro · Месяц'; + @override + String get proQuarterly => 'Pro · Квартал'; + @override + String get proYearly => 'Pro · Год'; + @override + String get perQuarter => '/квартал'; + @override + String get perYear => '/год'; + @override + String get payAmountLabel => 'Сумма перевода'; + @override + String get payAddressLabel => 'Адрес для оплаты'; + @override + String get payNetworkLabel => 'Сеть'; + @override + String get payExactAmountHint => 'Отправьте точную сумму — активация произойдёт автоматически'; + @override + String get copied => 'Скопировано'; + @override + String get openAlipay => 'Оплатить через Alipay'; + @override + String get openAlipayHint => 'Вернитесь после оплаты — страница обновится автоматически'; + @override + String get openAlipayFailed => 'Не удалось открыть Alipay. Проверьте, установлен ли он, или повторите'; + @override + String get awaitingPayment => 'Ожидание оплаты'; + @override + String get paySucceeded => 'Активировано'; + @override + String get payExpiresAt => 'Действует до'; + @override + String get payDone => 'Готово'; + @override + String get payFailed => 'Оплата не удалась'; + @override + String get payRetry => 'Повторить'; + @override + String get switchPayMethod => 'Сменить способ оплаты'; + @override + String get cancelOrder => 'Отменить заказ'; + @override + String get qrNotSupported => 'Скопируйте и откройте в Alipay'; + @override + String get orderCreateFailed => 'Не удалось создать заказ, повторите попытку позже'; + @override + String get payUnsupportedRenderType => 'Не поддерживается, смените способ оплаты'; + @override + String get payNoActiveOrder => 'Нет активных заказов'; + + // ── 支付渠道 switch + 订单 ── + @override + String get payChannel => 'Способ оплаты'; + @override + String get payChannelUsdt => 'USDT · Крипто'; + @override + String get payChannelCny => 'Юань · Alipay'; + @override + String get perMonthSuffix => ' /мес'; + @override + String get ordersTitle => 'Мои заказы'; + @override + String get orderDetailTitle => 'Детали заказа'; + @override + String get ordersEmpty => 'Заказов пока нет'; + @override + String get orderNoLabel => 'Номер заказа'; + @override + String get orderPlanLabel => 'Тариф'; + @override + String get orderMethodLabel => 'Способ оплаты'; + @override + String get orderAmountLabel => 'Сумма'; + @override + String get orderCreatedLabel => 'Создан'; + @override + String get orderPaidLabel => 'Оплачен'; + @override + String get orderStatusPaid => 'Активирован'; + @override + String get orderStatusCanceled => 'Отменён'; + @override + String get orderContinuePay => 'Продолжить оплату'; + @override + String get orderRefresh => 'Обновить'; + @override + String get orderRebuy => 'Купить снова'; + @override + String get payMethodNezha => 'Nazhapay'; + + // ── 邀请 / 通知 / 设置分组 / 订单过期(Spec ②③占位) ── + @override + String get inviteEntry => 'Приглашайте друзей и получайте членство'; + @override + String get inviteEntrySub => 'Друг регистрируется · оба получают дни подписки'; + @override + String get inviteTitle => 'Пригласить друзей'; + @override + String get inviteCodeLabel => 'Код приглашения'; + @override + String get inviteLinkLabel => 'Ссылка приглашения'; + @override + String get inviteCopyCode => 'Скопировать код'; + @override + String get inviteCopyLink => 'Скопировать ссылку'; + @override + String get inviteRewardsTitle => 'Правила наград'; + @override + String get inviteRewardRegister => 'Регистрация друга · +3 дня каждому'; + @override + String get inviteRewardFirstBuy => 'Первая покупка друга · +7 дней каждому'; + @override + String get inviteRewardJoinTg => 'Вступление в TG-канал · +3 дня'; + @override + String get inviteProgressTitle => 'Мои награды'; + @override + String get inviteTasksTitle => 'Больше способов заработать'; + @override + String get inviteJoinTgTitle => 'Вступить в Telegram-канал'; + @override + String get inviteJoinTgBtn => 'Вступить в канал'; + @override + String get inviteVerifyBtn => 'Проверить и получить'; + @override + String get inviteTaskDone => 'Получено +3 дня'; + @override + String get inviteStatInvited => 'Приглашено'; + @override + String get inviteStatConverted => 'Конвертировано'; + @override + String get inviteStatDays => 'Всего получено (дней)'; + @override + String get inviteCodeFieldLabel => 'Код приглашения (необязательно)'; + @override + String get notifTitle => 'Уведомления'; + @override + String get notifEmpty => 'Пока нет уведомлений'; + @override + String get notifTypeNews => 'Новости'; + @override + String get notifTypeFeature => 'Функции'; + @override + String get notifTypeImportant => 'Важное'; + @override + String get notifTypeReward => 'Начислено'; + @override + String get notifTypeVersion => 'Обновление'; + @override + String get notifTypePromo => 'Акция'; + @override + String get settingsGroupConn => 'Подключение'; + @override + String get settingsGroupAppearance => 'Внешний вид'; + @override + String get settingsGroupAbout => 'О приложении'; + @override + String get orderStatusExpired => 'Истёк'; + @override + String get promoBadge => 'Спецпредложение'; + @override + String get promoOriginalPrice => 'Было'; } diff --git a/client/lib/l10n/strings_zh.dart b/client/lib/l10n/strings_zh.dart index 11584ae..c1a100a 100644 --- a/client/lib/l10n/strings_zh.dart +++ b/client/lib/l10n/strings_zh.dart @@ -1,6 +1,6 @@ // strings_zh.dart — 中文文案资源(单显) // -// 红线词禁用:VPN / 翻墙 / 科学上网 / 突破封锁 / 自由穿越 / Go anywhere。 +// 红线词禁用:审查规避/敏感定位词,清单见 design/CLAUDE.md §1 铁律 13。 // 脱敏口径:网络加速 / 体验优化 + 隐私保护。套餐数字以 design/CLAUDE.md §7 为准。 import 'app_text.dart'; @@ -64,7 +64,7 @@ class StringsZh extends AppText { @override String get watchAdMore => '看广告加时'; @override - String get quotaExhaustedNotice => '今日免费时长已用完,看广告加时或升级会员'; + String get quotaExhaustedNotice => '今日免费时长已用完,升级会员可继续使用'; @override String get adPlaying => '广告播放中…'; @override @@ -197,7 +197,7 @@ class StringsZh extends AppText { @override String get sessionRevokedOk => '重新登录'; @override - String get redeemEntry => '兑换 & 购买'; + String get redeemEntry => '兑换'; @override String get contactEntry => '联系我们'; @override @@ -249,8 +249,6 @@ class StringsZh extends AppText { @override String get updateCheckFailed => '检查更新失败,请稍后重试'; - @override - String get choosePlan => '选择套餐'; @override String get freePlan => '免费版'; @override @@ -305,6 +303,8 @@ class StringsZh extends AppText { @override String get contactStore => '自助发卡商店'; @override + String get contactWebsite => '官网'; + @override String get contactHoursTitle => '服务时间'; @override String get contactHours => '每日 9:00 – 24:00 (GMT+8)'; @@ -421,4 +421,122 @@ class StringsZh extends AppText { String get cancelOrder => '取消订单'; @override String get qrNotSupported => '请复制内容后在支付宝内打开'; + @override + String get orderCreateFailed => '下单失败,请稍后重试'; + @override + String get payUnsupportedRenderType => '暂不支持,请换支付方式'; + @override + String get payNoActiveOrder => '暂无进行中的订单'; + + // ── 支付渠道 switch + 订单 ── + @override + String get payChannel => '支付渠道'; + @override + String get payChannelUsdt => 'USDT · 加密货币'; + @override + String get payChannelCny => '人民币 · 支付宝'; + @override + String get perMonthSuffix => ' /月'; + @override + String get ordersTitle => '我的订单'; + @override + String get orderDetailTitle => '订单详情'; + @override + String get ordersEmpty => '暂无订单'; + @override + String get orderNoLabel => '订单号'; + @override + String get orderPlanLabel => '套餐'; + @override + String get orderMethodLabel => '支付方式'; + @override + String get orderAmountLabel => '金额'; + @override + String get orderCreatedLabel => '创建时间'; + @override + String get orderPaidLabel => '付款时间'; + @override + String get orderStatusPaid => '已开通'; + @override + String get orderStatusCanceled => '已取消'; + @override + String get orderContinuePay => '继续支付'; + @override + String get orderRefresh => '刷新状态'; + @override + String get orderRebuy => '重新购买'; + @override + String get payMethodNezha => '哪吒支付'; + + // ── 邀请 / 通知 / 设置分组 / 订单过期(Spec ②③占位) ── + @override + String get inviteEntry => '邀请好友得会员'; + @override + String get inviteEntrySub => '好友注册 · 双方都得会员天数'; + @override + String get inviteTitle => '邀请好友'; + @override + String get inviteCodeLabel => '邀请码'; + @override + String get inviteLinkLabel => '邀请链接'; + @override + String get inviteCopyCode => '复制邀请码'; + @override + String get inviteCopyLink => '复制链接'; + @override + String get inviteRewardsTitle => '奖励规则'; + @override + String get inviteRewardRegister => '好友注册 · 双方各得 3 天'; + @override + String get inviteRewardFirstBuy => '好友首购 · 双方各得 7 天'; + @override + String get inviteRewardJoinTg => '加入 TG 频道 · 得 3 天'; + @override + String get inviteProgressTitle => '我的奖励'; + @override + String get inviteTasksTitle => '更多得会员'; + @override + String get inviteJoinTgTitle => '加入 Telegram 频道'; + @override + String get inviteJoinTgBtn => '加入频道'; + @override + String get inviteVerifyBtn => '验证领取'; + @override + String get inviteTaskDone => '已领 +3 天'; + @override + String get inviteStatInvited => '已邀请'; + @override + String get inviteStatConverted => '已转化'; + @override + String get inviteStatDays => '累计获赠(天)'; + @override + String get inviteCodeFieldLabel => '邀请码(选填)'; + @override + String get notifTitle => '通知'; + @override + String get notifEmpty => '暂无通知'; + @override + String get notifTypeNews => '新闻'; + @override + String get notifTypeFeature => '功能'; + @override + String get notifTypeImportant => '重要'; + @override + String get notifTypeReward => '到账'; + @override + String get notifTypeVersion => '版本'; + @override + String get notifTypePromo => '活动'; + @override + String get settingsGroupConn => '连接'; + @override + String get settingsGroupAppearance => '外观'; + @override + String get settingsGroupAbout => '关于'; + @override + String get orderStatusExpired => '已过期'; + @override + String get promoBadge => '限时优惠'; + @override + String get promoOriginalPrice => '原价'; } diff --git a/client/lib/main.dart b/client/lib/main.dart index 3262114..16fdc16 100644 --- a/client/lib/main.dart +++ b/client/lib/main.dart @@ -229,8 +229,12 @@ class _RootFlowState extends ConsumerState { logLine('AutoConnect', 'triggering connect → ${node.code}'); ref.read(connectionProvider.notifier).toggle(); // 自动连接 toast:「正在自动连接到 X」(主显示区内居中,见 pangolin_toast)。 + // 瞬态信息,1.5s 即可(默认 3s 用户反馈偏长;与「已复制」1.4s 同量级)。 final t = ref.read(appTextProvider); - if (mounted) showPangolinToast(context, t.autoConnectingTo(node.localizedName(t.lang))); + if (mounted) { + showPangolinToast(context, t.autoConnectingTo(node.localizedName(t.lang)), + duration: const Duration(milliseconds: 1500)); + } } else { logLine('AutoConnect', 'NOT connecting (autoConnect=${settings.autoConnect} phase=$phase)'); } diff --git a/client/lib/models/payment.dart b/client/lib/models/payment.dart index 1591a50..c2d8492 100644 --- a/client/lib/models/payment.dart +++ b/client/lib/models/payment.dart @@ -1,6 +1,16 @@ // payment.dart — pay v2 支付领域模型(server 代理端点的响应形状)。 // 金额只读展示:price_minor 为 CNY 分;crypto 精确金额在 session.payload 里。 +/// 支付渠道:结算币种即支付方式(一步定)。usdt→加密货币 / cny→支付宝。 +/// 各币种一口价(不做实时汇率换算);实际扣款以 pay 侧为准。 +enum PayChannel { usdt, cny } + +extension PayChannelX on PayChannel { + String get currency => this == PayChannel.usdt ? 'USDT' : 'CNY'; + // CNY 渠道生产走哪吒(nezha)聚合(底层支付宝/微信),非直连 alipay 商户。 + String get method => this == PayChannel.usdt ? 'crypto' : 'nezha'; +} + class PayCatalogItem { const PayCatalogItem({ required this.sku, @@ -8,13 +18,17 @@ class PayCatalogItem { required this.days, required this.priceMinor, required this.currency, + this.priceUsdtMicro = 0, + this.promo = false, }); final String sku; final String plan; final int days; - final int priceMinor; - final String currency; + final int priceMinor; // CNY 分(展示) + final String currency; // CNY + final int priceUsdtMicro; // USDT 微元(展示;各币种一口价) + final bool promo; // 全员可见的限时优惠档(如 pro_month_promo) factory PayCatalogItem.fromJson(Map j) => PayCatalogItem( sku: j['sku'] as String, @@ -22,9 +36,71 @@ class PayCatalogItem { days: (j['days'] as num?)?.toInt() ?? 0, priceMinor: (j['price_minor'] as num?)?.toInt() ?? 0, currency: j['currency'] as String? ?? 'CNY', + priceUsdtMicro: (j['price_usdt_micro'] as num?)?.toInt() ?? 0, + promo: j['promo'] as bool? ?? false, ); - String priceLabel() => '¥${(priceMinor / 100).toStringAsFixed(2)}'; + /// 约当月数(31→1 / 92→3 / 366→12),用于每月折算展示。 + int get months { + final m = (days / 30.4).round(); + return m < 1 ? 1 : m; + } + + /// 按渠道币种给总价文案(USDT $ / CNY ¥)。 + String priceLabel(PayChannel ch) => ch == PayChannel.usdt + ? '\$${(priceUsdtMicro / 1000000).toStringAsFixed(2)}' + : '¥${(priceMinor / 100).toStringAsFixed(2)}'; + + /// 按渠道币种给每月折算文案(不含 /月 后缀,后缀由 l10n 提供)。 + String perMonthLabel(PayChannel ch) => ch == PayChannel.usdt + ? '\$${(priceUsdtMicro / 1000000 / months).toStringAsFixed(2)}' + : '¥${(priceMinor / 100 / months).toStringAsFixed(2)}'; +} + +/// PayOrderSummary — 订单列表项(server orderView 台账视图)。 +/// 金额为结算币种 minor:CNY 分 / USDT 微元。 +class PayOrderSummary { + const PayOrderSummary({ + required this.orderNo, + required this.sku, + required this.plan, + required this.days, + required this.method, + required this.status, + required this.amountMinor, + required this.currency, + required this.createdAt, + this.paidAt, + }); + + final String orderNo; + final String sku; + final String plan; + final int days; + final String method; // crypto | alipay | nezha … + final String status; // created | paid | canceled(本地台账) + final int amountMinor; + final String currency; // CNY | USDT + final DateTime? createdAt; + final DateTime? paidAt; + + factory PayOrderSummary.fromJson(Map j) => PayOrderSummary( + orderNo: j['order_no'] as String? ?? '', + sku: j['sku'] as String? ?? '', + plan: j['plan'] as String? ?? 'pro', + days: (j['days'] as num?)?.toInt() ?? 0, + method: j['method'] as String? ?? '', + status: j['status'] as String? ?? '', + amountMinor: (j['amount_minor'] as num?)?.toInt() ?? 0, + currency: j['currency'] as String? ?? 'CNY', + createdAt: j['created_at'] == null ? null : DateTime.tryParse(j['created_at'] as String), + paidAt: j['paid_at'] == null ? null : DateTime.tryParse(j['paid_at'] as String), + ); + + /// 结算币种金额文案(USDT 6 位、CNY 2 位)。 + String amountLabel() => currency == 'USDT' + ? '\$${(amountMinor / 1000000).toStringAsFixed(2)}' + : '¥${(amountMinor / 100).toStringAsFixed(2)}'; } class PaySession { @@ -53,23 +129,29 @@ class PayOrder { ); } +/// PayOrderStatus — 查单/详情响应。轮询只用 activated;订单详情页额外用 +/// 台账富字段(server GetOrder 现回带 orderView 全字段)。 class PayOrderStatus { const PayOrderStatus({ required this.orderNo, required this.payStatus, required this.activated, this.expiresAt, + this.summary, }); final String orderNo; final String payStatus; final bool activated; // server 台账已消费 = 权益已开通(轮询成功判据) final DateTime? expiresAt; + final PayOrderSummary? summary; // 台账富字段(sku/plan/method/金额/时间…);详情页用 factory PayOrderStatus.fromJson(Map j) => PayOrderStatus( orderNo: j['order_no'] as String? ?? '', payStatus: j['pay_status'] as String? ?? '', activated: j['activated'] as bool? ?? false, expiresAt: j['expires_at'] == null ? null : DateTime.tryParse(j['expires_at'] as String), + // 详情端点回带台账字段(列表端点同形);sku 存在才解析。 + summary: j['sku'] == null ? null : PayOrderSummary.fromJson(j), ); } diff --git a/client/lib/screens/account_page.dart b/client/lib/screens/account_page.dart index b656f00..3f6a775 100644 --- a/client/lib/screens/account_page.dart +++ b/client/lib/screens/account_page.dart @@ -1,4 +1,4 @@ -// account_page.dart — 账户页(套餐横幅 + 账户信息 + 设备/兑换/联系 + 语言/主题) +// account_page.dart — 账户页(套餐横幅 + 账户信息 + 购买/订单/兑换 + 邀请 + 设备/设置/联系) import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -9,9 +9,15 @@ import '../state/account_providers.dart'; import '../state/app_providers.dart'; import '../state/auth_provider.dart'; import '../state/navigation_provider.dart'; +import '../state/notices_provider.dart'; import '../widgets/account_screens.dart'; import '../widgets/app_top_bar.dart'; +import '../widgets/notification_bell.dart'; +import '../widgets/page_body.dart'; import '../widgets/pangolin_icons.dart'; +import 'invite_page.dart'; +import 'notifications_page.dart'; +import 'orders_page.dart'; import 'payment_page.dart'; import 'purchase_page.dart'; @@ -34,8 +40,6 @@ class AccountPage extends ConsumerWidget { final me = ref.watch(meProvider).valueOrNull; final email = (me?.email.isNotEmpty ?? false) ? me!.email : '—'; final expiresLabel = me?.expiresAt != null ? _fmtDate(me!.expiresAt!) : ''; - final lang = ref.watch(localeProvider); - final isDark = Theme.of(context).brightness == Brightness.dark; final pad = isWide ? 28.0 : 20.0; // desktop 在内容区下钻(切 navView);mobile/tablet 全屏 push。 @@ -48,7 +52,7 @@ class AccountPage extends ConsumerWidget { } } - final body = ListView( + final body = PageBody(child: ListView( padding: EdgeInsets.fromLTRB(pad, isWide ? 6 : 0, pad, 24), children: [ _PlanBanner( @@ -56,21 +60,13 @@ class AccountPage extends ConsumerWidget { isFree: isFree, email: email, expiresLabel: expiresLabel, - // mobile/tablet 全屏 push(desktop 走 open() 的 navView 分支,mobilePage 参数被忽略, - // 由 desktop_shell 自己那份 PlansScreen(onChoose: ...) 接线,见 desktop_shell.dart)。 + // Renew/Upgrade 直达购买页(套餐 + 渠道 switch),不再经 PlansScreen。 onUpgrade: () => open( - NavView.plans, - PlansScreen( + NavView.purchase, + PurchaseScreen( t: t, - onChoose: (code) => code == 'pro' - ? Navigator.of(context).push(MaterialPageRoute( - builder: (_) => PurchaseScreen( - t: t, - onOrderCreated: () => Navigator.of(context) - .push(MaterialPageRoute(builder: (_) => PaymentScreen(t: t))), - ), - )) - : Navigator.of(context).push(MaterialPageRoute(builder: (_) => RedeemScreen(t: t))), + onOrderCreated: () => Navigator.of(context).push(MaterialPageRoute( + builder: (_) => PaymentScreen(t: t, onDone: () => Navigator.of(context).pop()))), ), ), ), @@ -83,42 +79,47 @@ class AccountPage extends ConsumerWidget { _InfoRow(icon: PangolinIcons.lock, title: t.accPassword, value: '••••••••••', trailing: _changeHint(c, t)), ]), const SizedBox(height: 16), - // 设备 / 兑换 / 联系 + // 购买套餐 / 我的订单 / 兑换码(独立一块,紧跟账户信息) _Card(children: [ - _NavRow(icon: PangolinIcons.monitorSmartphone, title: t.myDevices, onTap: () => open(NavView.devices, DevicesScreen(t: t))), + _NavRow( + icon: PangolinIcons.crown, + title: t.purchaseTitle, + onTap: () => open( + NavView.purchase, + PurchaseScreen( + t: t, + onOrderCreated: () => Navigator.of(context).push(MaterialPageRoute( + builder: (_) => + PaymentScreen(t: t, onDone: () => Navigator.of(context).pop()))), + ), + )), Divider(height: 1, color: c.border), - _NavRow(icon: PangolinIcons.settings, title: t.settingsTitle, onTap: () => open(NavView.settings, SettingsScreen(t: t))), + _NavRow(icon: PangolinIcons.ticket, title: t.ordersTitle, onTap: () => open(NavView.orders, OrdersScreen(t: t))), Divider(height: 1, color: c.border), _NavRow(icon: PangolinIcons.shoppingBag, title: t.redeemEntry, onTap: () => open(NavView.redeem, RedeemScreen(t: t))), - Divider(height: 1, color: c.border), - _NavRow(icon: PangolinIcons.messageCircle, title: t.contactEntry, onTap: () => open(NavView.contact, ContactScreen(t: t))), ]), const SizedBox(height: 16), - // 语言 / 主题 / 协议 + // 邀请好友 _Card(children: [ - _CustomRow( - icon: PangolinIcons.globe, - title: t.language, - trailing: _LangSwitch(lang: lang, onChange: (l) => ref.read(localeProvider.notifier).set(l)), - ), - Divider(height: 1, color: c.border), - _CustomRow( - icon: isDark ? PangolinIcons.moon : PangolinIcons.sun, - title: t.darkAppearance, - subtitle: isDark ? t.stateOn : t.followLight, - trailing: _PangolinSwitch( - value: isDark, - onChanged: (v) => ref.read(themeModeProvider.notifier).state = v ? ThemeMode.dark : ThemeMode.light, - ), - ), - Divider(height: 1, color: c.border), - _CustomRow( - icon: PangolinIcons.shield, - title: t.protocol, - trailing: Text('REALITY / Hysteria2', style: PangolinText.mono.copyWith(color: c.fg3, fontSize: 13)), + _NavRow( + icon: PangolinIcons.gift, + title: t.inviteEntry, + subtitle: t.inviteEntrySub, + onTap: () => open(NavView.invite, InviteScreen(t: t)), ), ]), const SizedBox(height: 16), + // 设备 / 设置 / 联系(设置/联系桌面侧栏已有,仅 mobile/tablet 在此下钻) + _Card(children: [ + _NavRow(icon: PangolinIcons.monitorSmartphone, title: t.myDevices, onTap: () => open(NavView.devices, DevicesScreen(t: t))), + if (context.formFactor == FormFactor.mobile) ...[ + Divider(height: 1, color: c.border), + _NavRow(icon: PangolinIcons.settings, title: t.settingsTitle, onTap: () => open(NavView.settings, SettingsScreen(t: t))), + Divider(height: 1, color: c.border), + _NavRow(icon: PangolinIcons.messageCircle, title: t.contactEntry, onTap: () => open(NavView.contact, ContactScreen(t: t))), + ], + ]), + const SizedBox(height: 16), _Card(children: [ _NavRow( icon: PangolinIcons.logOut, @@ -129,11 +130,17 @@ class AccountPage extends ConsumerWidget { ), ]), ], - ); + )); if (isWide) return body; return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - AppTopBar(brand: t.brand), + AppTopBar( + brand: t.brand, + trailing: NotificationBell( + hasUnread: (ref.watch(noticesProvider).valueOrNull?.unreadCount ?? 0) > 0, + onTap: () => open(NavView.notifications, NotificationsScreen(t: t)), + ), + ), PageTitle(title: t.meTitle), Expanded(child: body), ]); @@ -306,9 +313,10 @@ class _InfoRow extends StatelessWidget { } class _NavRow extends StatelessWidget { - const _NavRow({required this.icon, required this.title, required this.onTap, this.danger = false}); + const _NavRow({required this.icon, required this.title, required this.onTap, this.subtitle, this.danger = false}); final IconData icon; final String title; + final String? subtitle; final VoidCallback onTap; final bool danger; @override @@ -321,7 +329,17 @@ class _NavRow extends StatelessWidget { child: Row(children: [ Icon(icon, size: 20, color: danger ? c.danger : c.accent), const SizedBox(width: 13), - Expanded(child: Text(title, style: PangolinText.body.copyWith(color: danger ? c.danger : c.fg1, fontWeight: FontWeight.w500))), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(title, style: PangolinText.body.copyWith(color: danger ? c.danger : c.fg1, fontWeight: FontWeight.w500)), + if (subtitle != null) + Text(subtitle!, style: PangolinText.caption.copyWith(color: c.fg3)), + ], + ), + ), if (!danger) Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3), ]), ), @@ -329,30 +347,6 @@ class _NavRow extends StatelessWidget { } } -class _CustomRow extends StatelessWidget { - const _CustomRow({required this.icon, required this.title, this.subtitle, required this.trailing}); - final IconData icon; - final String title; - final String? subtitle; - final Widget trailing; - @override - Widget build(BuildContext context) { - final c = context.pangolin; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), - child: Row(children: [ - _IconBox(icon: icon), - const SizedBox(width: 13), - Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w500, fontSize: 15)), - if (subtitle != null) Text(subtitle!, style: PangolinText.caption.copyWith(color: c.fg3)), - ])), - trailing, - ]), - ); - } -} - class _IconBox extends StatelessWidget { const _IconBox({required this.icon}); final IconData icon; @@ -366,74 +360,3 @@ class _IconBox extends StatelessWidget { ); } } - -/// 段控:中文 / EN(单显切换)。 -class _LangSwitch extends StatelessWidget { - const _LangSwitch({required this.lang, required this.onChange}); - final AppLang lang; - final ValueChanged onChange; - @override - Widget build(BuildContext context) { - final c = context.pangolin; - // 6 种语言用下拉(段控横排会挤)。 - return PopupMenuButton( - initialValue: lang, - onSelected: onChange, - color: c.surface, - elevation: 8, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(PangolinRadius.md)), - itemBuilder: (_) => [ - for (final l in AppLang.values) - PopupMenuItem( - value: l, - height: 42, - child: Text(l.nativeLabel, - style: PangolinText.caption.copyWith( - color: l == lang ? c.accent : c.fg1, - fontWeight: l == lang ? FontWeight.w700 : FontWeight.w500, - fontSize: 13)), - ), - ], - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 6), - decoration: - BoxDecoration(color: c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.full)), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - Text(lang.nativeLabel, - style: PangolinText.caption - .copyWith(color: c.fg2, fontWeight: FontWeight.w700, fontSize: 12.5)), - const SizedBox(width: 4), - Icon(PangolinIcons.chevronDown, size: 14, color: c.fg3), - ]), - ), - ); - } -} - -class _PangolinSwitch extends StatelessWidget { - const _PangolinSwitch({required this.value, required this.onChanged}); - final bool value; - final ValueChanged onChanged; - @override - Widget build(BuildContext context) { - final c = context.pangolin; - return GestureDetector( - onTap: () => onChanged(!value), - child: AnimatedContainer( - duration: PangolinMotion.base, - curve: PangolinMotion.easeOut, - width: 46, height: 28, - decoration: BoxDecoration(color: value ? c.accent : c.borderStrong, borderRadius: BorderRadius.circular(PangolinRadius.full)), - child: AnimatedAlign( - duration: PangolinMotion.base, - curve: PangolinMotion.easeOut, - alignment: value ? Alignment.centerRight : Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.all(3), - child: Container(width: 22, height: 22, decoration: const BoxDecoration(color: PangolinColors.white, shape: BoxShape.circle)), - ), - ), - ), - ); - } -} diff --git a/client/lib/screens/connect_page.dart b/client/lib/screens/connect_page.dart index 657c928..95ff232 100644 --- a/client/lib/screens/connect_page.dart +++ b/client/lib/screens/connect_page.dart @@ -10,13 +10,10 @@ import '../pangolin_theme.dart'; 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'; import '../widgets/pangolin_icons.dart'; -import '../widgets/quota_card.dart'; class ConnectPage extends ConsumerWidget { const ConnectPage({super.key, required this.isWide, required this.onOpenNodes}); @@ -31,8 +28,6 @@ class ConnectPage extends ConsumerWidget { final conn = ref.watch(connectionProvider); final node = ref.watch(effectiveNodeProvider); final smart = ref.watch(isSmartSelectProvider); - final isFree = ref.watch(isFreePlanProvider); - final quota = ref.watch(quotaProvider); final stats = ref.watch(vpnStatsProvider).valueOrNull; // 账户首拉中 → 整页转圈,先拉到 me(决定免费/会员)再渲染, // 避免「先闪免费广告、me 到了再跳会员」。me 是常驻 Notifier,只首启时转一下。 @@ -53,18 +48,11 @@ 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(), ); @@ -82,14 +70,6 @@ class ConnectPage extends ConsumerWidget { ]); final infoChildren = [ - if (isFree) - 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), ]; @@ -114,8 +94,6 @@ 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), @@ -132,19 +110,6 @@ class ConnectPage extends ConsumerWidget { ), ], _NodePill(t: t, node: node, smart: smart, pingLabel: pingLabel), - if (isFree) ...[ - const SizedBox(height: 24), - SizedBox( - width: 340, - child: QuotaCard( - quota: quota, - t: t, - countdown: conn.freeCountdown, - isDesktop: isDesktop, - onWatchAd: onQuotaBlockedTap, - ), - ), - ], if (conn.phase == VpnPhase.on) ...[ const SizedBox(height: 16), _SpeedRow(t: t, down: down, up: up, latency: latencyValue), diff --git a/client/lib/screens/contact_page.dart b/client/lib/screens/contact_page.dart index 0ee0adb..6d90840 100644 --- a/client/lib/screens/contact_page.dart +++ b/client/lib/screens/contact_page.dart @@ -7,7 +7,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../l10n/app_text.dart'; import '../pangolin_theme.dart'; +import '../services/channel_launch.dart'; +import '../services/contact_channels.dart'; import '../state/app_providers.dart'; +import '../widgets/page_body.dart'; import '../widgets/pangolin_icons.dart'; class ContactPage extends ConsumerWidget { @@ -18,17 +21,23 @@ class ContactPage extends ConsumerWidget { final c = context.pangolin; final t = ref.watch(appTextProvider); - final channels = <({IconData icon, String name, String sub, bool accent})>[ - (icon: PangolinIcons.send, name: 'Telegram', sub: '@PangolinVPN_bot', accent: true), - (icon: PangolinIcons.messageCircle, name: 'LINE', sub: '@pangolinvpn', accent: false), - (icon: PangolinIcons.mail, name: t.contactEmail, sub: 'support@pangolin.vpn', accent: false), - (icon: PangolinIcons.shoppingBag, name: t.contactStore, sub: 'shop.pangolin.vpn', accent: false), - ]; + final channels = kContactChannels + .map((ch) => ( + icon: ch.icon, + name: switch (ch.nameKey) { + 'telegram' => 'Telegram', + 'email' => t.contactEmail, + 'website' => t.contactWebsite, + _ => ch.nameKey, + }, + sub: ch.sub, + accent: ch.accent, + )) + .toList(); return SingleChildScrollView( padding: const EdgeInsets.fromLTRB(32, 16, 32, 24), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 560), + child: PageBody( child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text(t.contactIntro, style: PangolinText.sm.copyWith(color: c.fg2)), const SizedBox(height: 16), @@ -51,7 +60,9 @@ class _ChannelCard extends StatelessWidget { @override Widget build(BuildContext context) { final c = context.pangolin; - return Container( + return GestureDetector( + onTap: () => launchChannel(channel.sub), + child: Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: c.surface, @@ -78,7 +89,7 @@ class _ChannelCard extends StatelessWidget { ), Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3), ]), - ); + )); } } diff --git a/client/lib/screens/device_limit_screen.dart b/client/lib/screens/device_limit_screen.dart index 6be3240..c5a6854 100644 --- a/client/lib/screens/device_limit_screen.dart +++ b/client/lib/screens/device_limit_screen.dart @@ -12,6 +12,7 @@ import '../services/auth_api.dart'; import '../services/device_identity.dart'; import '../state/account_providers.dart'; import '../state/auth_provider.dart'; +import '../widgets/pangolin_icons.dart'; class DeviceLimitScreen extends ConsumerStatefulWidget { const DeviceLimitScreen({super.key, required this.t, required this.limit}); @@ -84,7 +85,7 @@ class _DeviceLimitScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const SizedBox(height: 32), - Icon(Icons.devices_other_rounded, size: 40, color: c.accent), + Icon(PangolinIcons.monitorSmartphone, size: 40, color: c.accent), const SizedBox(height: 14), Text(t.deviceLimitTitle, textAlign: TextAlign.center, @@ -97,7 +98,7 @@ class _DeviceLimitScreenState extends ConsumerState { if (removableCount > 0) OutlinedButton.icon( onPressed: _busyUuid != null ? null : () => _removeOldest(devices, currentId), - icon: const Icon(Icons.history_rounded, size: 18), + icon: const Icon(PangolinIcons.clock, size: 18), label: Text(t.deviceLimitRemoveOldest), style: OutlinedButton.styleFrom( foregroundColor: c.accent, @@ -136,7 +137,9 @@ class _DeviceLimitScreenState extends ConsumerState { final busy = _busyUuid == d.uuid; final sub = d.online ? t.devOnline - : (d.lastSeen != null ? '${t.devLastOnline} ${_rel(d.lastSeen!)}' : t.devOffline); + : (d.lastSeen != null + ? '${t.devLastOnline} ${t.lang.relativeTime(DateTime.now().difference(d.lastSeen!.toLocal()))}' + : t.devOffline); return Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), decoration: BoxDecoration( @@ -184,18 +187,9 @@ class _DeviceLimitScreenState extends ConsumerState { } static IconData _platformIcon(DevicePlatform p) => switch (p) { - DevicePlatform.ios || DevicePlatform.android => Icons.smartphone_rounded, - DevicePlatform.windows || DevicePlatform.linux => Icons.computer_rounded, - DevicePlatform.macos => Icons.laptop_mac_rounded, - DevicePlatform.unknown => Icons.devices_other_rounded, + DevicePlatform.ios || DevicePlatform.android => PangolinIcons.smartphone, + DevicePlatform.windows || DevicePlatform.linux => PangolinIcons.monitor, + DevicePlatform.macos => PangolinIcons.laptop, + DevicePlatform.unknown => PangolinIcons.monitorSmartphone, }; - - // 相对时间(粗粒度):x 分钟/小时/天前。 - static String _rel(DateTime t) { - final d = DateTime.now().toUtc().difference(t.toUtc()); - if (d.inMinutes < 1) return '刚刚'; - if (d.inMinutes < 60) return '${d.inMinutes} 分钟前'; - if (d.inHours < 24) return '${d.inHours} 小时前'; - return '${d.inDays} 天前'; - } } diff --git a/client/lib/screens/invite_page.dart b/client/lib/screens/invite_page.dart new file mode 100644 index 0000000..0dd044e --- /dev/null +++ b/client/lib/screens/invite_page.dart @@ -0,0 +1,358 @@ +// invite_page.dart — 邀请好友:邀请区(码/链接)+ 战绩 + TG 任务卡。 +// +// 数据来自 inviteProvider(Task 11,`/v1/invite`);未登录/加载/出错回退占位文案, +// 不接任何静态 demo 数据。 +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../l10n/app_text.dart'; +import '../pangolin_theme.dart'; +import '../services/channel_launch.dart'; +import '../services/invite_api.dart'; +import '../state/invite_provider.dart'; +import '../widgets/page_body.dart'; +import '../widgets/pangolin_button.dart'; +import '../widgets/pangolin_icons.dart'; +import '../widgets/pangolin_toast.dart'; +import '../widgets/sub_scaffold.dart'; + +class InviteScreen extends ConsumerWidget { + const InviteScreen({super.key, required this.t, this.onBack, this.embedded = false}); + final AppText t; + final VoidCallback? onBack; + final bool embedded; + + Future _copy(BuildContext context, String text, String toastLabel) async { + await Clipboard.setData(ClipboardData(text: text)); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(toastLabel), + behavior: SnackBarBehavior.floating, + duration: const Duration(milliseconds: 1400), + )); + } + } + + // 验证领取:拉 deep_link 后外部打开。canLaunchUrl + try-catch 双保险, + // 失败给可见 toast(与 payment_page._openRedirectUrl 同法)。 + Future _verifyAndClaim(BuildContext context, WidgetRef ref) async { + try { + final link = await ref.read(inviteApiProvider).telegramStartLink(); + final uri = link.isEmpty ? null : Uri.tryParse(link); + if (uri == null) { + if (context.mounted) showPangolinToast(context, t.lang.loadFailedRetry); + return; + } + final can = await canLaunchUrl(uri); + if (!can) { + if (context.mounted) showPangolinToast(context, t.lang.loadFailedRetry); + return; + } + final ok = await launchUrl(uri, mode: LaunchMode.externalApplication); + if (!ok && context.mounted) showPangolinToast(context, t.lang.loadFailedRetry); + if (ok) await ref.read(inviteProvider.notifier).refresh(); + } catch (_) { + if (context.mounted) showPangolinToast(context, t.lang.loadFailedRetry); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final inviteAsync = ref.watch(inviteProvider); + + Widget body = PageBody(child: inviteAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, __) => _Placeholder(t: t), + data: (info) => info == null + ? _Placeholder(t: t) + : _InviteContent(t: t, info: info, onCopy: _copy, onVerifyAndClaim: (ctx) => _verifyAndClaim(ctx, ref)), + )); + + return SubScaffold( + title: t.inviteTitle, + onBack: onBack, + embedded: embedded, + child: body, + ); + } +} + +/// loading/error/未登录 的统一回退占位(保留原 IA 走查态)。 +class _Placeholder extends StatelessWidget { + const _Placeholder({required this.t}); + final AppText t; + @override + Widget build(BuildContext context) { + final c = context.pangolin; + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Icon(PangolinIcons.gift, size: 26, color: c.fg3), + const SizedBox(height: 10), + Text('—', style: PangolinText.body.copyWith(color: c.fg3)), + ]), + ), + ); + } +} + +class _InviteContent extends StatelessWidget { + const _InviteContent({required this.t, required this.info, required this.onCopy, required this.onVerifyAndClaim}); + final AppText t; + final InviteInfo info; + final Future Function(BuildContext, String, String) onCopy; + final Future Function(BuildContext) onVerifyAndClaim; + + @override + Widget build(BuildContext context) { + final c = context.pangolin; + return ListView( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), + children: [ + _CopyCard( + icon: PangolinIcons.gift, + label: t.inviteCodeLabel, + value: info.code, + mono: true, + copyLabel: t.inviteCopyCode, + onCopy: () => onCopy(context, info.code, t.copied), + ), + const SizedBox(height: 14), + _CopyCard( + icon: PangolinIcons.link, + label: t.inviteLinkLabel, + value: info.link, + mono: false, + copyLabel: t.inviteCopyLink, + onCopy: () => onCopy(context, info.link, t.copied), + ), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.fromLTRB(2, 0, 2, 10), + child: Text(t.inviteRewardsTitle, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w700, fontSize: 13)), + ), + Container( + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(PangolinRadius.lg), + border: Border.all(color: c.border), + boxShadow: PangolinShadow.sm, + ), + clipBehavior: Clip.antiAlias, + child: Column(children: [ + _RewardRow(icon: PangolinIcons.users, text: t.inviteRewardRegister), + Divider(height: 1, color: c.border), + _RewardRow(icon: PangolinIcons.crown, text: t.inviteRewardFirstBuy), + Divider(height: 1, color: c.border), + _RewardRow(icon: PangolinIcons.messageCircle, text: t.inviteRewardJoinTg), + ]), + ), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.fromLTRB(2, 0, 2, 10), + child: Text(t.inviteProgressTitle, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w700, fontSize: 13)), + ), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(PangolinRadius.lg), + border: Border.all(color: c.border), + boxShadow: PangolinShadow.sm, + ), + child: Row(children: [ + _StatCell(value: '${info.invited}', label: t.inviteStatInvited), + _StatDivider(color: c.border), + _StatCell(value: '${info.converted}', label: t.inviteStatConverted), + _StatDivider(color: c.border), + _StatCell(value: '${info.earnedDays}', label: t.inviteStatDays), + ]), + ), + if (info.tgEnabled) ...[ + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.fromLTRB(2, 0, 2, 10), + child: Text(t.inviteTasksTitle, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w700, fontSize: 13)), + ), + _TgTaskCard(t: t, info: info, onVerifyAndClaim: onVerifyAndClaim), + ], + ], + ); + } +} + +class _StatCell extends StatelessWidget { + const _StatCell({required this.value, required this.label}); + final String value; + final String label; + @override + Widget build(BuildContext context) { + final c = context.pangolin; + return Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 18), + child: Column(children: [ + Text(value, style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)), + const SizedBox(height: 4), + Text(label, style: PangolinText.caption.copyWith(color: c.fg3)), + ]), + ), + ); + } +} + +class _StatDivider extends StatelessWidget { + const _StatDivider({required this.color}); + final Color color; + @override + Widget build(BuildContext context) => SizedBox(height: 48, child: VerticalDivider(width: 1, color: color)); +} + +class _TgTaskCard extends StatelessWidget { + const _TgTaskCard({required this.t, required this.info, required this.onVerifyAndClaim}); + final AppText t; + final InviteInfo info; + final Future Function(BuildContext) onVerifyAndClaim; + + @override + Widget build(BuildContext context) { + final c = context.pangolin; + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(PangolinRadius.xl), + border: Border.all(color: c.border), + boxShadow: PangolinShadow.sm, + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Container( + width: 32, height: 32, + decoration: BoxDecoration(color: c.accentSubtle, borderRadius: BorderRadius.circular(PangolinRadius.sm)), + child: Icon(PangolinIcons.send, size: 17, color: c.accent), + ), + const SizedBox(width: 10), + Expanded( + child: Text(t.inviteJoinTgTitle, + style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w700, fontSize: 13.5)), + ), + ]), + const SizedBox(height: 14), + if (info.tgJoined) + PangolinButton( + label: t.inviteTaskDone, + icon: PangolinIcons.checkCircle, + variant: PangolinButtonVariant.secondary, + expand: true, + onPressed: null, + ) + else + Column(children: [ + PangolinButton( + label: t.inviteJoinTgBtn, + variant: PangolinButtonVariant.secondary, + expand: true, + onPressed: () => launchChannel(info.channel), + ), + const SizedBox(height: 10), + PangolinButton( + label: t.inviteVerifyBtn, + variant: PangolinButtonVariant.primary, + expand: true, + onPressed: () => onVerifyAndClaim(context), + ), + ]), + ]), + ); + } +} + +class _CopyCard extends StatelessWidget { + const _CopyCard({ + required this.icon, + required this.label, + required this.value, + required this.mono, + required this.copyLabel, + required this.onCopy, + }); + final IconData icon; + final String label; + final String value; + final bool mono; + final String copyLabel; + final VoidCallback onCopy; + + @override + Widget build(BuildContext context) { + final c = context.pangolin; + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(PangolinRadius.xl), + border: Border.all(color: c.border), + boxShadow: PangolinShadow.sm, + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Container( + width: 32, height: 32, + decoration: BoxDecoration(color: c.accentSubtle, borderRadius: BorderRadius.circular(PangolinRadius.sm)), + child: Icon(icon, size: 17, color: c.accent), + ), + const SizedBox(width: 10), + Text(label, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w700, fontSize: 13.5)), + ]), + const SizedBox(height: 12), + Row(children: [ + Expanded( + child: Text(value, + overflow: TextOverflow.ellipsis, + style: mono + ? PangolinText.mono.copyWith(color: c.fg1, fontWeight: FontWeight.w700, letterSpacing: 1.2, fontSize: 15) + : PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600)), + ), + const SizedBox(width: 10), + OutlinedButton.icon( + onPressed: onCopy, + style: OutlinedButton.styleFrom( + foregroundColor: c.accent, + side: BorderSide(color: c.accentBorder), + shape: const StadiumBorder(), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + ), + icon: Icon(PangolinIcons.copy, size: 15), + label: Text(copyLabel, style: PangolinText.caption.copyWith(fontWeight: FontWeight.w700, fontSize: 12.5)), + ), + ]), + ]), + ); + } +} + +class _RewardRow extends StatelessWidget { + const _RewardRow({required this.icon, required this.text}); + final IconData icon; + final String text; + @override + Widget build(BuildContext context) { + final c = context.pangolin; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + child: Row(children: [ + Container( + width: 32, height: 32, + decoration: BoxDecoration(color: c.accentSubtle, borderRadius: BorderRadius.circular(PangolinRadius.sm)), + child: Icon(icon, size: 17, color: c.accent), + ), + const SizedBox(width: 13), + Expanded(child: Text(text, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w500, fontSize: 14.5))), + ]), + ); + } +} diff --git a/client/lib/screens/nodes_page.dart b/client/lib/screens/nodes_page.dart index be8b797..82a01ae 100644 --- a/client/lib/screens/nodes_page.dart +++ b/client/lib/screens/nodes_page.dart @@ -13,6 +13,7 @@ import '../state/nodes_provider.dart'; import '../widgets/app_top_bar.dart'; import '../widgets/country_code.dart'; import '../widgets/pangolin_field.dart'; +import '../widgets/page_body.dart'; import '../widgets/pangolin_icons.dart'; import '../widgets/server_tile.dart'; import '../widgets/smart_select_card.dart'; @@ -128,31 +129,32 @@ class _NodesPageState extends ConsumerState { ]); if (widget.isWide) { - // 对照 dapp.jsx DServers:padding 4/28、网格 gap 10。智能卡置顶(CLAUDE.md §5 规范,三端一致)。 + // 桌面 Nodes 页宽度统一为 view-body(600 居中)、单列 server-row 行列表—— + // 镜像原型 design/prototype/screens/ui-desktop.html 的 .nodes-view(真相源)。 + // 智能卡置顶(CLAUDE.md §5 规范,三端一致)。 return RefreshIndicator( color: c.accent, backgroundColor: c.surface, onRefresh: _refresh, - child: ListView( - padding: const EdgeInsets.fromLTRB(28, 4, 28, 24), - children: [ - smartCard, - const SizedBox(height: 14), - ConstrainedBox(constraints: const BoxConstraints(maxWidth: 440), child: searchRow), - const SizedBox(height: 14), - GridView.count( - crossAxisCount: 2, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - mainAxisSpacing: 10, - crossAxisSpacing: 10, - childAspectRatio: 4.4, + child: PageBody( + child: ListView( + padding: const EdgeInsets.fromLTRB(28, 4, 28, 24), children: [ - for (final n in nodes) - _NodeGridTile(node: n, lang: t.lang, active: !smart && n.code == selected, onTap: () => _onTapNode(n)), + smartCard, + const SizedBox(height: 14), + ConstrainedBox(constraints: const BoxConstraints(maxWidth: 440), child: searchRow), + const SizedBox(height: 14), + for (var i = 0; i < nodes.length; i++) ...[ + _NodeGridTile( + node: nodes[i], + lang: t.lang, + active: !smart && nodes[i].code == selected, + onTap: () => _onTapNode(nodes[i]), + ), + if (i < nodes.length - 1) const SizedBox(height: 10), + ], ], ), - ], ), ); } @@ -226,7 +228,6 @@ class _SearchBox extends StatelessWidget { } } -/// 宽屏双列网格中的节点块。 /// 刷新按钮(搜索框右侧):点击刷新节点列表(受 10s 限流);刷新中显示 spinner。 class _RefreshButton extends StatelessWidget { const _RefreshButton({required this.refreshing, required this.onTap}); @@ -256,6 +257,7 @@ class _RefreshButton extends StatelessWidget { } } +/// 宽屏 Nodes 页单列节点行(镜像原型 .server-row);自然高度撑高,不依赖网格 aspect ratio。 class _NodeGridTile extends StatelessWidget { const _NodeGridTile({required this.node, required this.lang, required this.active, required this.onTap}); final Node node; diff --git a/client/lib/screens/notifications_page.dart b/client/lib/screens/notifications_page.dart new file mode 100644 index 0000000..4de2304 --- /dev/null +++ b/client/lib/screens/notifications_page.dart @@ -0,0 +1,270 @@ +// notifications_page.dart — 通知(真实数据:GET /v1/notices,已读态服务端持久化) +// +// 三态:loading(居中进度圈)/ data 为空(t.notifEmpty 空态)/ error(t.lang.loadFailedRetry)。 +// 类型 pill 走 news/feature/important/reward/version/promo 六值映射(未知 type 原样显示 +// type 字符串)。点击行展开/收起正文(body 为空则不可展开)。version 类型行尾「去更新」 +// 接现有 updateProvider.forceCheck() 流程(与 settings_page.dart 的手动检查同一套逻辑)。 +// 首帧后(有未读时)调一次 markAllRead();回前台(resumed)刷新一次列表。 +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../l10n/app_text.dart'; +import '../pangolin_theme.dart'; +import '../services/notices_api.dart'; +import '../state/notices_provider.dart'; +import '../state/update_provider.dart'; +import '../widgets/pangolin_icons.dart'; +import '../widgets/pangolin_toast.dart'; +import '../widgets/status_pill.dart'; +import '../widgets/sub_scaffold.dart'; + +/// type → (图标, 状态色, 文案) 映射;六个后端合法值 news/feature/important/reward/ +/// version/promo。未知 type 原样显示(label=type 字面量,兜底 bell/neutral)。 +({IconData icon, PangolinStatus status, String label}) _typeMeta(String type, AppText t) { + switch (type) { + case 'news': + return (icon: PangolinIcons.bell, status: PangolinStatus.neutral, label: t.notifTypeNews); + case 'feature': + return (icon: PangolinIcons.zap, status: PangolinStatus.connected, label: t.notifTypeFeature); + case 'important': + return (icon: PangolinIcons.alertTriangle, status: PangolinStatus.error, label: t.notifTypeImportant); + case 'reward': + return (icon: PangolinIcons.gift, status: PangolinStatus.connected, label: t.notifTypeReward); + case 'version': + return (icon: PangolinIcons.refreshCw, status: PangolinStatus.neutral, label: t.notifTypeVersion); + case 'promo': + return (icon: PangolinIcons.crown, status: PangolinStatus.pro, label: t.notifTypePromo); + default: + return (icon: PangolinIcons.bell, status: PangolinStatus.neutral, label: type); + } +} + +class NotificationsScreen extends ConsumerStatefulWidget { + const NotificationsScreen({super.key, required this.t, this.onBack, this.embedded = false}); + final AppText t; + final VoidCallback? onBack; + final bool embedded; + + @override + ConsumerState createState() => _NotificationsScreenState(); +} + +class _NotificationsScreenState extends ConsumerState with WidgetsBindingObserver { + final Set _expanded = {}; + ProviderSubscription>? _noticesSub; + bool _marked = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + + void tryMarkAllRead() { + if (_marked) return; + final data = ref.read(noticesProvider).valueOrNull; + if (data != null && data.unreadCount > 0) { + _marked = true; + ref.read(noticesProvider.notifier).markAllRead(); + } + } + + // 首帧后标全部已读(仅当当前已有数据且存在未读,避免每次进页空转一次网络请求)。 + // provider 首拉有延迟(build() 惰性等 2s),首帧时数据可能仍在 loading;因此额外 + // listenManual 监听后续到达的数据补触发一次,保证「进页即清读」不受首拉时序影响。 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + tryMarkAllRead(); + }); + _noticesSub = ref.listenManual(noticesProvider, (prev, next) => tryMarkAllRead()); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _noticesSub?.close(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // 回前台刷新(全局无 lifecycle 监听点,落在页内;详见 Task 10 报告)。 + if (state == AppLifecycleState.resumed && mounted) { + ref.read(noticesProvider.notifier).refresh(); + } + } + + /// version 类型通知行「去更新」:接 settings_page.dart 同一套 updateProvider.forceCheck() + /// 流程(最小接法,不新开更新入口)。 + Future _goUpdate() async { + final t = widget.t; + final info = await ref.read(updateProvider.notifier).forceCheck(); + if (!mounted) return; + if (info == null) { + showPangolinToast(context, t.updateCheckFailed); + return; + } + if (!info.hasUpdate) { + showPangolinToast(context, t.updateUpToDate); + return; + } + ref.read(dismissedUpdateVersionProvider.notifier).state = null; + showPangolinToast(context, t.updateAvailableTitle(info.latestVersion)); + } + + @override + Widget build(BuildContext context) { + final c = context.pangolin; + final t = widget.t; + final async = ref.watch(noticesProvider); + + Widget empty() => Padding( + padding: const EdgeInsets.only(top: 60), + child: Column(children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration(color: c.bgSubtle, shape: BoxShape.circle), + child: Icon(PangolinIcons.bell, size: 26, color: c.fg3), + ), + const SizedBox(height: 14), + Text(t.notifEmpty, style: PangolinText.sm.copyWith(color: c.fg3)), + ]), + ); + + Widget loading() => const Padding( + padding: EdgeInsets.only(top: 80), + child: Center(child: CircularProgressIndicator()), + ); + + Widget errorView() => Padding( + padding: const EdgeInsets.only(top: 60), + child: Center( + child: Text(t.lang.loadFailedRetry, style: PangolinText.sm.copyWith(color: c.fg3)), + ), + ); + + Widget row(NoticeItem n, bool divider) { + final meta = _typeMeta(n.type, t); + final body = t.lang == AppLang.zh ? n.bodyZh : n.bodyEn; + final hasBody = body.trim().isNotEmpty; + final expanded = _expanded.contains(n.id); + final subLabel = + n.publishedAt != null ? t.lang.relativeTime(DateTime.now().difference(n.publishedAt!)) : null; + + return InkWell( + onTap: hasBody + ? () => setState(() { + if (expanded) { + _expanded.remove(n.id); + } else { + _expanded.add(n.id); + } + }) + : null, + child: Container( + decoration: BoxDecoration(border: divider ? Border(bottom: BorderSide(color: c.border)) : null), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Stack(clipBehavior: Clip.none, children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration(color: c.accentSubtle, borderRadius: BorderRadius.circular(PangolinRadius.md)), + child: Icon(meta.icon, size: 18, color: c.accent), + ), + if (n.unread) + Positioned( + top: -2, + right: -2, + child: Container( + key: ValueKey('notif-unread-${n.id}'), + width: 8, + height: 8, + decoration: BoxDecoration(color: c.danger, shape: BoxShape.circle), + ), + ), + ]), + const SizedBox(width: 12), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + Row(children: [ + Expanded( + child: Text(n.title(t.lang), + overflow: TextOverflow.ellipsis, + style: PangolinText.sm.copyWith( + color: c.fg1, fontWeight: n.unread ? FontWeight.w700 : FontWeight.w500, fontSize: 14.5)), + ), + const SizedBox(width: 8), + StatusPill(label: meta.label, status: meta.status), + ]), + if (subLabel != null) ...[ + const SizedBox(height: 4), + Text(subLabel, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)), + ], + if (expanded && hasBody) ...[ + const SizedBox(height: 8), + Text(body, style: PangolinText.sm.copyWith(color: c.fg2, fontSize: 13.5)), + ], + if (n.type == 'version') ...[ + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: OutlinedButton( + onPressed: _goUpdate, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + side: BorderSide(color: c.border), + ), + child: Text(t.lang.updateNow, + style: PangolinText.caption.copyWith(color: c.accent, fontWeight: FontWeight.w600)), + ), + ), + ], + ]), + ), + ]), + ), + ); + } + + Widget content(NoticesData? data) { + final items = data?.items ?? const []; + return ListView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), + children: [ + if (items.isEmpty) + empty() + else + Container( + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(PangolinRadius.lg), + border: Border.all(color: c.border), + boxShadow: PangolinShadow.sm, + ), + clipBehavior: Clip.antiAlias, + child: Column(children: [ + for (var i = 0; i < items.length; i++) row(items[i], i < items.length - 1), + ]), + ), + ], + ); + } + + final body = async.when( + loading: () => loading(), + error: (err, st) => errorView(), + data: (data) => content(data), + ); + + return SubScaffold( + title: t.notifTitle, + onBack: widget.onBack, + embedded: widget.embedded, + wrapPageBody: true, + child: body, + ); + } +} diff --git a/client/lib/screens/orders_page.dart b/client/lib/screens/orders_page.dart new file mode 100644 index 0000000..f6fa9ce --- /dev/null +++ b/client/lib/screens/orders_page.dart @@ -0,0 +1,429 @@ +// orders_page.dart — 我的订单(列表 → 详情两屏,单 widget 内切换)。 +// +// 数据走本地台账(ordersProvider / orderDetailProvider);金额/状态文案全经 AppText。 +// App 内无支付表单——「继续支付」= 恢复 created 单的原支付会话(paymentFlowProvider.resume, +// 走 retry,不新下单);「重新购买」(expired/canceled/paid 复购)才引导到购买页(PurchaseScreen)。 +// 规格参考 design/prototype/screens/orders.html。 +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/responsive/form_factor.dart'; +import '../l10n/app_text.dart'; +import '../models/payment.dart'; +import '../pangolin_theme.dart'; +import '../state/navigation_provider.dart'; +import '../state/orders_provider.dart'; +import '../state/payment_provider.dart'; +import '../widgets/page_body.dart'; +import '../widgets/sub_scaffold.dart'; +import '../widgets/pangolin_button.dart'; +import '../widgets/pangolin_icons.dart'; +import '../widgets/pangolin_toast.dart'; +import '../widgets/status_pill.dart'; +import 'payment_page.dart'; +import 'purchase_page.dart'; + +// ── 文案映射工具(sku→套餐名 / method→支付方式 / status→状态胶囊)── + +String _planName(AppText t, PayOrderSummary o) => switch (o.sku) { + 'pro_month' => t.proMonthly, + 'pro_quarter' => t.proQuarterly, + 'pro_year' => t.proYearly, + _ => o.sku.isNotEmpty ? o.sku : o.plan, + }; + +String _methodName(AppText t, String method) => switch (method) { + 'crypto' => t.payMethodCrypto, + 'alipay' => t.payMethodAlipay, + 'nezha' => t.payMethodNezha, + _ => method, + }; + +/// 订单状态 → (胶囊色, 文案)。created=等待付款 / paid=已开通 / canceled=已取消 / expired=已过期。 +({PangolinStatus status, String label}) _statusPill(AppText t, String status) => + switch (status) { + 'created' => (status: PangolinStatus.connecting, label: t.awaitingPayment), + 'paid' => (status: PangolinStatus.connected, label: t.orderStatusPaid), + 'canceled' => (status: PangolinStatus.neutral, label: t.orderStatusCanceled), + 'expired' => (status: PangolinStatus.neutral, label: t.orderStatusExpired), + _ => (status: PangolinStatus.neutral, label: status), + }; + +String _fmtDate(DateTime d) { + final l = d.toLocal(); + String two(int v) => v.toString().padLeft(2, '0'); + return '${l.year}-${two(l.month)}-${two(l.day)}'; +} + +String _fmtDateTime(DateTime d) { + final l = d.toLocal(); + String two(int v) => v.toString().padLeft(2, '0'); + return '${l.year}-${two(l.month)}-${two(l.day)} ${two(l.hour)}:${two(l.minute)}'; +} + +/// 订单页入口:内部在「列表」与「详情」两屏间切换(与原型单帧同构)。 +class OrdersScreen extends ConsumerStatefulWidget { + const OrdersScreen({super.key, required this.t, this.onBack, this.embedded = false}); + final AppText t; + final VoidCallback? onBack; + final bool embedded; + + @override + ConsumerState createState() => _OrdersScreenState(); +} + +class _OrdersScreenState extends ConsumerState { + PayOrderSummary? _selected; // 非空 = 展示详情 + + @override + Widget build(BuildContext context) { + if (_selected != null) { + return _OrderDetailView( + t: widget.t, + order: _selected!, + embedded: widget.embedded, + onBack: () => setState(() => _selected = null), + ); + } + return _OrderListView( + t: widget.t, + embedded: widget.embedded, + onBack: widget.onBack, + onOpen: (o) => setState(() => _selected = o), + ); + } +} + +// ── 列表屏 ── +class _OrderListView extends ConsumerWidget { + const _OrderListView({required this.t, required this.embedded, required this.onOpen, this.onBack}); + final AppText t; + final bool embedded; + final ValueChanged onOpen; + final VoidCallback? onBack; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final c = context.pangolin; + final ordersAsync = ref.watch(ordersProvider); + + Widget refreshBtn() => Align( + alignment: Alignment.centerRight, + child: InkWell( + borderRadius: BorderRadius.circular(PangolinRadius.full), + onTap: () => ref.invalidate(ordersProvider), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration( + color: c.bgSubtle, + borderRadius: BorderRadius.circular(PangolinRadius.full), + border: Border.all(color: c.border), + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(PangolinIcons.refreshCw, size: 14, color: c.fg2), + const SizedBox(width: 6), + Text(t.orderRefresh, style: PangolinText.caption.copyWith(color: c.fg2, fontWeight: FontWeight.w600, fontSize: 12.5)), + ]), + ), + ), + ); + + Widget empty() => Padding( + padding: const EdgeInsets.only(top: 60), + child: Column(children: [ + Container( + width: 56, height: 56, + decoration: BoxDecoration(color: c.bgSubtle, shape: BoxShape.circle), + child: Icon(PangolinIcons.ticket, size: 26, color: c.fg3), + ), + const SizedBox(height: 14), + Text(t.ordersEmpty, style: PangolinText.sm.copyWith(color: c.fg3)), + ]), + ); + + Widget content() => ordersAsync.when( + loading: () => const Center(child: Padding(padding: EdgeInsets.all(40), child: CircularProgressIndicator())), + error: (_, __) => Center( + child: Padding(padding: const EdgeInsets.all(40), child: Text(t.lang.loadFailedRetry, style: PangolinText.body.copyWith(color: c.fg3)))), + data: (orders) => ListView(padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), children: [ + refreshBtn(), + const SizedBox(height: 12), + if (orders.isEmpty) + empty() + else + Container( + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(PangolinRadius.lg), + border: Border.all(color: c.border), + boxShadow: PangolinShadow.sm, + ), + clipBehavior: Clip.antiAlias, + child: Column(children: [ + for (var i = 0; i < orders.length; i++) + _row(context, c, orders[i], i < orders.length - 1), + ]), + ), + ]), + ); + + return SubScaffold(title: t.ordersTitle, onBack: onBack, embedded: embedded, wrapPageBody: true, child: content()); + } + + Widget _row(BuildContext context, PangolinScheme c, PayOrderSummary o, bool divider) { + final pill = _statusPill(t, o.status); + final dateStr = o.createdAt != null ? _fmtDate(o.createdAt!) : ''; + final sub = [o.amountLabel(), if (dateStr.isNotEmpty) dateStr].join(' · '); + return InkWell( + onTap: () => onOpen(o), + child: Container( + decoration: BoxDecoration(border: divider ? Border(bottom: BorderSide(color: c.border)) : null), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13), + child: Row(children: [ + Container( + width: 38, height: 38, + decoration: BoxDecoration(color: c.accentSubtle, borderRadius: BorderRadius.circular(PangolinRadius.md)), + child: Icon(PangolinIcons.ticket, size: 19, color: c.accent), + ), + const SizedBox(width: 12), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + Text(_planName(t, o), overflow: TextOverflow.ellipsis, + style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w600, fontSize: 14.5)), + const SizedBox(height: 2), + Text(sub, overflow: TextOverflow.ellipsis, + style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)), + ]), + ), + const SizedBox(width: 8), + StatusPill(label: pill.label, status: pill.status), + const SizedBox(width: 4), + Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3), + ]), + ), + ); + } +} + +// ── 详情屏 ── +class _OrderDetailView extends ConsumerWidget { + const _OrderDetailView({required this.t, required this.order, required this.embedded, required this.onBack}); + final AppText t; + final PayOrderSummary order; + final bool embedded; + final VoidCallback onBack; + + // 「重新购买」(expired/canceled 重下单;paid 复购)→ 购买页 + // (desktop 内容区下钻;mobile/tablet 全屏 push)。 + void _goPurchase(BuildContext context, WidgetRef ref) { + final isDesktop = context.formFactor == FormFactor.desktop; + if (isDesktop) { + ref.read(navViewProvider.notifier).state = NavView.purchase; + } else { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => PurchaseScreen( + t: t, + onOrderCreated: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PaymentScreen( + t: t, + onDone: () { + Navigator.of(context).pop(); + // 支付页「完成/取消」回到订单详情:失效列表 + 本单详情,拉新状态。 + ref.invalidate(ordersProvider); + ref.invalidate(orderDetailProvider(order.orderNo)); + }, + ), + ), + ), + ), + )); + } + } + + // 「继续支付」(created 单)→ 恢复原单支付会话(retry),**不**新下单、不经购买页。 + // 成功:awaitingPayment → 打开支付页(desktop 内容区下钻;mobile/tablet 全屏 push)。 + // 失败(含 409 ORDER_NOT_PENDING 自纠——订单其实已变化,常见已被 webhook 开通): + // 不导航,toast 报错 + 失效列表/详情,让页面刷出订单最新状态。 + Future _resumePay(BuildContext context, WidgetRef ref) async { + await ref.read(paymentFlowProvider.notifier).resume(order.orderNo, order.method); + if (!context.mounted) return; + final flow = ref.read(paymentFlowProvider); + if (flow.phase == PaymentPhase.failed) { + final msg = (t.lang == AppLang.zh ? flow.errorZh : flow.errorEn) ?? t.orderContinuePay; + showPangolinToast(context, msg); + ref.invalidate(ordersProvider); + ref.invalidate(orderDetailProvider(order.orderNo)); + return; + } + final isDesktop = context.formFactor == FormFactor.desktop; + if (isDesktop) { + ref.read(navViewProvider.notifier).state = NavView.payment; + } else { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => PaymentScreen( + t: t, + onDone: () { + Navigator.of(context).pop(); + // 支付页「完成/取消」回到订单详情:失效列表 + 本单详情,拉新状态。 + ref.invalidate(ordersProvider); + ref.invalidate(orderDetailProvider(order.orderNo)); + }, + ), + )); + } + } + + // 「取消订单」(created 单详情)——远程 cancel 后刷新列表/详情;失败(如已支付, + // pay 回 409)不报错,同样走刷新,以最新状态为准(已付单会显示已开通)。 + Future _cancelOrder(BuildContext context, WidgetRef ref) async { + try { + await ref.read(paymentApiProvider).cancel(order.orderNo); + } catch (_) {} + if (!context.mounted) return; + ref.invalidate(ordersProvider); + ref.invalidate(orderDetailProvider(order.orderNo)); + } + + Future _copy(BuildContext context, String text) async { + await Clipboard.setData(ClipboardData(text: text)); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(t.copied), + behavior: SnackBarBehavior.floating, + duration: const Duration(milliseconds: 1400), + )); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final c = context.pangolin; + // 富详情(拿新鲜 expiresAt/status);summary 缺省回退列表项。 + final detailAsync = ref.watch(orderDetailProvider(order.orderNo)); + final o = detailAsync.valueOrNull?.summary ?? order; + final expiresAt = detailAsync.valueOrNull?.expiresAt; + final pill = _statusPill(t, o.status); + + // 内容区(mobile 套 SubScaffold;desktop embedded 前置一条返回条回到列表)。 + Widget summaryCard() => Container( + width: double.infinity, + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(PangolinRadius.xl), + border: Border.all(color: c.border), + boxShadow: PangolinShadow.sm, + ), + padding: const EdgeInsets.all(20), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(_planName(t, o), style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w600)), + const SizedBox(height: 10), + Text(o.amountLabel(), style: PangolinText.h1.copyWith(color: c.fg1, fontWeight: FontWeight.w700, height: 1)), + const SizedBox(height: 14), + StatusPill(label: pill.label, status: pill.status), + ]), + ); + + Widget kvCard() { + final rows = [ + _kv(c, t.orderNoLabel, o.orderNo, onCopy: () => _copy(context, o.orderNo)), + _kv(c, t.orderPlanLabel, _planName(t, o)), + _kv(c, t.orderMethodLabel, _methodName(t, o.method)), + _kv(c, t.orderAmountLabel, o.amountLabel()), + if (o.createdAt != null) _kv(c, t.orderCreatedLabel, _fmtDateTime(o.createdAt!)), + if (o.paidAt != null) _kv(c, t.orderPaidLabel, _fmtDateTime(o.paidAt!)), + if (expiresAt != null) _kv(c, t.payExpiresAt, _fmtDate(expiresAt)), + ]; + return Container( + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(PangolinRadius.lg), + border: Border.all(color: c.border), + boxShadow: PangolinShadow.sm, + ), + clipBehavior: Clip.antiAlias, + child: Column(children: [ + for (var i = 0; i < rows.length; i++) ...[ + rows[i], + if (i < rows.length - 1) Divider(height: 1, color: c.border), + ], + ]), + ); + } + + Widget actions() { + final list = []; + switch (o.status) { + case 'created': + list.add(PangolinButton(label: t.orderContinuePay, expand: true, onPressed: () => _resumePay(context, ref))); + // 原型 pending 态为双按钮:继续支付(主)+ 取消订单(ghost)——orders.html/ui-desktop 均已登记。 + list.add(PangolinButton( + label: t.cancelOrder, + variant: PangolinButtonVariant.ghost, + expand: true, + onPressed: () => _cancelOrder(context, ref))); + // 过期单 pay 会话已失效,点击是重新下单而非续付 → 用「重新购买」避免歧义。 + case 'expired': + case 'canceled': + list.add(PangolinButton(label: t.orderRebuy, expand: true, onPressed: () => _goPurchase(context, ref))); + case 'paid': + list.add(PangolinButton(label: t.orderRebuy, variant: PangolinButtonVariant.secondary, expand: true, onPressed: () => _goPurchase(context, ref))); + } + if (list.isEmpty) return const SizedBox.shrink(); + return Column(children: [for (final w in list) Padding(padding: const EdgeInsets.only(bottom: 10), child: w)]); + } + + Widget body() => ListView(padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), children: [ + summaryCard(), + const SizedBox(height: 18), + kvCard(), + const SizedBox(height: 20), + actions(), + ]); + + if (embedded) { + // desktop 内容区:shell 顶栏返回 = 回账户;这里再给一条返回条回到订单列表。 + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + InkWell( + onTap: onBack, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 6), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(PangolinIcons.arrowLeft, size: 18, color: c.accent), + const SizedBox(width: 6), + Text(t.orderDetailTitle, style: PangolinText.sm.copyWith(color: c.accent, fontWeight: FontWeight.w600)), + ]), + ), + ), + Expanded(child: PageBody(child: body())), + ]); + } + return SubScaffold(title: t.orderDetailTitle, onBack: onBack, wrapPageBody: true, child: body()); + } + + Widget _kv(PangolinScheme c, String label, String value, {VoidCallback? onCopy}) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + SizedBox( + width: 92, + child: Text(label, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w500)), + ), + const SizedBox(width: 12), + Expanded( + child: Text(value, textAlign: TextAlign.right, + style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w500)), + ), + if (onCopy != null) ...[ + const SizedBox(width: 8), + InkWell( + borderRadius: BorderRadius.circular(PangolinRadius.sm), + onTap: onCopy, + child: Padding(padding: const EdgeInsets.all(2), child: Icon(PangolinIcons.copy, size: 15, color: c.fg3)), + ), + ], + ]), + ); + } +} diff --git a/client/lib/screens/payment_page.dart b/client/lib/screens/payment_page.dart index 9d5be3b..5678b17 100644 --- a/client/lib/screens/payment_page.dart +++ b/client/lib/screens/payment_page.dart @@ -9,11 +9,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:url_launcher/url_launcher.dart'; import '../l10n/app_text.dart'; +import '../models/payment.dart'; import '../pangolin_theme.dart'; import '../state/payment_provider.dart'; import '../widgets/pangolin_button.dart'; import '../widgets/pangolin_icons.dart'; import '../widgets/pangolin_toast.dart'; +import '../widgets/sub_scaffold.dart'; class PaymentScreen extends ConsumerStatefulWidget { const PaymentScreen({super.key, required this.t, this.onBack, this.onDone, this.embedded = false}); @@ -79,21 +81,21 @@ class _PaymentScreenState extends ConsumerState { // 而非静默无反应。 Future _openRedirectUrl(BuildContext context, String url) async { final uri = Uri.tryParse(url); - if (uri == null) { + if (uri == null || url.isEmpty) { if (context.mounted) showPangolinToast(context, t.openAlipayFailed); return; } + // payload.url 是收银台 https 页(二维码 + 打开支付宝 + 轮询),不是裸 alipays 深链。 + // 不用 canLaunchUrl 预判:Android 11+ 包可见性下它对 https 常误判 false(即本次 + // 「couldn't open ali」根因)。直接试:外部浏览器/支付宝 → 失败退 app 内浏览视图 + // (Custom Tab/WebView,收银台页在 app 内显示,没装支付宝也能扫码/走 H5)。 try { - final can = await canLaunchUrl(uri); - if (!can) { - if (context.mounted) showPangolinToast(context, t.openAlipayFailed); - return; - } - final ok = await launchUrl(uri, mode: LaunchMode.externalApplication); - if (!ok && context.mounted) showPangolinToast(context, t.openAlipayFailed); - } catch (_) { - if (context.mounted) showPangolinToast(context, t.openAlipayFailed); - } + if (await launchUrl(uri, mode: LaunchMode.externalApplication)) return; + } catch (_) {} + try { + if (await launchUrl(uri, mode: LaunchMode.inAppBrowserView)) return; + } catch (_) {} + if (context.mounted) showPangolinToast(context, t.openAlipayFailed); } // 卡片配方 = account_page.dart::_Card(真相源既有样式,非新造)。 @@ -132,6 +134,48 @@ class _PaymentScreenState extends ConsumerState { ); } + String _planName(String sku) => switch (sku) { + 'pro_month' => t.proMonthly, + 'pro_quarter' => t.proQuarterly, + 'pro_year' => t.proYearly, + _ => sku, + }; + + // 订单信息卡:套餐名 + 支付渠道 + 金额(金额按所选渠道币种显价)。 + Widget _orderCard(BuildContext context, PaymentFlowState s) { + final c = context.pangolin; + final ch = s.method == 'crypto' ? PayChannel.usdt : PayChannel.cny; + final chanLabel = ch == PayChannel.usdt ? t.payChannelUsdt : t.payChannelCny; + final item = s.item; + return _card(c, child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(t.orderDetailTitle.toUpperCase(), + style: PangolinText.caption + .copyWith(color: c.fg3, fontWeight: FontWeight.w700, letterSpacing: 0.6)), + const SizedBox(height: 10), + if (item != null) _orderRow(c, t.orderPlanLabel, _planName(item.sku)), + _orderRow(c, t.payChannel, chanLabel), + if (item != null) _orderRow(c, t.orderAmountLabel, item.priceLabel(ch), amount: true), + ])); + } + + Widget _orderRow(PangolinScheme c, String k, String v, {bool amount = false}) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text(k, style: PangolinText.sm.copyWith(color: c.fg3)), + Flexible( + child: Text(v, + textAlign: TextAlign.right, + style: (amount ? PangolinText.mono : PangolinText.sm).copyWith( + color: amount ? c.accent : c.fg1, fontWeight: FontWeight.w700)), + ), + ], + ), + ); + Widget _awaiting(BuildContext context, WidgetRef ref, PaymentFlowState s) { final c = context.pangolin; final session = s.order!.session; @@ -166,11 +210,14 @@ class _PaymentScreenState extends ConsumerState { Text(t.qrNotSupported, style: PangolinText.caption.copyWith(color: c.fg3)), ])); default: - renderBody = _card(c, child: Text('${session.renderType}: ${_zh ? "暂不支持,请换支付方式" : "Unsupported, switch method"}', - style: PangolinText.body.copyWith(color: c.fg2))); + renderBody = _card(c, + child: Text('${session.renderType}: ${t.payUnsupportedRenderType}', + style: PangolinText.body.copyWith(color: c.fg2))); } return ListView(padding: const EdgeInsets.fromLTRB(20, 14, 20, 24), children: [ + _orderCard(context, s), + const SizedBox(height: 14), renderBody, const SizedBox(height: 16), Row(children: [ @@ -198,9 +245,16 @@ class _PaymentScreenState extends ConsumerState { ]); } + // switchMethod 内部已把网络/超时/未知异常兜到 failed 相位(与 start/resume 对齐, + // 会经 build() 的 phase 分支自动切到 _failed() 呈现)——这里的 try/catch 是最后一道 + // 防线,防止任何逃逸异常变成无提示的 unhandled rejection(照抄 _openRedirectUrl 的写法)。 Future _pickAndSwitch(BuildContext context, WidgetRef ref, PaymentFlowState s) async { - final other = s.method == 'crypto' ? 'alipay' : 'crypto'; - await ref.read(paymentFlowProvider.notifier).switchMethod(other); + final other = s.method == 'crypto' ? 'nezha' : 'crypto'; + try { + await ref.read(paymentFlowProvider.notifier).switchMethod(other); + } catch (_) { + if (context.mounted) showPangolinToast(context, t.orderCreateFailed); + } } Widget _succeeded(BuildContext context, PaymentFlowState s) { @@ -261,29 +315,15 @@ class _PaymentScreenState extends ConsumerState { PaymentPhase.succeeded => _succeeded(context, s), PaymentPhase.failed => _failed(context, ref, s), PaymentPhase.idle => Center( - child: Text(_zh ? '暂无进行中的订单' : 'No active order', + child: Text(t.payNoActiveOrder, style: PangolinText.body.copyWith(color: c.fg3))), }; - if (embedded) return body; - return Scaffold( - backgroundColor: c.bg, - body: SafeArea( - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(8, 6, 16, 10), - child: Row(children: [ - IconButton( - onPressed: onBack ?? () => Navigator.of(context).maybePop(), - icon: Icon(PangolinIcons.arrowLeft, size: 22, color: c.fg1), - ), - Text(t.paymentTitle, - style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)), - ]), - ), - Expanded(child: body), - ]), - ), + return SubScaffold( + title: t.paymentTitle, + onBack: onBack, + embedded: embedded, + child: body, ); } } diff --git a/client/lib/screens/purchase_page.dart b/client/lib/screens/purchase_page.dart index 357af97..9317bb7 100644 --- a/client/lib/screens/purchase_page.dart +++ b/client/lib/screens/purchase_page.dart @@ -1,5 +1,7 @@ // purchase_page.dart — 购买套餐(三档 pro:月/季/年,pay v2 通道)。 // 档位数据来自 GET /v1/pay/catalog(server 单源);金额仅展示,扣款以 pay 为准。 +// 支付渠道即币种即方式(USDT→加密货币 默认 / 人民币→支付宝),顶部 SegSwitch 选定, +// 套餐价随渠道币种刷新;点「立即购买」直接下单进支付页(不再底部弹窗选方式)。 import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -7,10 +9,22 @@ import '../l10n/app_text.dart'; import '../models/payment.dart'; import '../pangolin_theme.dart'; import '../state/payment_provider.dart'; +import '../widgets/page_body.dart'; import '../widgets/pangolin_icons.dart'; +import '../widgets/sub_scaffold.dart'; +import '../widgets/pangolin_toast.dart'; import '../widgets/plan_card.dart'; +import '../widgets/seg_switch.dart'; -class PurchaseScreen extends ConsumerWidget { +/// 手写 firstWhere-or-null(避免仅为一次查找引入 package:collection 依赖)。 +T? _firstWhereOrNull(List items, bool Function(T) test) { + for (final it in items) { + if (test(it)) return it; + } + return null; +} + +class PurchaseScreen extends ConsumerStatefulWidget { const PurchaseScreen({super.key, required this.t, this.onBack, this.onOrderCreated, this.embedded = false}); final AppText t; @@ -19,6 +33,17 @@ class PurchaseScreen extends ConsumerWidget { final VoidCallback? onOrderCreated; final bool embedded; + @override + ConsumerState createState() => _PurchaseScreenState(); +} + +class _PurchaseScreenState extends ConsumerState { + AppText get t => widget.t; + bool get _zh => t.lang == AppLang.zh; + + // 支付渠道:默认 USDT·加密货币。切换即刷新套餐卡显价币种。 + PayChannel _channel = PayChannel.usdt; + String _name(String sku) => switch (sku) { 'pro_month' => t.proMonthly, 'pro_quarter' => t.proQuarterly, @@ -26,109 +51,194 @@ class PurchaseScreen extends ConsumerWidget { _ => sku, }; - String _period(String sku) => switch (sku) { - 'pro_month' => t.perMonth, - 'pro_quarter' => t.perQuarter, - 'pro_year' => t.perYear, - _ => '', - }; - - Future _choose(BuildContext context, WidgetRef ref, PayCatalogItem item) async { - final c = context.pangolin; - final method = await showModalBottomSheet( - context: context, - backgroundColor: c.surface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(PangolinRadius.xl)), - ), - builder: (sheetCtx) => SafeArea( - child: Column(mainAxisSize: MainAxisSize.min, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(20, 18, 20, 8), - child: Align( - alignment: Alignment.centerLeft, - child: Text(t.choosePayMethod, - style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)), - ), - ), - ListTile( - leading: Icon(PangolinIcons.creditCard, color: c.fg2), - title: Text(t.payMethodAlipay, style: PangolinText.body.copyWith(color: c.fg1)), - trailing: Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3), - onTap: () => Navigator.of(sheetCtx).pop('alipay'), - ), - ListTile( - leading: Icon(PangolinIcons.globe, color: c.fg2), - title: Text(t.payMethodCrypto, style: PangolinText.body.copyWith(color: c.fg1)), - trailing: Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3), - onTap: () => Navigator.of(sheetCtx).pop('crypto'), - ), - const SizedBox(height: 12), - ]), - ), - ); - if (method == null || !context.mounted) return; - await ref.read(paymentFlowProvider.notifier).start(item, method); - if (!context.mounted) return; - if (ref.read(paymentFlowProvider).phase == PaymentPhase.awaitingPayment) { - onOrderCreated?.call(); + Future _buy(PayCatalogItem item) async { + await ref.read(paymentFlowProvider.notifier).start(item, _channel.method); + if (!mounted) return; + final st = ref.read(paymentFlowProvider); + if (st.phase == PaymentPhase.awaitingPayment) { + widget.onOrderCreated?.call(); + } else if (st.phase == PaymentPhase.failed) { + // 下单失败给可见 toast,别让用户以为「点了没反应」。 + showPangolinToast( + context, + (_zh ? st.errorZh : st.errorEn) ?? t.orderCreateFailed, + ); } } @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final c = context.pangolin; final catalog = ref.watch(payCatalogProvider); - final body = catalog.when( + final body = PageBody(wide: true, child: catalog.when( loading: () => const Center( child: Padding(padding: EdgeInsets.all(40), child: CircularProgressIndicator())), error: (_, __) => Center( child: Padding( padding: const EdgeInsets.all(40), - child: Text(t.lang == AppLang.zh ? '加载失败,请重试' : 'Failed to load, retry', - style: PangolinText.body.copyWith(color: c.fg3)), + child: Text(t.lang.loadFailedRetry, style: PangolinText.body.copyWith(color: c.fg3)), ), ), - data: (items) => ListView( - padding: const EdgeInsets.fromLTRB(20, 14, 20, 24), - children: [ - for (final it in items) + data: (items) { + final promoItem = _firstWhereOrNull(items, (i) => i.promo); + final planItems = items.where((i) => !i.promo).toList(); + final originalItem = _firstWhereOrNull(items, (i) => i.sku == 'pro_month'); + return ListView( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 24), + children: [ + // 支付渠道 SegSwitch(默认 USDT;人民币走支付宝)→ 套餐价随之刷新 Padding( - padding: EdgeInsets.only(bottom: 14, top: it.sku == 'pro_year' ? 12 : 0), - child: PlanCard( - name: _name(it.sku), - price: it.priceLabel(), - period: _period(it.sku), - features: t.featsPro, - ctaLabel: t.buyNow, - featured: it.sku == 'pro_year', - popularLabel: it.sku == 'pro_year' ? t.mostPopular : null, - onPressed: () => _choose(context, ref, it), - ), + padding: const EdgeInsets.only(left: 2, bottom: 8), + child: Text(t.payChannel.toUpperCase(), + style: PangolinText.caption.copyWith( + color: c.fg3, fontWeight: FontWeight.w700, letterSpacing: 0.6)), ), - ], - ), - ); - - if (embedded) return body; - return Scaffold( - backgroundColor: c.bg, - body: SafeArea( - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(8, 6, 16, 10), - child: Row(children: [ - IconButton( - onPressed: onBack ?? () => Navigator.of(context).maybePop(), - icon: Icon(PangolinIcons.arrowLeft, size: 22, color: c.fg1), + SegSwitch( + options: [ + (icon: PangolinIcons.globe, label: t.payChannelUsdt), + (icon: PangolinIcons.creditCard, label: t.payChannelCny), + ], + selectedIndex: _channel == PayChannel.usdt ? 0 : 1, + onChanged: (i) => setState( + () => _channel = i == 0 ? PayChannel.usdt : PayChannel.cny), + ), + const SizedBox(height: 18), + if (promoItem != null) + Padding( + padding: const EdgeInsets.only(bottom: 14), + child: _PromoCard( + t: t, + channel: _channel, + item: promoItem, + originalItem: originalItem, + onPressed: () => _buy(promoItem), + ), ), - Text(t.purchaseTitle, - style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)), + for (final it in planItems) + Padding( + padding: EdgeInsets.only(bottom: 14, top: it.sku == 'pro_year' ? 12 : 0), + child: PlanCard( + name: _name(it.sku), + price: it.priceLabel(_channel), + period: '${it.perMonthLabel(_channel)}${t.perMonthSuffix}', + features: t.featsPro, + ctaLabel: t.buyNow, + featured: it.sku == 'pro_year', + popularLabel: it.sku == 'pro_year' ? t.mostPopular : null, + onPressed: () => _buy(it), + ), + ), + ], + ); + }, + )); + + return SubScaffold( + title: t.purchaseTitle, + onBack: widget.onBack, + embedded: widget.embedded, + child: body, + ); + } +} + +/// _PromoCard — 全员优惠月付特殊卡片(横幅式,视觉上与普通 PlanCard 拉开层次)。 +/// 徽章 + 大号现价(随渠道 ¥/$ 联动)+ 可选原价划线(以 pro_month 为参照)。 +class _PromoCard extends StatelessWidget { + const _PromoCard({ + required this.t, + required this.channel, + required this.item, + required this.originalItem, + required this.onPressed, + }); + + final AppText t; + final PayChannel channel; + final PayCatalogItem item; + final PayCatalogItem? originalItem; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final originalLabel = originalItem?.priceLabel(channel); + + return Container( + padding: const EdgeInsets.fromLTRB(18, 16, 18, 16), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [PangolinColors.clay500, PangolinColors.clay700], + ), + borderRadius: BorderRadius.circular(PangolinRadius.xl), + boxShadow: PangolinShadow.md, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + decoration: BoxDecoration( + color: PangolinColors.white.withValues(alpha: 0.22), + borderRadius: BorderRadius.circular(PangolinRadius.full), + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(PangolinIcons.zap, size: 13, color: PangolinColors.white), + const SizedBox(width: 4), + Text(t.promoBadge, + style: PangolinText.caption.copyWith( + color: PangolinColors.white, fontWeight: FontWeight.w700, fontSize: 11)), ]), ), - Expanded(child: body), - ]), + const SizedBox(height: 12), + Text(t.proMonthly, + style: PangolinText.sm.copyWith( + color: PangolinColors.white.withValues(alpha: 0.85), fontWeight: FontWeight.w600)), + const SizedBox(height: 6), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text(item.priceLabel(channel), + style: PangolinText.display.copyWith(color: PangolinColors.white, fontSize: 30)), + Text(t.perMonthSuffix, + style: PangolinText.caption.copyWith( + color: PangolinColors.white.withValues(alpha: 0.85), fontSize: 13)), + if (originalLabel != null) ...[ + const SizedBox(width: 8), + Text(originalLabel, + style: PangolinText.sm.copyWith( + color: PangolinColors.white.withValues(alpha: 0.62), + fontSize: 14, + decoration: TextDecoration.lineThrough, + )), + ], + ], + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: Material( + color: PangolinColors.white, + shape: const StadiumBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onPressed, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Center( + child: Text(t.buyNow, + style: PangolinText.sm.copyWith( + fontWeight: FontWeight.w700, fontSize: 14, color: PangolinColors.clay700)), + ), + ), + ), + ), + ), + ], ), ); } diff --git a/client/lib/screens/settings_page.dart b/client/lib/screens/settings_page.dart index 97a9604..587ce1d 100644 --- a/client/lib/screens/settings_page.dart +++ b/client/lib/screens/settings_page.dart @@ -1,7 +1,7 @@ // settings_page.dart — 设置(desktop 一级页) // -// 对照 ui_kits/desktop/dapp.jsx DSettings(精简):兑换入口 + 语言段控 + -// 深色外观 + 协议 + 版本。开关组(开机自启/智能分流/Kill-switch)待 l10n 补齐后加。 +// 三组:连接(开机自启/自动连接/智能分流/kill-switch 开关)· 外观(语言/深色)· +// 关于(协议/检查更新/版本 + Web 用户中心链接)。购买/订单入口已归 Account 页。 import 'dart:io'; import 'package:flutter/material.dart'; @@ -13,8 +13,10 @@ import '../services/web_launch.dart'; import '../state/app_providers.dart'; import '../state/settings_provider.dart'; import '../state/update_provider.dart'; +import '../widgets/page_body.dart'; import '../widgets/pangolin_icons.dart'; import '../widgets/pangolin_toast.dart'; + /// 「检查更新」手动触发:拉取 `$kApiBaseUrl/version`,失败/无更新走轻提示, /// 有更新则清掉「已忽略版本」→ 顶部更新 banner 显示 + toast(不弹窗)。 Future _checkForUpdate(BuildContext context, WidgetRef ref, AppText t) async { @@ -53,16 +55,10 @@ class SettingsPage extends ConsumerWidget { return SingleChildScrollView( padding: const EdgeInsets.fromLTRB(32, 16, 32, 24), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 560), + child: PageBody( child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // 兑换入口 - _Card(children: [ - _Row(title: t.redeemEntry, sub: t.proMember, last: true, - right: Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3), onTap: () {}), - ]), - const SizedBox(height: 16), - // 功能开关组(开机自启是桌面端登录项概念,iOS/Android 无此能力,仅桌面显示) + // ① 连接(功能开关组;开机自启是桌面端登录项概念,iOS/Android 无此能力,仅桌面显示) + _SectionLabel(text: t.settingsGroupConn), _Card(children: [ if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) _Row(title: t.autostart, sub: t.autostartSub, right: sw(s.autostart, settings.setAutostart)), @@ -70,19 +66,26 @@ class SettingsPage extends ConsumerWidget { _Row(title: t.smartRoute, sub: t.smartRouteSub, right: sw(s.smartRoute, settings.setSmartRoute)), _Row(title: t.killSwitch, sub: t.killSwitchSub, last: true, right: sw(s.killSwitch, settings.setKillSwitch)), ]), - const SizedBox(height: 16), - // 配置组 + const SizedBox(height: 20), + // ② 外观(语言 + 深色) + _SectionLabel(text: t.settingsGroupAppearance), _Card(children: [ _Row(title: t.language, right: _LangSwitch(lang: lang, onPick: (l) => ref.read(localeProvider.notifier).set(l))), _Row( title: t.darkAppearance, sub: isDark ? t.stateOn : t.followLight, + last: true, right: sw(isDark, (v) => ref.read(themeModeProvider.notifier).state = v ? ThemeMode.dark : ThemeMode.light), ), + ]), + const SizedBox(height: 20), + // ③ 关于(协议 + 检查更新 + 版本 + 底部 Web 用户中心链接) + _SectionLabel(text: t.settingsGroupAbout), + _Card(children: [ _Row(title: t.protocol, right: Text('REALITY / Hysteria2', style: PangolinText.mono.copyWith(fontSize: 13, color: c.fg3))), - _Row(title: t.webUserCenter, right: Icon(PangolinIcons.externalLink, size: 18, color: c.fg3), onTap: () => openWebUserCenter(ref)), _Row(title: t.checkUpdate, right: Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3), onTap: () => _checkForUpdate(context, ref, t)), - _Row(title: 'Version', last: true, right: Text(version, style: PangolinText.mono.copyWith(fontSize: 13, color: c.fg3))), + _Row(title: 'Version', right: Text(version, style: PangolinText.mono.copyWith(fontSize: 13, color: c.fg3))), + _Row(title: t.webUserCenter, last: true, right: Icon(PangolinIcons.externalLink, size: 18, color: c.fg3), onTap: () => openWebUserCenter(ref)), ]), ]), ), @@ -90,6 +93,19 @@ class SettingsPage extends ConsumerWidget { } } +class _SectionLabel extends StatelessWidget { + const _SectionLabel({required this.text}); + final String text; + @override + Widget build(BuildContext context) { + final c = context.pangolin; + return Padding( + padding: const EdgeInsets.fromLTRB(4, 0, 4, 10), + child: Text(text, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w700, fontSize: 13)), + ); + } +} + class _Card extends StatelessWidget { const _Card({required this.children}); final List children; diff --git a/client/lib/screens/stats_page.dart b/client/lib/screens/stats_page.dart index da4aa7c..a9df736 100644 --- a/client/lib/screens/stats_page.dart +++ b/client/lib/screens/stats_page.dart @@ -11,6 +11,7 @@ import '../state/account_providers.dart'; import '../state/app_providers.dart'; import '../state/auth_provider.dart'; import '../widgets/app_top_bar.dart'; +import '../widgets/page_body.dart'; import '../widgets/pangolin_icons.dart'; import '../widgets/period_card.dart'; import '../widgets/usage_line_chart.dart'; @@ -69,7 +70,7 @@ class StatsPage extends ConsumerWidget { ref.invalidate(deviceUsageProvider(30)); await ref.read(meProvider.notifier).refresh(); }, - child: ListView( + child: PageBody(child: ListView( padding: EdgeInsets.fromLTRB(isWide ? 32 : 20, isWide ? 8 : 4, isWide ? 32 : 20, 24), children: [ // 顶行:标题(宽屏) + 设备下拉(右上角) @@ -115,7 +116,7 @@ class StatsPage extends ConsumerWidget { ]), ), ], - ), + )), ); if (isWide) return body; diff --git a/client/lib/services/auth_api.dart b/client/lib/services/auth_api.dart index b5d9466..2ebb9e0 100644 --- a/client/lib/services/auth_api.dart +++ b/client/lib/services/auth_api.dart @@ -115,12 +115,14 @@ class AuthApi { required String code, required String password, Map? device, + String? inviteCode, }) async { final resp = await _post('/v1/auth/register', { 'email': email, 'code': code, 'password': password, if (device != null) 'device': device, + if (inviteCode != null && inviteCode.isNotEmpty) 'invite_code': inviteCode, }); if (resp.statusCode != 200 && resp.statusCode != 201) { _throwFromResponse(resp); diff --git a/client/lib/services/channel_launch.dart b/client/lib/services/channel_launch.dart new file mode 100644 index 0000000..62dd3cf --- /dev/null +++ b/client/lib/services/channel_launch.dart @@ -0,0 +1,21 @@ +// channel_launch.dart — 联系/购买渠道点击:按 handle(sub)推导 URL 并外部打开。 +// @xxx → https://t.me/xxx (Telegram) +// a@b.com → mailto:a@b.com +// x.yanmei... → https://x.yanmei... +import 'package:url_launcher/url_launcher.dart'; + +String channelUrl(String sub) { + if (sub.startsWith('@')) return 'https://t.me/${sub.substring(1)}'; + if (sub.contains('@')) return 'mailto:$sub'; + return 'https://$sub'; +} + +/// 点击渠道卡:外部打开对应 URL。失败静默(不炸 UI)。 +Future launchChannel(String sub) async { + final uri = Uri.parse(channelUrl(sub)); + try { + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } catch (_) {} +} diff --git a/client/lib/services/contact_channels.dart b/client/lib/services/contact_channels.dart new file mode 100644 index 0000000..c131f68 --- /dev/null +++ b/client/lib/services/contact_channels.dart @@ -0,0 +1,23 @@ +// contact_channels.dart — 联系渠道单源常量(Telegram/邮箱/官网) +// +// 三处引用点:screens/contact_page.dart(桌面联系页)、 +// widgets/account_screens.dart 的 ContactScreen / RedeemScreen。 +// name 用 nameKey 占位,页面侧按 t 映射(telegram→字面量,email/website→AppText), +// 避免把 AppText 塞进常量。点击行为仍由 services/channel_launch.dart::launchChannel 处理。 +import 'package:flutter/widgets.dart'; +import '../widgets/pangolin_icons.dart'; + +class ContactChannel { + const ContactChannel({required this.icon, required this.name, required this.sub, this.accent = false}); + final IconData icon; + final String name; // 展示名(Telegram 用字面量;邮箱/官网用 l10n,在页面侧传入) + final String sub; // handle:@x / a@b / domain(launchChannel 据此推导 URL) + final bool accent; +} + +// name 用 key 占位,页面按 t 映射;Telegram 固定字面量 +const List<({IconData icon, String nameKey, String sub, bool accent})> kContactChannels = [ + (icon: PangolinIcons.send, nameKey: 'telegram', sub: '@pangolin_app', accent: true), + (icon: PangolinIcons.mail, nameKey: 'email', sub: 'support@yanmeiai.com', accent: false), + (icon: PangolinIcons.globe, nameKey: 'website', sub: 'pangolin.yanmeiai.com', accent: false), +]; diff --git a/client/lib/services/invite_api.dart b/client/lib/services/invite_api.dart new file mode 100644 index 0000000..af13de5 --- /dev/null +++ b/client/lib/services/invite_api.dart @@ -0,0 +1,35 @@ +// invite_api.dart — 邀请/奖励任务代理端点封装(JWT 经 ApiClient 自动注入)。 +import 'api_client.dart'; + +class InviteInfo { + const InviteInfo({ + required this.code, required this.link, required this.invited, + required this.converted, required this.earnedDays, + required this.tgEnabled, required this.tgJoined, required this.channel, + }); + final String code, link, channel; + final int invited, converted, earnedDays; + final bool tgEnabled, tgJoined; + + factory InviteInfo.fromJson(Map j) { + final tg = (j['telegram'] as Map?) ?? const {}; + return InviteInfo( + code: j['invite_code'] as String? ?? '', + link: j['invite_link'] as String? ?? '', + invited: (j['invited'] as num?)?.toInt() ?? 0, + converted: (j['converted'] as num?)?.toInt() ?? 0, + earnedDays: (j['earned_days'] as num?)?.toInt() ?? 0, + tgEnabled: tg['enabled'] as bool? ?? false, + tgJoined: tg['joined'] as bool? ?? false, + channel: tg['channel'] as String? ?? '', + ); + } +} + +class InviteApi { + InviteApi(this._c); + final ApiClient _c; + Future fetch() async => InviteInfo.fromJson(await _c.getJson('/v1/invite')); + Future telegramStartLink() async => + (await _c.getJson('/v1/tasks/telegram/start'))['deep_link'] as String? ?? ''; +} diff --git a/client/lib/services/notices_api.dart b/client/lib/services/notices_api.dart new file mode 100644 index 0000000..be0b665 --- /dev/null +++ b/client/lib/services/notices_api.dart @@ -0,0 +1,92 @@ +// notices_api.dart — 系统通知代理端点封装(JWT 经 ApiClient 自动注入)。 +import '../l10n/app_text.dart'; +import 'api_client.dart'; + +class NoticeItem { + const NoticeItem({ + required this.id, + required this.type, + required this.titleZh, + required this.titleEn, + required this.bodyZh, + required this.bodyEn, + required this.link, + required this.publishedAt, + required this.unread, + }); + + final int id; + final String type, titleZh, titleEn, bodyZh, bodyEn, link; + final DateTime? publishedAt; + final bool unread; + + /// 单显文案:zh 取中文,非 zh(en/ja/ko/ru/es)一律取英文兜底。 + String title(AppLang lang) => lang == AppLang.zh ? titleZh : titleEn; + + factory NoticeItem.fromJson(Map j) { + DateTime? published; + final raw = j['published_at'] as String?; + if (raw != null && raw.isNotEmpty) { + try { + published = DateTime.parse(raw); + } catch (_) { + published = null; + } + } + return NoticeItem( + id: (j['id'] as num?)?.toInt() ?? 0, + type: j['type'] as String? ?? '', + titleZh: j['title_zh'] as String? ?? '', + titleEn: j['title_en'] as String? ?? '', + bodyZh: j['body_zh'] as String? ?? '', + bodyEn: j['body_en'] as String? ?? '', + link: j['link'] as String? ?? '', + publishedAt: published, + unread: j['unread'] as bool? ?? false, + ); + } + + NoticeItem copyWith({bool? unread}) => NoticeItem( + id: id, + type: type, + titleZh: titleZh, + titleEn: titleEn, + bodyZh: bodyZh, + bodyEn: bodyEn, + link: link, + publishedAt: publishedAt, + unread: unread ?? this.unread, + ); +} + +class NoticesData { + const NoticesData({required this.items, required this.unreadCount}); + + final List items; + final int unreadCount; + + factory NoticesData.fromJson(Map j) { + final rawItems = j['notices'] as List? ?? const []; + return NoticesData( + items: rawItems + .whereType>() + .map(NoticeItem.fromJson) + .toList(), + unreadCount: (j['unread_count'] as num?)?.toInt() ?? 0, + ); + } + + NoticesData copyWith({List? items, int? unreadCount}) => + NoticesData(items: items ?? this.items, unreadCount: unreadCount ?? this.unreadCount); +} + +class NoticesApi { + NoticesApi(this._c); + final ApiClient _c; + + Future fetch() async => NoticesData.fromJson(await _c.getJson('/v1/notices')); + + Future markRead() async { + await _c.postJson('/v1/notices/read'); + } +} diff --git a/client/lib/services/payment_api.dart b/client/lib/services/payment_api.dart index 32a7b93..5fb5249 100644 --- a/client/lib/services/payment_api.dart +++ b/client/lib/services/payment_api.dart @@ -26,6 +26,13 @@ class PaymentApi { Future orderStatus(String orderNo) async => PayOrderStatus.fromJson(await _c.getJson('/v1/pay/orders/$orderNo')); + /// 订单列表(倒序;纯本地台账,不逐单回查 pay 上游)。 + Future> listOrders() async { + final body = await _c.getJson('/v1/pay/orders'); + final items = (body['orders'] as List?) ?? const []; + return [for (final it in items) PayOrderSummary.fromJson(it as Map)]; + } + Future retry(String orderNo, {required String method, Map? metadata}) async => PayOrder.fromJson(await _c.postJson('/v1/pay/orders/$orderNo/retry', { 'method': method, diff --git a/client/lib/shell/desktop_shell.dart b/client/lib/shell/desktop_shell.dart index effbd59..7c6370d 100644 --- a/client/lib/shell/desktop_shell.dart +++ b/client/lib/shell/desktop_shell.dart @@ -8,7 +8,10 @@ import '../pangolin_theme.dart'; import '../screens/account_page.dart'; import '../screens/connect_page.dart'; import '../screens/contact_page.dart'; +import '../screens/invite_page.dart'; import '../screens/nodes_page.dart'; +import '../screens/notifications_page.dart'; +import '../screens/orders_page.dart'; import '../screens/payment_page.dart'; import '../screens/purchase_page.dart'; import '../screens/settings_page.dart'; @@ -33,7 +36,8 @@ class DesktopShell extends ConsumerWidget { final items = [ (icon: PangolinIcons.power, label: t.tabConnect, view: NavView.connect), (icon: PangolinIcons.globe, label: t.tabServers, view: NavView.servers), - (icon: PangolinIcons.barChart, label: t.tabStats, view: NavView.stats), + (icon: PangolinIcons.chartNoAxesColumn, label: t.tabStats, view: NavView.stats), + (icon: PangolinIcons.user, label: t.tabMe, view: NavView.account), (icon: PangolinIcons.settings, label: t.settingsTitle, view: NavView.settings), (icon: PangolinIcons.messageCircle, label: t.contactTitle, view: NavView.contact), ]; @@ -45,11 +49,13 @@ class DesktopShell extends ConsumerWidget { NavView.account: t.tabMe, NavView.contact: t.contactTitle, NavView.settings: t.settingsTitle, - NavView.plans: t.choosePlan, NavView.redeem: t.redeemTitle, NavView.devices: t.myDevices, NavView.purchase: t.purchaseTitle, NavView.payment: t.paymentTitle, + NavView.orders: t.ordersTitle, + NavView.invite: t.inviteTitle, + NavView.notifications: t.notifTitle, }; void go(NavView v) => ref.read(navViewProvider.notifier).state = v; @@ -69,9 +75,6 @@ class DesktopShell extends ConsumerWidget { case NavView.settings: return const SettingsPage(); // 账户下钻子页(内容区,顶栏带返回) - case NavView.plans: - // 只有 pro 走 pay v2 购买通道;team 等仍走既有兑换/联系人工流程。 - return PlansScreen(t: t, embedded: true, onChoose: (code) => go(code == 'pro' ? NavView.purchase : NavView.redeem)); case NavView.redeem: return RedeemScreen(t: t, embedded: true); case NavView.devices: @@ -80,6 +83,12 @@ class DesktopShell extends ConsumerWidget { return PurchaseScreen(t: t, embedded: true, onOrderCreated: () => go(NavView.payment)); case NavView.payment: return PaymentScreen(t: t, embedded: true, onDone: () => go(NavView.account)); + case NavView.orders: + return OrdersScreen(t: t, embedded: true); + case NavView.invite: + return InviteScreen(t: t, embedded: true); + case NavView.notifications: + return NotificationsScreen(t: t, embedded: true); } } @@ -87,6 +96,7 @@ class DesktopShell extends ConsumerWidget { // 其余下钻子页(含 purchase 本身)→ account。 VoidCallback? backTarget() { if (view == NavView.payment) return () => go(NavView.purchase); + if (view == NavView.notifications) return () => go(NavView.account); if (kAccountSubViews.contains(view)) return () => go(NavView.account); return null; } @@ -100,6 +110,7 @@ class DesktopShell extends ConsumerWidget { ContentTopBar( title: titles[view] ?? t.brand, onBack: backTarget(), + onBell: () => go(NavView.notifications), ), Expanded(child: content()), ]), diff --git a/client/lib/shell/tablet_shell.dart b/client/lib/shell/tablet_shell.dart index 921378b..522e1b2 100644 --- a/client/lib/shell/tablet_shell.dart +++ b/client/lib/shell/tablet_shell.dart @@ -10,6 +10,7 @@ import '../screens/account_page.dart'; import '../screens/connect_page.dart'; import '../screens/contact_page.dart'; import '../screens/nodes_page.dart'; +import '../screens/notifications_page.dart'; import '../screens/settings_page.dart'; import '../screens/stats_page.dart'; import '../state/app_providers.dart'; @@ -27,11 +28,11 @@ class TabletShell extends ConsumerWidget { final t = ref.watch(appTextProvider); final view = ref.watch(navViewProvider); - // 「我的」移到侧栏顶部品牌按钮(见 NavSidebar);设置/联系我们对齐桌面侧栏。 + // 「我的」经侧栏底部 PlanBadgeCard 进入(见 NavSidebar;品牌块本身不可点);设置/联系我们对齐桌面侧栏。 final items = [ (icon: PangolinIcons.power, label: t.tabConnect, view: NavView.connect), (icon: PangolinIcons.globe, label: t.tabServers, view: NavView.servers), - (icon: PangolinIcons.barChart, label: t.tabStats, view: NavView.stats), + (icon: PangolinIcons.chartNoAxesColumn, label: t.tabStats, view: NavView.stats), (icon: PangolinIcons.settings, label: t.settingsTitle, view: NavView.settings), (icon: PangolinIcons.messageCircle, label: t.contactTitle, view: NavView.contact), ]; @@ -43,6 +44,8 @@ class TabletShell extends ConsumerWidget { NavView.account: t.tabMe, NavView.settings: t.settingsTitle, NavView.contact: t.contactTitle, + NavView.invite: t.inviteTitle, + NavView.notifications: t.notifTitle, }; void go(NavView v) => ref.read(navViewProvider.notifier).state = v; @@ -59,11 +62,19 @@ class TabletShell extends ConsumerWidget { return const SettingsPage(); case NavView.contact: return const ContactPage(); + case NavView.notifications: + return NotificationsScreen(t: t, embedded: true); default: // account / plans / redeem / devices → 账户(其余下钻在账户页内) return const AccountPage(isWide: true); } } + // 通知子页返回目标:回账户(镜像桌面 desktop_shell.dart::backTarget)。 + VoidCallback? backTarget() { + if (view == NavView.notifications) return () => go(NavView.account); + return null; + } + return ColoredBox( color: c.bg, // iPad 上内容会顶到状态栏(时间/电量/notch)。SafeArea 加顶部安全区,避开重叠; @@ -74,7 +85,13 @@ class TabletShell extends ConsumerWidget { NavSidebar(items: items, width: 232, dense: false), Expanded( child: Column(children: [ - ContentTopBar(title: titles[view] ?? t.tabMe, showThemeToggle: false, titleSize: 19), + ContentTopBar( + title: titles[view] ?? t.tabMe, + onBack: backTarget(), + onBell: () => go(NavView.notifications), + showThemeToggle: false, + titleSize: 19, + ), Expanded(child: content()), ]), ), diff --git a/client/lib/state/connection_provider.dart b/client/lib/state/connection_provider.dart index 07a38f6..09a133b 100644 --- a/client/lib/state/connection_provider.dart +++ b/client/lib/state/connection_provider.dart @@ -41,7 +41,6 @@ class ConnectionState { required this.phase, this.elapsed = Duration.zero, this.error, - this.freeCountdown, }); final VpnPhase phase; @@ -50,15 +49,10 @@ class ConnectionState { /// 连接失败/中断原因(已本地化);null = 无错误。供 UI 提示,不再静默吞掉。 final String? error; - /// 免费版连接期剩余额度(倒计时);null = 不适用(会员/未连接/额度不限)。 - /// 由状态机按「连接时锁定的剩余额度 − 已用时长」本地推算,归零即自动切断。 - final Duration? freeCountdown; - - ConnectionState copyWith({VpnPhase? phase, Duration? elapsed, Duration? freeCountdown}) => + ConnectionState copyWith({VpnPhase? phase, Duration? elapsed}) => ConnectionState( phase: phase ?? this.phase, elapsed: elapsed ?? this.elapsed, - freeCountdown: freeCountdown ?? this.freeCountdown, ); @override @@ -66,11 +60,10 @@ class ConnectionState { other is ConnectionState && other.phase == phase && other.elapsed == elapsed && - other.error == error && - other.freeCountdown == freeCountdown; + other.error == error; @override - int get hashCode => Object.hash(phase, elapsed, error, freeCountdown); + int get hashCode => Object.hash(phase, elapsed, error); } // ── 连通看门狗 ───────────────────────────────────────────────────── @@ -503,17 +496,14 @@ class ConnectionController extends StateNotifier { if (!mounted || state.phase != VpnPhase.on || at == null) return; final elapsed = _now().difference(at); - Duration? countdown; + // 免费版:到点(锁定额度 − 已用秒 ≤ 0)即自动切断(enforcement,非展示; + // 额度 UI 已移除,不再推算展示用倒计时)。null = 会员/额度不限,不切。 final capSec = _freeRemainingSec; - if (capSec != null) { - final leftSec = capSec - elapsed.inSeconds; - if (leftSec <= 0) { - unawaited(_onFreeQuotaExhausted()); - return; - } - countdown = Duration(seconds: leftSec); + if (capSec != null && capSec - elapsed.inSeconds <= 0) { + unawaited(_onFreeQuotaExhausted()); + return; } - state = ConnectionState(phase: VpnPhase.on, elapsed: elapsed, freeCountdown: countdown); + state = ConnectionState(phase: VpnPhase.on, elapsed: elapsed); } /// 免费额度耗尽:主动切断隧道(不报节点异常),本地置耗尽让按钮灰化,并拉 me 校准。 diff --git a/client/lib/state/invite_provider.dart b/client/lib/state/invite_provider.dart new file mode 100644 index 0000000..acd3acc --- /dev/null +++ b/client/lib/state/invite_provider.dart @@ -0,0 +1,25 @@ +// invite_provider.dart — 邀请信息状态装配:复用 apiClientProvider,未登录不打网络。 +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../services/invite_api.dart'; +import 'account_providers.dart'; +import 'auth_provider.dart'; + +final inviteApiProvider = Provider((ref) => InviteApi(ref.watch(apiClientProvider))); + +class InviteNotifier extends AsyncNotifier { + @override + Future build() async { + // 未登录返回 null(不打网络);已登录拉取。 + final token = ref.watch(authProvider).accessToken; + if (token == null || token.isEmpty) return null; + return ref.read(inviteApiProvider).fetch(); + } + + Future refresh() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(() => ref.read(inviteApiProvider).fetch()); + } +} + +final inviteProvider = AsyncNotifierProvider(InviteNotifier.new); diff --git a/client/lib/state/navigation_provider.dart b/client/lib/state/navigation_provider.dart index 9ea0451..cd9ba5e 100644 --- a/client/lib/state/navigation_provider.dart +++ b/client/lib/state/navigation_provider.dart @@ -3,11 +3,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; /// 一级视图 + 账户子页。 /// desktop 侧栏暴露 6 个一级项(connect..settings);mobile/tablet 取前 4 项, -/// contact/settings 在 mobile 走账户子页。plans/redeem 是 account 的下钻页。 -enum NavView { connect, servers, stats, account, contact, settings, plans, redeem, devices, purchase, payment } +/// contact/settings 在 mobile 走账户子页。redeem 是 account 的下钻页。 +enum NavView { connect, servers, stats, account, contact, settings, redeem, devices, purchase, payment, orders, invite, notifications } /// 账户下钻子页(desktop 在内容区切换、顶栏带返回;mobile/tablet 走全屏 push)。 -const Set kAccountSubViews = {NavView.plans, NavView.redeem, NavView.devices, NavView.purchase, NavView.payment}; +const Set kAccountSubViews = {NavView.redeem, NavView.devices, NavView.purchase, NavView.payment, NavView.orders, NavView.invite}; /// 当前视图。 final navViewProvider = StateProvider((ref) => NavView.connect); diff --git a/client/lib/state/notices_provider.dart b/client/lib/state/notices_provider.dart new file mode 100644 index 0000000..bbc808e --- /dev/null +++ b/client/lib/state/notices_provider.dart @@ -0,0 +1,56 @@ +// notices_provider.dart — 系统通知状态装配:复用 apiClientProvider,未登录不打网络。 +// +// 对照 invite_provider.dart(装配模式:未登录 build 返回 null、不打网络)与 +// update_provider.dart(延迟首拉的可取消 Timer 模式)。回前台重拉由 Task 10 的 +// 消费方(WidgetsBindingObserver)调用 refresh(),本 provider 只暴露 refresh()。 +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../services/notices_api.dart'; +import 'account_providers.dart'; +import 'auth_provider.dart'; + +/// 首次拉取前的延迟(避开登录/首屏竞争)。 +const _kInitialDelay = Duration(seconds: 2); + +final noticesApiProvider = Provider((ref) => NoticesApi(ref.watch(apiClientProvider))); + +class NoticesNotifier extends AsyncNotifier { + Timer? _initialTimer; + + @override + Future build() async { + ref.onDispose(() => _initialTimer?.cancel()); + + // 未登录返回 null(不打网络)。 + final token = ref.watch(authProvider).accessToken; + if (token == null || token.isEmpty) return null; + + // 延迟首拉:用可取消的 Timer(而非 Future.delayed,其内部 timer 无法取消, + // provider 在延迟期间被 dispose 时会悬挂)。 + final ready = Completer(); + _initialTimer = Timer(_kInitialDelay, ready.complete); + await ready.future; // 若延迟期间被 dispose,_initialTimer 取消 → 永不 complete,build 中止 + + return ref.read(noticesApiProvider).fetch(); + } + + Future refresh() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(() => ref.read(noticesApiProvider).fetch()); + } + + /// 标记全部已读:调服务端成功后,本地把 unreadCount 置 0、items 的 unread 置 false。 + Future markAllRead() async { + final current = state.value; + if (current == null) return; + await ref.read(noticesApiProvider).markRead(); + state = AsyncValue.data(current.copyWith( + items: current.items.map((n) => n.copyWith(unread: false)).toList(), + unreadCount: 0, + )); + } +} + +final noticesProvider = AsyncNotifierProvider(NoticesNotifier.new); diff --git a/client/lib/state/orders_provider.dart b/client/lib/state/orders_provider.dart new file mode 100644 index 0000000..51a5aee --- /dev/null +++ b/client/lib/state/orders_provider.dart @@ -0,0 +1,19 @@ +// orders_provider.dart — 订单台账数据(列表 + 单条详情)。 +// 纯本地台账读取(server 不逐单回查 pay 上游);autoDispose 让每次进入订单页 +// 拿到新鲜数据,刷新 = ref.invalidate(...)。 +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../models/payment.dart'; +import 'payment_provider.dart'; + +/// 订单列表(server 倒序返回)。刷新:`ref.invalidate(ordersProvider)`。 +final ordersProvider = FutureProvider.autoDispose>( + (ref) => ref.watch(paymentApiProvider).listOrders(), +); + +/// 单条订单富详情(含 summary 台账字段 + activated/expiresAt)。 +/// 刷新:`ref.invalidate(orderDetailProvider(orderNo))`。 +final orderDetailProvider = + FutureProvider.autoDispose.family( + (ref, orderNo) => ref.watch(paymentApiProvider).orderStatus(orderNo), +); diff --git a/client/lib/state/payment_provider.dart b/client/lib/state/payment_provider.dart index 9060697..394ab82 100644 --- a/client/lib/state/payment_provider.dart +++ b/client/lib/state/payment_provider.dart @@ -36,7 +36,7 @@ class PaymentFlowState { final PaymentPhase phase; final PayCatalogItem? item; - final String method; // alipay | crypto + final String method; // nezha | crypto final PayOrder? order; final PayOrderStatus? status; final String? errorZh; @@ -82,8 +82,10 @@ class PaymentFlowController extends StateNotifier { return Platform.isAndroid || Platform.isIOS; } + // 法币渠道(nezha)带 is_mobile 提示;crypto 不需要。经 pangolin 代理白名单 + // (is_mobile/render)透传到 pay。 Map _metadata(String method) => - method == 'alipay' ? {'is_mobile': _isMobile ? '1' : '0'} : const {}; + method == 'nezha' ? {'is_mobile': _isMobile ? '1' : '0'} : const {}; Future start(PayCatalogItem item, String method) async { _stopPolling(); @@ -98,6 +100,50 @@ class PaymentFlowController extends StateNotifier { } on AuthApiException catch (e) { if (!mounted) return; state = state.copyWith(phase: PaymentPhase.failed, errorZh: e.messageZh, errorEn: e.messageEn); + } catch (_) { + // 网络/解析等非 Auth 异常也要落 failed —— 否则状态机卡在 creating, + // 购买页看着「点了没反应」。 + if (!mounted) return; + state = state.copyWith( + phase: PaymentPhase.failed, + errorZh: '下单失败,请检查网络后重试', + errorEn: 'Failed to create order, please check your network and retry', + ); + } + } + + /// 恢复 `created` 单的支付会话(订单页「继续支付」)——**不是**新下单。 + /// 走 retry 拿回(或续出)同一 orderNo 的支付会话;409 ORDER_NOT_PENDING 说明 + /// 订单在这期间已变化(常见:webhook 已到账但列表还没刷新)→ 落 failed 自纠, + /// 由调用方(订单页)据此刷新列表,用户会看到订单其实已开通,不再重复下单。 + Future resume(String orderNo, String method) async { + _stopPolling(); + state = PaymentFlowState(phase: PaymentPhase.creating, method: method); + try { + final next = await _ref! + .read(paymentApiProvider) + .retry(orderNo, method: method, metadata: _metadata(method)); + if (!mounted) return; + state = state.copyWith(phase: PaymentPhase.awaitingPayment, order: next); + _startPolling(); + } on AuthApiException catch (e) { + if (!mounted) return; + if (e.code == 'ORDER_NOT_PENDING') { + state = state.copyWith( + phase: PaymentPhase.failed, + errorZh: '订单状态已变化,请刷新查看最新状态', + errorEn: 'Order status has changed, please refresh to see the latest status', + ); + return; + } + state = state.copyWith(phase: PaymentPhase.failed, errorZh: e.messageZh, errorEn: e.messageEn); + } catch (_) { + if (!mounted) return; + state = state.copyWith( + phase: PaymentPhase.failed, + errorZh: '下单失败,请检查网络后重试', + errorEn: 'Failed to create order, please check your network and retry', + ); } } @@ -123,6 +169,15 @@ class PaymentFlowController extends StateNotifier { return; } state = state.copyWith(phase: PaymentPhase.failed, errorZh: e.messageZh, errorEn: e.messageEn); + } catch (_) { + // 网络/超时等非 Auth 异常也要落 failed —— 与 start()/resume() 同一形状,否则 + // 穿出到无 try/catch 的 _pickAndSwitch,UI 卡在 awaitingPayment 无提示。 + if (!mounted) return; + state = state.copyWith( + phase: PaymentPhase.failed, + errorZh: '下单失败,请检查网络后重试', + errorEn: 'Failed to create order, please check your network and retry', + ); } } diff --git a/client/lib/widgets/account_screens.dart b/client/lib/widgets/account_screens.dart index 35c3672..2497eab 100644 --- a/client/lib/widgets/account_screens.dart +++ b/client/lib/widgets/account_screens.dart @@ -10,6 +10,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../l10n/app_text.dart'; import '../models/device.dart'; import '../pangolin_theme.dart'; +import '../services/channel_launch.dart'; +import '../services/contact_channels.dart'; import '../screens/settings_page.dart'; import '../services/device_identity.dart'; import 'adaptive_menu.dart'; @@ -18,40 +20,7 @@ import '../services/auth_api.dart'; import '../state/account_providers.dart'; import 'pangolin_button.dart'; import 'pangolin_icons.dart'; -import 'plan_card.dart'; - -/// 通用:带返回箭头的子页骨架。embedded=true 时只返回内容(desktop 内容区下钻, -/// 返回/标题由外层 shell 顶栏提供),否则整页 Scaffold(mobile/tablet 全屏 push)。 -class _SubScaffold extends StatelessWidget { - const _SubScaffold({required this.title, required this.child, this.onBack, this.embedded = false}); - final String title; - final Widget child; - final VoidCallback? onBack; - final bool embedded; - @override - Widget build(BuildContext context) { - final c = context.pangolin; - if (embedded) return child; - return Scaffold( - backgroundColor: c.bg, - body: SafeArea( - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(8, 6, 16, 10), - child: Row(children: [ - IconButton( - onPressed: onBack ?? () => Navigator.of(context).maybePop(), - icon: Icon(PangolinIcons.arrowLeft, size: 22, color: c.fg1), - ), - Text(title, style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)), - ]), - ), - Expanded(child: child), - ]), - ), - ); - } -} +import 'sub_scaffold.dart'; /// ── 设置(移动端从「我的」下钻)── 复用桌面 SettingsPage 内容,套移动返回栏。 class SettingsScreen extends StatelessWidget { @@ -61,67 +30,7 @@ class SettingsScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return _SubScaffold(title: t.settingsTitle, onBack: onBack, child: const SettingsPage()); - } -} - -/// ── 套餐选择 ── (价格/额度来自 GET /v1/plans;当前档由 me.plan 判定; -/// 功能清单仍取 l10n,后端无此字段) -class PlansScreen extends ConsumerWidget { - const PlansScreen({super.key, required this.t, this.onChoose, this.onBack, this.embedded = false}); - final AppText t; - final ValueChanged? onChoose; - final VoidCallback? onBack; - final bool embedded; - - List _feats(String code) => switch (code) { - 'free' => t.featsFree, - 'pro' => t.featsPro, - 'team' => t.featsTeam, - _ => const [], - }; - - // 套餐名是 UI 文案(后端 plans 表无名称列),按 code 取 l10n。 - String _name(String code) => switch (code) { - 'free' => t.freePlan, - 'pro' => t.proPlan, - 'team' => t.teamPlan, - _ => code, - }; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final c = context.pangolin; - final plansAsync = ref.watch(plansProvider); - final myPlan = ref.watch(meProvider).valueOrNull?.plan ?? 'free'; - - Widget body() => plansAsync.when( - loading: () => const Center(child: Padding(padding: EdgeInsets.all(40), child: CircularProgressIndicator())), - error: (_, __) => Center( - child: Padding(padding: const EdgeInsets.all(40), child: Text(t.lang.loadFailedRetry, style: PangolinText.body.copyWith(color: c.fg3)))), - data: (plans) => ListView( - padding: const EdgeInsets.fromLTRB(20, 14, 20, 24), - children: [ - for (final p in plans) - Padding( - padding: EdgeInsets.only(bottom: 14, top: p.code == 'pro' ? 12 : 0), - child: PlanCard( - name: _name(p.code), - price: p.priceLabel(), - period: t.perMonth, - features: _feats(p.code), - ctaLabel: p.code == myPlan ? t.current : (p.code == 'team' ? t.choose : t.upgrade), - featured: p.code == 'pro', - isCurrent: p.code == myPlan, - popularLabel: p.code == 'pro' ? t.mostPopular : null, - onPressed: () => onChoose?.call(p.code), - ), - ), - ], - ), - ); - - return _SubScaffold(title: t.choosePlan, onBack: onBack, embedded: embedded, child: body()); + return SubScaffold(title: t.settingsTitle, onBack: onBack, child: const SettingsPage()); } } @@ -172,7 +81,7 @@ class DevicesScreen extends ConsumerWidget { ]), ); - return _SubScaffold( + return SubScaffold( title: t.myDevices, onBack: onBack, embedded: embedded, child: _DevicesAutoRefresh(child: content())); } @@ -253,7 +162,7 @@ class DevicesScreen extends ConsumerWidget { iconColor: c.fg3, items: [ AdaptiveMenuItem( - icon: PangolinIcons.edit, + icon: PangolinIcons.pencil, label: t.devRename, onSelected: () async { final newName = await _renameDialog(context, c, d); @@ -264,7 +173,7 @@ class DevicesScreen extends ConsumerWidget { ), if (!isLocal) ...[ AdaptiveMenuItem(icon: PangolinIcons.logOut, label: t.devForceLogout, onSelected: () => confirmAction(isClear: false)), - AdaptiveMenuItem(icon: PangolinIcons.trash, label: t.devClearLogin, danger: true, onSelected: () => confirmAction(isClear: true)), + AdaptiveMenuItem(icon: PangolinIcons.trash2, label: t.devClearLogin, danger: true, onSelected: () => confirmAction(isClear: true)), ], ], ); @@ -373,13 +282,7 @@ class _RedeemScreenState extends ConsumerState { Widget build(BuildContext context) { final c = context.pangolin; final t = widget.t; - final channels = [ - (icon: PangolinIcons.shoppingBag, name: t.chStore, sub: 'shop.pangolin.vpn', accent: true), - (icon: PangolinIcons.send, name: 'Telegram', sub: '@PangolinVPN_bot', accent: false), - (icon: PangolinIcons.messageCircle, name: 'LINE', sub: '@pangolinvpn', accent: false), - (icon: PangolinIcons.mail, name: t.chEmail, sub: 'buy@pangolin.vpn', accent: false), - ]; - return _SubScaffold( + return SubScaffold( title: t.redeemTitle, onBack: widget.onBack, embedded: widget.embedded, @@ -417,36 +320,9 @@ class _RedeemScreenState extends ConsumerState { ], ]), ), - const SizedBox(height: 22), - Text(t.buyTitle, style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w700, fontSize: 14.5)), - const SizedBox(height: 6), - Text(t.buySub, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400, height: 1.5)), - const SizedBox(height: 14), - for (final ch in channels) Padding(padding: const EdgeInsets.only(bottom: 10), child: _channel(c, ch.icon, ch.name, ch.sub, ch.accent)), ]), ); } - - Widget _channel(PangolinScheme c, IconData icon, String name, String sub, bool accent) { - return InkWell( - borderRadius: BorderRadius.circular(PangolinRadius.lg), - onTap: () {}, - child: Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration(color: accent ? c.accentSubtle : c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: accent ? c.accentBorder : c.border), boxShadow: PangolinShadow.sm), - child: Row(children: [ - Container(width: 40, height: 40, decoration: BoxDecoration(color: accent ? c.accent : c.bgSubtle, borderRadius: BorderRadius.circular(PangolinRadius.md)), - child: Icon(icon, size: 20, color: accent ? PangolinColors.white : c.accent)), - const SizedBox(width: 13), - Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(name, style: PangolinText.body.copyWith(color: c.fg1, fontWeight: FontWeight.w600)), - Text(sub, style: PangolinText.caption.copyWith(color: c.fg3, fontWeight: FontWeight.w400)), - ])), - Icon(PangolinIcons.externalLink, size: 16, color: c.fg3), - ]), - ), - ); - } } /// ── 联系我们 ── @@ -458,13 +334,20 @@ class ContactScreen extends StatelessWidget { @override Widget build(BuildContext context) { final c = context.pangolin; - final channels = [ - (icon: PangolinIcons.send, name: 'Telegram', sub: '@PangolinVPN_bot', accent: true), - (icon: PangolinIcons.messageCircle, name: 'LINE', sub: '@pangolinvpn', accent: false), - (icon: PangolinIcons.mail, name: t.contactEmail, sub: 'support@pangolin.vpn', accent: false), - (icon: PangolinIcons.shoppingBag, name: t.contactStore, sub: 'shop.pangolin.vpn', accent: false), - ]; - return _SubScaffold( + final channels = kContactChannels + .map((ch) => ( + icon: ch.icon, + name: switch (ch.nameKey) { + 'telegram' => 'Telegram', + 'email' => t.contactEmail, + 'website' => t.contactWebsite, + _ => ch.nameKey, + }, + sub: ch.sub, + accent: ch.accent, + )) + .toList(); + return SubScaffold( title: t.contactTitle, onBack: onBack, child: ListView(padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), children: [ @@ -488,7 +371,7 @@ class ContactScreen extends StatelessWidget { Widget _contact(PangolinScheme c, IconData icon, String name, String sub, bool accent) { return InkWell( borderRadius: BorderRadius.circular(PangolinRadius.lg), - onTap: () {}, + onTap: () => launchChannel(sub), child: Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration(color: accent ? c.accentSubtle : c.surface, borderRadius: BorderRadius.circular(PangolinRadius.lg), border: Border.all(color: accent ? c.accentBorder : c.border), boxShadow: PangolinShadow.sm), diff --git a/client/lib/widgets/auth_screen.dart b/client/lib/widgets/auth_screen.dart index e3b4baf..14fd0b4 100644 --- a/client/lib/widgets/auth_screen.dart +++ b/client/lib/widgets/auth_screen.dart @@ -24,10 +24,14 @@ const _easeOut = Cubic(0.22, 1, 0.36, 1); enum _AuthMode { login, register } class AuthScreen extends ConsumerStatefulWidget { - const AuthScreen({super.key, required this.onDone, required this.t}); + const AuthScreen({super.key, required this.onDone, required this.t, this.api}); final VoidCallback onDone; final AppText t; + /// 测试可注入(见 test/widget/auth_invite_field_test.dart);生产不传,内部 + /// 用 kApiBaseUrl 现建一个,行为不变。 + final AuthApi? api; + @override ConsumerState createState() => _AuthScreenState(); } @@ -44,6 +48,7 @@ class _AuthScreenState extends ConsumerState final _email = TextEditingController(); final _code = TextEditingController(); final _pw = TextEditingController(); + final _invite = TextEditingController(); final _emailFocus = FocusNode(); final _pwFocus = FocusNode(); @@ -54,7 +59,7 @@ class _AuthScreenState extends ConsumerState duration: const Duration(milliseconds: 620), ); - late final AuthApi _api = AuthApi(baseUrl: kApiBaseUrl); + late final AuthApi _api = widget.api ?? AuthApi(baseUrl: kApiBaseUrl); bool get _emailValid => RegExp(r'\S+@\S+\.\S+').hasMatch(_email.text); @@ -79,6 +84,7 @@ class _AuthScreenState extends ConsumerState _email.dispose(); _code.dispose(); _pw.dispose(); + _invite.dispose(); _emailFocus.dispose(); _pwFocus.dispose(); _codeFocus.dispose(); @@ -108,6 +114,7 @@ class _AuthScreenState extends ConsumerState code: _code.text.trim(), password: _pw.text, device: device, + inviteCode: _invite.text.trim(), ); await ref.read(tokenStoreProvider).saveLastEmail(_email.text.trim()); await ref.read(authProvider.notifier).saveTokens(tokens); @@ -464,6 +471,11 @@ class _AuthScreenState extends ConsumerState ), const SizedBox(height: 16), _field(c, icon: PangolinIcons.lock, label: t.pwLabel, focused: _pwFocus.hasFocus, child: _pwInput(c, t.setPwPh, onSubmit: _submitRegister)), + const SizedBox(height: 16), + _field(c, icon: PangolinIcons.gift, label: t.inviteCodeFieldLabel, focused: false, + child: TextField(key: const Key('invite-code-field'), controller: _invite, + textInputAction: TextInputAction.done, + decoration: _bare.copyWith(hintText: ''))), const SizedBox(height: 18), _primaryBtn(c, label: _loading ? '...' : t.doCreate, onPressed: (!_loading && _pw.text.length >= 6) ? _doRegister : null), diff --git a/client/lib/widgets/bottom_tab_bar.dart b/client/lib/widgets/bottom_tab_bar.dart index 7a80bdc..ebe354a 100644 --- a/client/lib/widgets/bottom_tab_bar.dart +++ b/client/lib/widgets/bottom_tab_bar.dart @@ -20,7 +20,7 @@ class BottomTabBar extends ConsumerWidget { final items = <({IconData icon, String label, NavView view})>[ (icon: PangolinIcons.power, label: t.tabConnect, view: NavView.connect), (icon: PangolinIcons.globe, label: t.tabServers, view: NavView.servers), - (icon: PangolinIcons.barChart, label: t.tabStats, view: NavView.stats), + (icon: PangolinIcons.chartNoAxesColumn, label: t.tabStats, view: NavView.stats), (icon: PangolinIcons.user, label: t.tabMe, view: NavView.account), ]; return Container( diff --git a/client/lib/widgets/connect_button.dart b/client/lib/widgets/connect_button.dart index 20abc9d..468e210 100644 --- a/client/lib/widgets/connect_button.dart +++ b/client/lib/widgets/connect_button.dart @@ -24,19 +24,11 @@ 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; @@ -63,26 +55,18 @@ 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 = 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, - }; + 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.loaderCircle, + VpnPhase.on => PangolinIcons.shieldCheck, + }; // off 用柔和阴影;connecting/on 增加同色光晕环(box-shadow,不动背景计算)。 final List glow = s == VpnPhase.off @@ -100,7 +84,7 @@ class _ConnectButtonState extends State with SingleTickerProvider button: true, label: widget.offLabel, child: GestureDetector( - onTap: disabled ? widget.onDisabledTap : widget.onTap, + onTap: widget.onTap, child: AnimatedContainer( duration: PangolinMotion.slow, curve: PangolinMotion.easeOut, diff --git a/client/lib/widgets/content_top_bar.dart b/client/lib/widgets/content_top_bar.dart index 39a63c7..bfd8404 100644 --- a/client/lib/widgets/content_top_bar.dart +++ b/client/lib/widgets/content_top_bar.dart @@ -9,15 +9,25 @@ import '../pangolin_theme.dart'; import '../state/app_providers.dart'; import '../state/connection_provider.dart'; import '../state/nodes_provider.dart'; +import '../state/notices_provider.dart'; +import 'notification_bell.dart'; import 'pangolin_icons.dart'; class ContentTopBar extends ConsumerWidget { - const ContentTopBar({super.key, required this.title, this.onBack, this.showThemeToggle = true, this.titleSize = 17}); + const ContentTopBar({ + super.key, + required this.title, + this.onBack, + this.showThemeToggle = true, + this.titleSize = 17, + this.onBell, + }); final String title; final VoidCallback? onBack; final bool showThemeToggle; final double titleSize; + final VoidCallback? onBell; @override Widget build(BuildContext context, WidgetRef ref) { @@ -52,6 +62,13 @@ class ContentTopBar extends ConsumerWidget { color: conn.phase == VpnPhase.on ? c.success : c.fg3, ), ), + if (onBell != null) ...[ + const SizedBox(width: 12), + NotificationBell( + onTap: onBell!, + hasUnread: (ref.watch(noticesProvider).valueOrNull?.unreadCount ?? 0) > 0, + ), + ], if (showThemeToggle) ...[ const SizedBox(width: 12), InkWell( diff --git a/client/lib/widgets/notification_bell.dart b/client/lib/widgets/notification_bell.dart new file mode 100644 index 0000000..effcd68 --- /dev/null +++ b/client/lib/widgets/notification_bell.dart @@ -0,0 +1,42 @@ +// notification_bell.dart — 顶栏通知铃铛(带未读红点) +// +// 桌面顶栏(content_top_bar.dart)在主题切换旁挂一枚铃铛,点击进 NavView.notifications。 +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../pangolin_theme.dart'; +import 'pangolin_icons.dart'; + +class NotificationBell extends ConsumerWidget { + const NotificationBell({super.key, required this.onTap, this.hasUnread = false}); + + final VoidCallback onTap; + final bool hasUnread; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final c = context.pangolin; + + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(PangolinRadius.sm), + child: Padding( + padding: const EdgeInsets.all(6), + child: Stack(clipBehavior: Clip.none, children: [ + Icon(PangolinIcons.bell, size: 18, color: c.fg2), + // TODO(spec-3): 未读态来自 notices provider,现暂由调用方写死演示。 + if (hasUnread) + Positioned( + top: -1, + right: -1, + child: Container( + width: 7, + height: 7, + decoration: BoxDecoration(color: c.danger, shape: BoxShape.circle), + ), + ), + ]), + ), + ); + } +} diff --git a/client/lib/widgets/page_body.dart b/client/lib/widgets/page_body.dart new file mode 100644 index 0000000..266144c --- /dev/null +++ b/client/lib/widgets/page_body.dart @@ -0,0 +1,20 @@ +// page_body.dart — 桌面内容页统一宽度容器(镜像 design/prototype/atoms.css 的 .view-body)。 +// 默认 600 居中;wide=true 840(多列网格页,如购买页)。手机屏宽 < 600 时约束自然不生效,可无条件包。 +import 'package:flutter/widgets.dart'; + +const double kViewMaxW = 600; // atoms.css .view-body +const double kViewMaxWWide = 840; // atoms.css .view-body.is-wide + +class PageBody extends StatelessWidget { + const PageBody({super.key, required this.child, this.wide = false}); + final Widget child; + final bool wide; + @override + Widget build(BuildContext context) => Center( + child: ConstrainedBox( + constraints: + BoxConstraints(maxWidth: wide ? kViewMaxWWide : kViewMaxW), + child: child, + ), + ); +} diff --git a/client/lib/widgets/pangolin_icons.dart b/client/lib/widgets/pangolin_icons.dart index b4d70fc..331847f 100644 --- a/client/lib/widgets/pangolin_icons.dart +++ b/client/lib/widgets/pangolin_icons.dart @@ -1,80 +1,67 @@ -// pangolin_icons.dart — Lucide 图标映射表 -import 'package:flutter/widgets.dart'; -import 'package:lucide_icons/lucide_icons.dart'; +// GENERATED by design/codegen/gen_flutter_icons.mjs —— 勿手改;图标真源=design/prototype/icons.js +// 重新生成:node design/codegen/gen_flutter_icons.mjs +import 'package:lucide_icons_flutter/lucide_icons.dart'; class PangolinIcons { PangolinIcons._(); - // ── 连接 / 状态 ── - static const power = LucideIcons.power; - static const shieldCheck = LucideIcons.shieldCheck; - static const shield = LucideIcons.shield; - static const loader = LucideIcons.loader; - static const zap = LucideIcons.zap; - static const globe = LucideIcons.globe; - static const clock = LucideIcons.clock; - static const playCircle = LucideIcons.playCircle; - static const wifi = LucideIcons.wifi; - - // ── 导航 / 操作 ── - static const settings = LucideIcons.settings; - static const eye = LucideIcons.eye; - static const eyeOff = LucideIcons.eyeOff; - static const user = LucideIcons.user; - static const search = LucideIcons.search; - static const check = LucideIcons.check; - static const checkCircle = LucideIcons.checkCircle; - static const x = LucideIcons.x; - static const chevronRight = LucideIcons.chevronRight; - static const chevronDown = LucideIcons.chevronDown; - static const arrowLeft = LucideIcons.arrowLeft; - static const arrowUp = LucideIcons.arrowUp; - static const arrowDown = LucideIcons.arrowDown; - static const refreshCw = LucideIcons.refreshCw; - static const compass = LucideIcons.compass; - static const barChart = LucideIcons.barChart; - static const mapPin = LucideIcons.mapPin; - - // ── 主题 ── - static const moon = LucideIcons.moon; - static const sun = LucideIcons.sun; - - // ── 账户 / 套餐 / 兑换 ── - static const crown = LucideIcons.crown; - static const ticket = LucideIcons.ticket; - static const creditCard = LucideIcons.creditCard; - static const shoppingBag = LucideIcons.shoppingBag; - static const mail = LucideIcons.mail; - static const lock = LucideIcons.lock; - static const logOut = LucideIcons.logOut; - static const send = LucideIcons.send; - static const messageCircle= LucideIcons.messageCircle; + static const layoutDashboard = LucideIcons.layoutDashboard; + static const link = LucideIcons.link; + static const ticket = LucideIcons.ticket; + static const users = LucideIcons.users; + static const bell = LucideIcons.bell; + static const copy = LucideIcons.copy; + static const refreshCw = LucideIcons.refreshCw; + static const qrCode = LucideIcons.qrCode; + static const download = LucideIcons.download; + static const check = LucideIcons.check; + static const checkCircle = LucideIcons.checkCircle; + static const zap = LucideIcons.zap; + static const clock = LucideIcons.clock; + static const shieldCheck = LucideIcons.shieldCheck; + static const shield = LucideIcons.shield; + static const crown = LucideIcons.crown; + static const creditCard = LucideIcons.creditCard; + static const send = LucideIcons.send; + static const messageCircle = LucideIcons.messageCircle; + static const mail = LucideIcons.mail; + static const shoppingBag = LucideIcons.shoppingBag; + static const logOut = LucideIcons.logOut; static const externalLink = LucideIcons.externalLink; - - // ── 设备 ── - static const laptop = LucideIcons.laptop; - static const smartphone = LucideIcons.smartphone; - static const monitorSmartphone = LucideIcons.smartphone; // 回退 + static const gift = LucideIcons.gift; + static const smartphone = LucideIcons.smartphone; + static const lock = LucideIcons.lock; + static const chevronRight = LucideIcons.chevronRight; + static const arrowDown = LucideIcons.arrowDown; + static const settings = LucideIcons.settings; + static const trash2 = LucideIcons.trash2; + static const monitor = LucideIcons.monitor; + static const key = LucideIcons.key; + static const sun = LucideIcons.sun; + static const moon = LucideIcons.moon; + static const alertTriangle = LucideIcons.alertTriangle; + static const x = LucideIcons.x; + static const power = LucideIcons.power; + static const loaderCircle = LucideIcons.loaderCircle; + static const globe = LucideIcons.globe; + static const user = LucideIcons.user; + static const search = LucideIcons.search; + static const chevronDown = LucideIcons.chevronDown; + static const arrowLeft = LucideIcons.arrowLeft; + static const arrowUp = LucideIcons.arrowUp; + static const arrowRight = LucideIcons.arrowRight; + static const compass = LucideIcons.compass; + static const chartNoAxesColumn = LucideIcons.chartNoAxesColumn; + static const home = LucideIcons.home; + static const mapPin = LucideIcons.mapPin; + static const laptop = LucideIcons.laptop; + static const monitorSmartphone = LucideIcons.monitorSmartphone; + static const playCircle = LucideIcons.playCircle; + static const wifi = LucideIcons.wifi; + static const eye = LucideIcons.eye; + static const eyeOff = LucideIcons.eyeOff; static const moreVertical = LucideIcons.moreVertical; - static const trash = LucideIcons.trash2; - static const alertTriangle= LucideIcons.alertTriangle; - static const edit = LucideIcons.pencil; - - // ── 支付 ── - static const copy = LucideIcons.copy; - - static IconData? byName(String name) => _byName[name]; - - static const Map _byName = { - 'power': power, 'shield-check': shieldCheck, 'shield': shield, 'loader-circle': loader, - 'zap': zap, 'globe': globe, 'settings': settings, 'user': user, 'search': search, - 'check': check, 'check-circle': checkCircle, 'x': x, 'chevron-right': chevronRight, - 'chevron-down': chevronDown, 'arrow-left': arrowLeft, 'arrow-up': arrowUp, 'arrow-down': arrowDown, - 'refresh-cw': refreshCw, 'compass': compass, 'chart-no-axes-column': barChart, 'chart': barChart, - 'map-pin': mapPin, 'moon': moon, 'sun': sun, 'crown': crown, 'ticket': ticket, - 'credit-card': creditCard, 'shopping-bag': shoppingBag, 'mail': mail, 'lock': lock, - 'log-out': logOut, 'send': send, 'message-circle': messageCircle, 'external-link': externalLink, - 'laptop': laptop, 'smartphone': smartphone, 'monitor-smartphone': monitorSmartphone, - 'copy': copy, - }; + static const pencil = LucideIcons.pencil; + static const terminal = LucideIcons.terminal; + static const menu = LucideIcons.menu; } diff --git a/client/lib/widgets/seg_switch.dart b/client/lib/widgets/seg_switch.dart new file mode 100644 index 0000000..36924da --- /dev/null +++ b/client/lib/widgets/seg_switch.dart @@ -0,0 +1,80 @@ +// seg_switch.dart — 分段开关(镜像 design/prototype atoms .segswitch)。 +// 一排等宽选项,每项 leading 图标 + 文字;选中态 surface 底 + 柔和阴影 + fg1, +// 未选 fg2;容器 bg-subtle + border + full 圆角。渠道/币种/主题等二选一场景通用。 +import 'package:flutter/material.dart'; +import '../pangolin_theme.dart'; + +/// 单个分段项:图标 + 文案。 +typedef SegOption = ({IconData icon, String label}); + +class SegSwitch extends StatelessWidget { + const SegSwitch({ + super.key, + required this.options, + required this.selectedIndex, + required this.onChanged, + }); + + final List options; + final int selectedIndex; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final c = context.pangolin; + return Container( + // .segswitch:bg-subtle + border + full 圆角 + space-1 内边距/间隙 + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: c.bgSubtle, + border: Border.all(color: c.border), + borderRadius: BorderRadius.circular(PangolinRadius.full), + ), + child: Row( + children: [ + for (var i = 0; i < options.length; i++) ...[ + if (i > 0) const SizedBox(width: 4), + Expanded(child: _opt(c, i, options[i])), + ], + ], + ), + ); + } + + Widget _opt(PangolinScheme c, int i, SegOption o) { + final active = i == selectedIndex; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: active ? null : () => onChanged(i), + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + curve: Curves.easeOut, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + // .segswitch-opt.is-active:surface 底 + shadow-sm;未选透明 + color: active ? c.surface : Colors.transparent, + borderRadius: BorderRadius.circular(PangolinRadius.full), + boxShadow: active ? PangolinShadow.sm : null, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(o.icon, size: 16, color: active ? c.fg1 : c.fg2), + const SizedBox(width: 8), + Flexible( + child: Text( + o.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: PangolinText.sm.copyWith( + color: active ? c.fg1 : c.fg2, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/client/lib/widgets/sub_scaffold.dart b/client/lib/widgets/sub_scaffold.dart new file mode 100644 index 0000000..b62c389 --- /dev/null +++ b/client/lib/widgets/sub_scaffold.dart @@ -0,0 +1,57 @@ +// sub_scaffold.dart — 通用「返回箭头 + 标题」子页脚手架(六处收敛,视觉零变化)。 +// +// 收敛自 account_screens.dart/orders_page.dart 的私有 `_SubScaffold`,以及 +// purchase_page.dart/payment_page.dart/invite_page.dart/notifications_page.dart +// 里结构相同的内联等价实现。逐像素沿用原实现(同 padding/字号/图标),纯收敛非重设计。 +// +// embedded=true 时只返回内容(desktop 内容区下钻,返回/标题由外层 shell 顶栏提供); +// 否则整页 Scaffold + SafeArea + 返回箭头行(mobile/tablet 全屏 push)。 +// wrapPageBody=true 时由本组件把 child 套进 PageBody(桌面内容页统一宽度容器);默认 +// false——调用方若已自行用 PageBody 包裹 child(如购买页需要 wide 变体),避免重复包裹。 +import 'package:flutter/material.dart'; + +import '../pangolin_theme.dart'; +import 'page_body.dart'; +import 'pangolin_icons.dart'; + +class SubScaffold extends StatelessWidget { + const SubScaffold({ + super.key, + required this.title, + required this.child, + this.onBack, + this.embedded = false, + this.wrapPageBody = false, + }); + + final String title; + final Widget child; + final VoidCallback? onBack; + final bool embedded; + final bool wrapPageBody; + + @override + Widget build(BuildContext context) { + final c = context.pangolin; + final body = wrapPageBody ? PageBody(child: child) : child; + if (embedded) return body; + return Scaffold( + backgroundColor: c.bg, + body: SafeArea( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 16, 10), + child: Row(children: [ + IconButton( + onPressed: onBack ?? () => Navigator.of(context).maybePop(), + icon: Icon(PangolinIcons.arrowLeft, size: 22, color: c.fg1), + ), + Text(title, style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)), + ]), + ), + Expanded(child: body), + ]), + ), + ); + } +} diff --git a/client/lib/widgets/update_dialog.dart b/client/lib/widgets/update_dialog.dart index 6e89ef4..e28ad31 100644 --- a/client/lib/widgets/update_dialog.dart +++ b/client/lib/widgets/update_dialog.dart @@ -126,7 +126,7 @@ class UpdateBanner extends ConsumerWidget { IconButton( onPressed: () => ref.read(dismissedUpdateVersionProvider.notifier).state = info.latestVersion, - icon: Icon(Icons.close, size: 18, color: c.fg3), + icon: Icon(PangolinIcons.x, size: 18, color: c.fg3), padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 36, minHeight: 36), tooltip: t.updateLater, diff --git a/client/pubspec.yaml b/client/pubspec.yaml index 2bd6c5b..e1d3679 100644 --- a/client/pubspec.yaml +++ b/client/pubspec.yaml @@ -1,7 +1,7 @@ name: pangolin_vpn description: 穿山甲 · Pangolin — 极简、稳定、跨平台网络加速客户端。 publish_to: "none" -version: 1.0.48+49 +version: 1.0.73+74 environment: sdk: ^3.5.0 @@ -12,7 +12,7 @@ dependencies: sdk: flutter flutter_svg: ^2.0.10 # 加载 assets/ 下的矢量 logo country_flags: ^3.1.0 # 节点国旗(SVG,全平台含 Windows;按 region 码渲染) - lucide_icons: ^0.257.0 # Lucide 细线条图标 + lucide_icons_flutter: ^3.1.14 # Lucide 细线条图标(自带字体 assets/lucide.ttf) google_fonts: ^6.2.1 # 运行时加载 Sora / Manrope / Noto Sans SC / JetBrains Mono flutter_riverpod: ^2.5.1 # 状态层(连接状态机 / 免费额度 / 节点选择 / 语言 / 主题) http: ^1.2.1 # 控制面 HTTP 客户端(connect API) @@ -27,7 +27,6 @@ dependencies: launch_at_startup: ^0.5.1 tray_manager: ^0.5.3 window_manager: ^0.5.1 - url_launcher: ^6.3.0 # 支付 redirect(如支付宝)拉起外部浏览器/App dev_dependencies: flutter_test: @@ -44,12 +43,6 @@ flutter_launcher_icons: remove_alpha_ios: true image_path: "assets/app-icon-ios-1024.png" -# lucide_icons-0.257.0 defines LucideIconData extends IconData, which breaks -# when Flutter SDK marks IconData as final. Override with a local patched copy -# that replaces the subclass with direct IconData(...) const constructors. -dependency_overrides: - lucide_icons: - path: packages/lucide_icons_patched flutter: uses-material-design: true diff --git a/client/test/golden/desktop_pages_golden_test.dart b/client/test/golden/desktop_pages_golden_test.dart index 53385c9..a5db857 100644 --- a/client/test/golden/desktop_pages_golden_test.dart +++ b/client/test/golden/desktop_pages_golden_test.dart @@ -70,7 +70,6 @@ void main() { testWidgets('desktop 联系页', (t) => _shoot(t, NavView.contact, 'desktop_contact')); testWidgets('desktop 设置页', (t) => _shoot(t, NavView.settings, 'desktop_settings')); // 账户下钻子页(内容区 + 顶栏返回) - testWidgets('desktop 套餐选择', (t) => _shoot(t, NavView.plans, 'desktop_plans')); testWidgets('desktop 兑换购买', (t) => _shoot(t, NavView.redeem, 'desktop_redeem')); testWidgets('desktop 设备管理', (t) => _shoot(t, NavView.devices, 'desktop_devices')); } diff --git a/client/test/golden/goldens/auth_login_dark.png b/client/test/golden/goldens/auth_login_dark.png index 6e6af25..0bfd829 100644 Binary files a/client/test/golden/goldens/auth_login_dark.png and b/client/test/golden/goldens/auth_login_dark.png differ diff --git a/client/test/golden/goldens/auth_login_light.png b/client/test/golden/goldens/auth_login_light.png index 937e511..f82f4b0 100644 Binary files a/client/test/golden/goldens/auth_login_light.png and b/client/test/golden/goldens/auth_login_light.png differ diff --git a/client/test/golden/goldens/connect_connecting_dark.png b/client/test/golden/goldens/connect_connecting_dark.png index 373b3a9..d6bbcd0 100644 Binary files a/client/test/golden/goldens/connect_connecting_dark.png and b/client/test/golden/goldens/connect_connecting_dark.png differ diff --git a/client/test/golden/goldens/connect_connecting_light.png b/client/test/golden/goldens/connect_connecting_light.png index 05d7730..8cf161f 100644 Binary files a/client/test/golden/goldens/connect_connecting_light.png and b/client/test/golden/goldens/connect_connecting_light.png differ diff --git a/client/test/golden/goldens/connect_off_dark.png b/client/test/golden/goldens/connect_off_dark.png index cdb3043..615300d 100644 Binary files a/client/test/golden/goldens/connect_off_dark.png and b/client/test/golden/goldens/connect_off_dark.png differ diff --git a/client/test/golden/goldens/connect_off_light.png b/client/test/golden/goldens/connect_off_light.png index 6190ca8..cf758aa 100644 Binary files a/client/test/golden/goldens/connect_off_light.png and b/client/test/golden/goldens/connect_off_light.png differ diff --git a/client/test/golden/goldens/connect_on_dark.png b/client/test/golden/goldens/connect_on_dark.png index 942fa71..4db8f77 100644 Binary files a/client/test/golden/goldens/connect_on_dark.png and b/client/test/golden/goldens/connect_on_dark.png differ diff --git a/client/test/golden/goldens/connect_on_light.png b/client/test/golden/goldens/connect_on_light.png index 5035931..b1446d1 100644 Binary files a/client/test/golden/goldens/connect_on_light.png and b/client/test/golden/goldens/connect_on_light.png differ diff --git a/client/test/golden/goldens/desktop_account.png b/client/test/golden/goldens/desktop_account.png index ba0eca3..f39f1e9 100644 Binary files a/client/test/golden/goldens/desktop_account.png and b/client/test/golden/goldens/desktop_account.png differ diff --git a/client/test/golden/goldens/desktop_contact.png b/client/test/golden/goldens/desktop_contact.png index 1d67bc9..18f827e 100644 Binary files a/client/test/golden/goldens/desktop_contact.png and b/client/test/golden/goldens/desktop_contact.png differ diff --git a/client/test/golden/goldens/desktop_devices.png b/client/test/golden/goldens/desktop_devices.png index fa38eb0..ea8dc5e 100644 Binary files a/client/test/golden/goldens/desktop_devices.png and b/client/test/golden/goldens/desktop_devices.png differ diff --git a/client/test/golden/goldens/desktop_plans.png b/client/test/golden/goldens/desktop_plans.png deleted file mode 100644 index 4c0a4fa..0000000 Binary files a/client/test/golden/goldens/desktop_plans.png and /dev/null differ diff --git a/client/test/golden/goldens/desktop_redeem.png b/client/test/golden/goldens/desktop_redeem.png index 0f2fe8c..7f0f541 100644 Binary files a/client/test/golden/goldens/desktop_redeem.png and b/client/test/golden/goldens/desktop_redeem.png differ diff --git a/client/test/golden/goldens/desktop_servers.png b/client/test/golden/goldens/desktop_servers.png index 68addd3..606400e 100644 Binary files a/client/test/golden/goldens/desktop_servers.png and b/client/test/golden/goldens/desktop_servers.png differ diff --git a/client/test/golden/goldens/desktop_settings.png b/client/test/golden/goldens/desktop_settings.png index e3d5c7a..b940ebf 100644 Binary files a/client/test/golden/goldens/desktop_settings.png and b/client/test/golden/goldens/desktop_settings.png differ diff --git a/client/test/golden/goldens/desktop_stats.png b/client/test/golden/goldens/desktop_stats.png index 427ec83..ab1e03a 100644 Binary files a/client/test/golden/goldens/desktop_stats.png and b/client/test/golden/goldens/desktop_stats.png differ diff --git a/client/test/golden/goldens/quota_exhausted_dark.png b/client/test/golden/goldens/quota_exhausted_dark.png index 6e082bb..d848ce3 100644 Binary files a/client/test/golden/goldens/quota_exhausted_dark.png 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 index d987372..94461c9 100644 Binary files a/client/test/golden/goldens/quota_exhausted_light.png 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 0179d15..cce8d2d 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 e5b58c7..1d02939 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/golden/goldens/smart_card_dark.png b/client/test/golden/goldens/smart_card_dark.png index a0ddccb..b6e2ea2 100644 Binary files a/client/test/golden/goldens/smart_card_dark.png and b/client/test/golden/goldens/smart_card_dark.png differ diff --git a/client/test/golden/goldens/smart_card_light.png b/client/test/golden/goldens/smart_card_light.png index 48649cb..62051c3 100644 Binary files a/client/test/golden/goldens/smart_card_light.png and b/client/test/golden/goldens/smart_card_light.png differ diff --git a/client/test/golden/goldens/tablet_account_dark_zh.png b/client/test/golden/goldens/tablet_account_dark_zh.png index 16d848e..99b9f71 100644 Binary files a/client/test/golden/goldens/tablet_account_dark_zh.png and b/client/test/golden/goldens/tablet_account_dark_zh.png differ diff --git a/client/test/golden/goldens/tablet_account_light_en.png b/client/test/golden/goldens/tablet_account_light_en.png index d6bf07d..ff7cc32 100644 Binary files a/client/test/golden/goldens/tablet_account_light_en.png and b/client/test/golden/goldens/tablet_account_light_en.png differ diff --git a/client/test/golden/goldens/tablet_account_light_zh.png b/client/test/golden/goldens/tablet_account_light_zh.png index c7feea9..2a4a308 100644 Binary files a/client/test/golden/goldens/tablet_account_light_zh.png and b/client/test/golden/goldens/tablet_account_light_zh.png differ diff --git a/client/test/golden/goldens/tablet_connect_dark_zh.png b/client/test/golden/goldens/tablet_connect_dark_zh.png index 31d6cfa..906cbdb 100644 Binary files a/client/test/golden/goldens/tablet_connect_dark_zh.png and b/client/test/golden/goldens/tablet_connect_dark_zh.png differ diff --git a/client/test/golden/goldens/tablet_connect_light_en.png b/client/test/golden/goldens/tablet_connect_light_en.png index a5d56c1..ea41a50 100644 Binary files a/client/test/golden/goldens/tablet_connect_light_en.png and b/client/test/golden/goldens/tablet_connect_light_en.png differ diff --git a/client/test/golden/goldens/tablet_connect_light_zh.png b/client/test/golden/goldens/tablet_connect_light_zh.png index 9ba545c..5b03255 100644 Binary files a/client/test/golden/goldens/tablet_connect_light_zh.png and b/client/test/golden/goldens/tablet_connect_light_zh.png differ diff --git a/client/test/golden/goldens/tablet_servers_dark_zh.png b/client/test/golden/goldens/tablet_servers_dark_zh.png index 3570bec..ac79cf0 100644 Binary files a/client/test/golden/goldens/tablet_servers_dark_zh.png and b/client/test/golden/goldens/tablet_servers_dark_zh.png differ diff --git a/client/test/golden/goldens/tablet_servers_light_en.png b/client/test/golden/goldens/tablet_servers_light_en.png index 664c5d9..331f08b 100644 Binary files a/client/test/golden/goldens/tablet_servers_light_en.png and b/client/test/golden/goldens/tablet_servers_light_en.png differ diff --git a/client/test/golden/goldens/tablet_servers_light_zh.png b/client/test/golden/goldens/tablet_servers_light_zh.png index 3317bf1..b773618 100644 Binary files a/client/test/golden/goldens/tablet_servers_light_zh.png and b/client/test/golden/goldens/tablet_servers_light_zh.png differ diff --git a/client/test/golden/goldens/tablet_stats_dark_zh.png b/client/test/golden/goldens/tablet_stats_dark_zh.png index 9b2a882..4506950 100644 Binary files a/client/test/golden/goldens/tablet_stats_dark_zh.png and b/client/test/golden/goldens/tablet_stats_dark_zh.png differ diff --git a/client/test/golden/goldens/tablet_stats_light_en.png b/client/test/golden/goldens/tablet_stats_light_en.png index 0631b9f..cb31c2f 100644 Binary files a/client/test/golden/goldens/tablet_stats_light_en.png and b/client/test/golden/goldens/tablet_stats_light_en.png differ diff --git a/client/test/golden/goldens/tablet_stats_light_zh.png b/client/test/golden/goldens/tablet_stats_light_zh.png index e584957..be6e447 100644 Binary files a/client/test/golden/goldens/tablet_stats_light_zh.png and b/client/test/golden/goldens/tablet_stats_light_zh.png differ diff --git a/client/test/unit/connection_watchdog_test.dart b/client/test/unit/connection_watchdog_test.dart index a162c7c..2c89aea 100644 --- a/client/test/unit/connection_watchdog_test.dart +++ b/client/test/unit/connection_watchdog_test.dart @@ -330,8 +330,9 @@ void main() { await tester.pump(); }); - // 免费版 10 分钟卡控:连接期倒计时,墙上时钟越过额度即自动切断 + 本地置耗尽(#21)。 - testWidgets('免费额度倒计时归零 → 自动切断 + 置耗尽', (tester) async { + // 免费版 10 分钟卡控(enforcement):连接期锁定额度,墙上时钟越过额度即自动切断 + + // 本地置耗尽(#21;额度倒计时 UI 已移除,只保留到点即切的强制行为)。 + testWidgets('免费额度到点 → 自动切断 + 置耗尽', (tester) async { final bridge = _FakeBridge(); var fake = DateTime(2026, 7, 1, 12); final c = makeContainer(bridge, now: () => fake); // 未登录默认免费,剩余 10 分钟 @@ -341,7 +342,7 @@ void main() { c.read(nodesProvider); await tester.pump(); - // 免费默认剩余 10 分钟 → toggle 触发 _connect 锁定 600s 倒计时(fake api 抛错不影响锁定)。 + // 免费默认剩余 10 分钟 → toggle 触发 _connect 锁定 600s 额度(fake api 抛错不影响锁定)。 expect(c.read(quotaProvider).remainingMinutes, 10); c.read(connectionProvider.notifier).toggle(); await tester.pump(); @@ -349,7 +350,6 @@ void main() { 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)); diff --git a/client/test/unit/invite_api_test.dart b/client/test/unit/invite_api_test.dart new file mode 100644 index 0000000..da1431a --- /dev/null +++ b/client/test/unit/invite_api_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:pangolin_vpn/services/api_client.dart'; +import 'package:pangolin_vpn/services/invite_api.dart'; + +ApiClient _c(MockClient m) => ApiClient(baseUrl: 'http://x', getToken: () => 't', refresh: () async => false, client: m); + +void main() { + test('fetch 解析邀请信息 + TG 任务态', () async { + final api = InviteApi(_c(MockClient((req) async { + expect(req.url.path, '/v1/invite'); + return http.Response('{"invite_code":"ABCD2345","invite_link":"https://x/i/ABCD2345",' + '"invited":3,"converted":1,"earned_days":16,' + '"telegram":{"enabled":true,"joined":false,"channel":"@pangolin_app"}}', 200); + }))); + final info = await api.fetch(); + expect(info.code, 'ABCD2345'); + expect(info.invited, 3); + expect(info.earnedDays, 16); + expect(info.tgEnabled, true); + expect(info.tgJoined, false); + }); +} diff --git a/client/test/unit/notices_api_test.dart b/client/test/unit/notices_api_test.dart new file mode 100644 index 0000000..d1c7124 --- /dev/null +++ b/client/test/unit/notices_api_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:pangolin_vpn/l10n/app_text.dart'; +import 'package:pangolin_vpn/services/api_client.dart'; +import 'package:pangolin_vpn/services/notices_api.dart'; + +ApiClient _c(MockClient m) => ApiClient(baseUrl: 'http://x', getToken: () => 't', refresh: () async => false, client: m); + +const _utf8Json = {'content-type': 'application/json; charset=utf-8'}; + +void main() { + test('fetch 解析公告列表 + unread_count(双语字段)', () async { + final api = NoticesApi(_c(MockClient((req) async { + expect(req.url.path, '/v1/notices'); + return http.Response( + '{"notices":[' + '{"id":1,"type":"maintenance","title_zh":"维护通知","title_en":"Maintenance",' + '"body_zh":"今晚维护","body_en":"Tonight maintenance","link":"https://x/1",' + '"published_at":"2026-07-10T08:00:00Z","unread":true},' + '{"id":2,"type":"promo","title_zh":"活动","title_en":"Promo",' + '"published_at":"2026-07-09T08:00:00Z","unread":false}' + '],"unread_count":1}', + 200, + headers: _utf8Json); + }))); + final data = await api.fetch(); + expect(data.items.length, 2); + expect(data.unreadCount, 1); + + final first = data.items[0]; + expect(first.id, 1); + expect(first.type, 'maintenance'); + expect(first.titleZh, '维护通知'); + expect(first.titleEn, 'Maintenance'); + expect(first.bodyZh, '今晚维护'); + expect(first.bodyEn, 'Tonight maintenance'); + expect(first.link, 'https://x/1'); + expect(first.publishedAt, DateTime.parse('2026-07-10T08:00:00Z')); + expect(first.unread, true); + expect(first.title(AppLang.zh), '维护通知'); + expect(first.title(AppLang.en), 'Maintenance'); + + final second = data.items[1]; + expect(second.id, 2); + expect(second.bodyZh, ''); + expect(second.bodyEn, ''); + expect(second.link, ''); + expect(second.unread, false); + // 非 zh 语言取 en 兜底 + expect(second.title(AppLang.ja), second.titleEn); + }); + + test('fetch 缺字段安全默认(不 crash)', () async { + final api = NoticesApi(_c(MockClient((req) async { + return http.Response('{}', 200); + }))); + final data = await api.fetch(); + expect(data.items, isEmpty); + expect(data.unreadCount, 0); + }); + + test('markRead 打 POST /v1/notices/read', () async { + var called = false; + final api = NoticesApi(_c(MockClient((req) async { + called = true; + expect(req.method, 'POST'); + expect(req.url.path, '/v1/notices/read'); + return http.Response('{"ok":true}', 200); + }))); + await api.markRead(); + expect(called, true); + }); +} diff --git a/client/test/unit/payment_api_test.dart b/client/test/unit/payment_api_test.dart index 29f9b69..2fa86f5 100644 --- a/client/test/unit/payment_api_test.dart +++ b/client/test/unit/payment_api_test.dart @@ -3,6 +3,7 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:pangolin_vpn/services/api_client.dart'; import 'package:pangolin_vpn/services/auth_api.dart'; +import 'package:pangolin_vpn/models/payment.dart'; import 'package:pangolin_vpn/services/payment_api.dart'; ApiClient _client(MockClient mock) => ApiClient( @@ -25,7 +26,15 @@ void main() { final items = await api.catalog(); expect(items.length, 3); expect(items.first.sku, 'pro_month'); - expect(items.first.priceLabel(), '¥29.99'); + expect(items.first.priceLabel(PayChannel.cny), '¥29.99'); + }); + + test('PayChannel 渠道→method 映射:usdt→crypto / cny→nezha(生产渠道码)', () { + // 回归钉:CNY 生产走哪吒(nezha)聚合,不是直连 alipay——发错 method 会被 pay 回 400。 + expect(PayChannel.usdt.method, 'crypto'); + expect(PayChannel.cny.method, 'nezha'); + expect(PayChannel.usdt.currency, 'USDT'); + expect(PayChannel.cny.currency, 'CNY'); }); test('createOrder 只传 sku+method+metadata,解析 session', () async { diff --git a/client/test/unit/payment_flow_test.dart b/client/test/unit/payment_flow_test.dart index 6ebeb31..91ac817 100644 --- a/client/test/unit/payment_flow_test.dart +++ b/client/test/unit/payment_flow_test.dart @@ -7,14 +7,18 @@ import 'package:pangolin_vpn/state/payment_provider.dart'; class _FakePaymentApi implements PaymentApi { _FakePaymentApi(); - int createCalls = 0, cancelCalls = 0, statusCalls = 0; + int createCalls = 0, cancelCalls = 0, statusCalls = 0, retryCalls = 0; bool activated = false; Exception? retryError; String lastMethod = ''; + String lastOrderNo = ''; @override Future> catalog() async => const []; + @override + Future> listOrders() async => const []; + @override Future createOrder({required String sku, required String method, Map? metadata}) async { createCalls++; @@ -32,6 +36,9 @@ class _FakePaymentApi implements PaymentApi { @override Future retry(String orderNo, {required String method, Map? metadata}) async { + retryCalls++; + lastMethod = method; + lastOrderNo = orderNo; if (retryError != null) throw retryError!; return PayOrder(orderNo: orderNo, session: PaySession(renderType: 'redirect', payload: const {'url': 'https://y'}, expiresAt: null)); } @@ -97,4 +104,49 @@ void main() { expect(api.lastMethod, 'alipay'); expect(c.read(paymentFlowProvider).order?.orderNo, 'pay2'); }); + + test('switchMethod 遇非 Auth 异常(网络/超时)→ 落 failed 兜底,不卡 awaitingPayment', () async { + final api = _FakePaymentApi(); + final c = _container(api); + final ctl = c.read(paymentFlowProvider.notifier); + await ctl.start(item, 'crypto'); + api.retryError = Exception('network timeout'); + await ctl.switchMethod('alipay'); + expect(c.read(paymentFlowProvider).phase, PaymentPhase.failed); + expect(c.read(paymentFlowProvider).errorZh, isNotNull); + expect(c.read(paymentFlowProvider).errorZh, isNotEmpty); + expect(c.read(paymentFlowProvider).errorEn, isNotNull); + expect(c.read(paymentFlowProvider).errorEn, isNotEmpty); + }); + + test('resume() 成功 → retry 恢复原单会话(不新下单),phase=awaitingPayment', () async { + final api = _FakePaymentApi(); + final c = _container(api); + final ctl = c.read(paymentFlowProvider.notifier); + + await ctl.resume('o-month', 'alipay'); + + expect(api.retryCalls, 1); + expect(api.createCalls, 0, reason: '继续支付不应新下单'); + expect(api.lastOrderNo, 'o-month'); + expect(api.lastMethod, 'alipay'); + expect(c.read(paymentFlowProvider).phase, PaymentPhase.awaitingPayment); + expect(c.read(paymentFlowProvider).order?.orderNo, 'o-month'); + }); + + test('resume() 遇 409 ORDER_NOT_PENDING(订单其实已变化)→ failed 自纠,不 crash', () async { + final api = _FakePaymentApi(); + final c = _container(api); + final ctl = c.read(paymentFlowProvider.notifier); + api.retryError = const AuthApiException( + statusCode: 409, messageZh: 'x', messageEn: 'x', code: 'ORDER_NOT_PENDING'); + + await ctl.resume('o-month', 'alipay'); + + expect(c.read(paymentFlowProvider).phase, PaymentPhase.failed); + expect(c.read(paymentFlowProvider).errorZh, isNotNull); + expect(c.read(paymentFlowProvider).errorZh, isNotEmpty); + expect(c.read(paymentFlowProvider).errorEn, isNotNull); + expect(c.read(paymentFlowProvider).errorEn, isNotEmpty); + }); } diff --git a/client/test/widget/account_desktop_nav_test.dart b/client/test/widget/account_desktop_nav_test.dart new file mode 100644 index 0000000..a4a9aac --- /dev/null +++ b/client/test/widget/account_desktop_nav_test.dart @@ -0,0 +1,105 @@ +// account_desktop_nav_test.dart — 复现 Windows/桌面账户页子导航点击是否切 navView。 +// +// 用 debugDefaultTargetPlatformOverride=windows + 宽 surface 让 context.formFactor==desktop, +// 渲染 DesktopShell 账户页,点每个 _NavRow,断言 navViewProvider 切到对应子视图。 +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pangolin_vpn/bridge/vpn_bridge_mock.dart'; +import 'package:pangolin_vpn/bridge/vpn_bridge_provider.dart'; +import 'package:pangolin_vpn/l10n/app_text.dart'; +import 'package:pangolin_vpn/l10n/strings_zh.dart'; +import 'package:pangolin_vpn/pangolin_theme.dart'; +import 'package:pangolin_vpn/screens/account_page.dart'; +import 'package:pangolin_vpn/services/token_store.dart'; +import 'package:pangolin_vpn/shell/desktop_shell.dart'; +import 'package:pangolin_vpn/shell/home_shell.dart'; +import 'package:pangolin_vpn/state/app_providers.dart'; +import 'package:pangolin_vpn/state/auth_provider.dart'; +import 'package:pangolin_vpn/state/navigation_provider.dart'; + +import '../helpers/harness.dart'; + +final AppText t = StringsZh(); + +class _NullTokenStore implements TokenStore { + const _NullTokenStore(); + @override + Future saveTokens({required String access, required String refresh}) async {} + @override + Future loadAccessToken() async => null; + @override + Future loadRefreshToken() async => null; + @override + Future clear() async {} + @override + Future markOnboarded() async {} + @override + Future isOnboarded() async => true; + @override + Future saveLastEmail(String email) async {} + @override + Future loadLastEmail() async => null; +} + +/// 在桌面态渲染 DesktopShell(起始 account 视图),点账户卡里 [label] 那一行, +/// 返回点击后的 navViewProvider 值。 +Future _tapRow(WidgetTester tester, String label) async { + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + tester.view.physicalSize = const Size(920, 1400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + try { + await tester.pumpWidget(ProviderScope( + overrides: [ + tokenStoreProvider.overrideWithValue(const _NullTokenStore()), + vpnBridgeProvider.overrideWithValue(VpnBridgeMock()), + appTextProvider.overrideWithValue(t), + navViewProvider.overrideWith((ref) => NavView.account), + ], + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: PangolinTheme.light, + home: const HomeShell(), + ), + )); + await tester.pumpAndSettle(); + + expect(find.byType(DesktopShell), findsOneWidget); + expect(find.byType(AccountPage), findsOneWidget); + + // 侧栏与账户卡可能共享同一文案(设置/联系我们),故限定在 AccountPage 内。 + final row = find.descendant(of: find.byType(AccountPage), matching: find.text(label)); + expect(row, findsOneWidget, reason: '账户卡里应有且仅有一行「$label」'); + + final container = ProviderScope.containerOf(tester.element(find.byType(HomeShell))); + expect(container.read(navViewProvider), NavView.account); + + await tester.tap(row); + await tester.pump(); + return container.read(navViewProvider); + } finally { + debugDefaultTargetPlatformOverride = null; + } +} + +void main() { + setUpAll(disableGoogleFontsFetching); + + // 注:设置/联系已从桌面态账户卡移除(IA 重构后仅 mobile/tablet 在账户页下钻, + // 桌面走侧栏 NavSidebar 的 t.settingsTitle/t.contactTitle 入口,非本文件覆盖范围)。 + testWidgets('desktop account -> redeem', (tester) async { + expect(await _tapRow(tester, t.redeemEntry), NavView.redeem); + }); + testWidgets('desktop account -> orders', (tester) async { + expect(await _tapRow(tester, t.ordersTitle), NavView.orders); + }); + testWidgets('desktop account -> devices', (tester) async { + expect(await _tapRow(tester, t.myDevices), NavView.devices); + }); + testWidgets('desktop account -> invite', (tester) async { + expect(await _tapRow(tester, t.inviteEntry), NavView.invite); + }); +} diff --git a/client/test/widget/account_ia_test.dart b/client/test/widget/account_ia_test.dart new file mode 100644 index 0000000..045bd5c --- /dev/null +++ b/client/test/widget/account_ia_test.dart @@ -0,0 +1,84 @@ +// account_ia_test.dart — 账户页 IA 结构断言:邀请入口在/语言不在/兑换在。 +// +// 桌面态(windows 平台 + 宽 surface),经 HomeShell 渲染真实 DesktopShell → AccountPage, +// 与 account_desktop_nav_test.dart 同一套 harness。 +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pangolin_vpn/bridge/vpn_bridge_mock.dart'; +import 'package:pangolin_vpn/bridge/vpn_bridge_provider.dart'; +import 'package:pangolin_vpn/l10n/app_text.dart'; +import 'package:pangolin_vpn/l10n/strings_zh.dart'; +import 'package:pangolin_vpn/pangolin_theme.dart'; +import 'package:pangolin_vpn/screens/account_page.dart'; +import 'package:pangolin_vpn/services/token_store.dart'; +import 'package:pangolin_vpn/shell/desktop_shell.dart'; +import 'package:pangolin_vpn/shell/home_shell.dart'; +import 'package:pangolin_vpn/state/app_providers.dart'; +import 'package:pangolin_vpn/state/auth_provider.dart'; +import 'package:pangolin_vpn/state/navigation_provider.dart'; + +import '../helpers/harness.dart'; + +final AppText t = StringsZh(); + +class _NullTokenStore implements TokenStore { + const _NullTokenStore(); + @override + Future saveTokens({required String access, required String refresh}) async {} + @override + Future loadAccessToken() async => null; + @override + Future loadRefreshToken() async => null; + @override + Future clear() async {} + @override + Future markOnboarded() async {} + @override + Future isOnboarded() async => true; + @override + Future saveLastEmail(String email) async {} + @override + Future loadLastEmail() async => null; +} + +void main() { + setUpAll(disableGoogleFontsFetching); + + testWidgets('account page IA: invite entry present / language moved out / redeem present', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + tester.view.physicalSize = const Size(920, 1400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + try { + await tester.pumpWidget(ProviderScope( + overrides: [ + tokenStoreProvider.overrideWithValue(const _NullTokenStore()), + vpnBridgeProvider.overrideWithValue(VpnBridgeMock()), + appTextProvider.overrideWithValue(t), + navViewProvider.overrideWith((ref) => NavView.account), + ], + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: PangolinTheme.light, + home: const HomeShell(), + ), + )); + await tester.pumpAndSettle(); + + expect(find.byType(DesktopShell), findsOneWidget); + expect(find.byType(AccountPage), findsOneWidget); + + // 邀请入口存在(账户页内)。 + expect(find.descendant(of: find.byType(AccountPage), matching: find.text(t.inviteEntry)), findsOneWidget); + // 语言/主题不在账户页(移到 Settings)。 + expect(find.descendant(of: find.byType(AccountPage), matching: find.text(t.language)), findsNothing); + // 兑换在账户页(购买&订单&兑换块)。 + expect(find.descendant(of: find.byType(AccountPage), matching: find.text(t.redeemEntry)), findsOneWidget); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); +} diff --git a/client/test/widget/auth_invite_field_test.dart b/client/test/widget/auth_invite_field_test.dart new file mode 100644 index 0000000..f3db199 --- /dev/null +++ b/client/test/widget/auth_invite_field_test.dart @@ -0,0 +1,103 @@ +// auth_invite_field_test.dart — Task 13: 注册页「邀请码(选填)」输入框 +// +// 驱动路径:切到注册 tab(step0)→ 填邮箱 → 点发送验证码(mock 网络 204)→ +// 填 6 位验证码 → 点下一步(进 step1 设密码步)→ 断言 step1 渲染出 +// key 为 invite-code-field 的 TextField,label 为 t.inviteCodeFieldLabel。 +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:pangolin_vpn/l10n/strings_zh.dart'; +import 'package:pangolin_vpn/pangolin_theme.dart'; +import 'package:pangolin_vpn/services/auth_api.dart'; +import 'package:pangolin_vpn/services/token_store.dart'; +import 'package:pangolin_vpn/state/auth_provider.dart'; +import 'package:pangolin_vpn/widgets/auth_screen.dart'; + +import '../helpers/harness.dart'; + +class _NullTokenStore implements TokenStore { + const _NullTokenStore(); + @override + Future saveTokens({required String access, required String refresh}) async {} + @override + Future loadAccessToken() async => null; + @override + Future loadRefreshToken() async => null; + @override + Future clear() async {} + @override + Future markOnboarded() async {} + @override + Future isOnboarded() async => true; + @override + Future saveLastEmail(String email) async {} + @override + Future loadLastEmail() async => null; +} + +void main() { + setUpAll(disableGoogleFontsFetching); + const t = StringsZh(); + + Future _driveToRegisterStep1(WidgetTester tester) async { + final mockClient = MockClient((req) async { + if (req.method == 'POST' && req.url.path == '/v1/auth/code') { + return http.Response('', 204); + } + return http.Response(jsonEncode({'message_zh': 'unexpected'}), 404); + }); + final api = AuthApi(baseUrl: 'http://test.invalid', client: mockClient); + + await tester.pumpWidget(ProviderScope( + overrides: [tokenStoreProvider.overrideWithValue(const _NullTokenStore())], + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: PangolinTheme.light, + home: AuthScreen(t: t, onDone: () {}, api: api), + ), + )); + await tester.pumpAndSettle(); + + // 切到注册 tab。 + await tester.tap(find.text(t.tabRegister)); + await tester.pumpAndSettle(); + + // step0:填邮箱 → 点发送验证码(mock 204)。 + await tester.enterText(find.byType(TextField).first, 'invitee@example.com'); + await tester.pump(); + await tester.tap(find.text(t.sendCode)); + await tester.pumpAndSettle(); + + // 填 6 位验证码 → 下一步,进 step1。 + await tester.enterText(find.byType(TextField).at(1), '123456'); + await tester.pump(); + await tester.tap(find.text(t.doNext)); + await tester.pumpAndSettle(); + } + + testWidgets('注册 step1(设密码步)渲染邀请码(选填)输入框', (tester) async { + await _driveToRegisterStep1(tester); + + expect(find.byKey(const Key('invite-code-field')), findsOneWidget); + expect(find.text(t.inviteCodeFieldLabel), findsOneWidget); + }); + + testWidgets('登录路径不含邀请码输入框', (tester) async { + final api = AuthApi(baseUrl: 'http://test.invalid', client: MockClient((req) async => http.Response('', 404))); + await tester.pumpWidget(ProviderScope( + overrides: [tokenStoreProvider.overrideWithValue(const _NullTokenStore())], + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: PangolinTheme.light, + home: AuthScreen(t: t, onDone: () {}, api: api), + ), + )); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('invite-code-field')), findsNothing); + }); +} diff --git a/client/test/widget/connect_button_test.dart b/client/test/widget/connect_button_test.dart index 89f27d7..306a1d7 100644 --- a/client/test/widget/connect_button_test.dart +++ b/client/test/widget/connect_button_test.dart @@ -30,7 +30,7 @@ void main() { testWidgets('connecting 态:loader 图标', (tester) async { await tester.pumpWidget(button(VpnPhase.connecting)); await tester.pump(const Duration(milliseconds: 100)); - expect(find.byIcon(PangolinIcons.loader), findsOneWidget); + expect(find.byIcon(PangolinIcons.loaderCircle), findsOneWidget); }); testWidgets('on 态:盾勾 + 计时 + 已加密', (tester) async { diff --git a/client/test/widget/invite_page_test.dart b/client/test/widget/invite_page_test.dart new file mode 100644 index 0000000..cac73b0 --- /dev/null +++ b/client/test/widget/invite_page_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pangolin_vpn/l10n/strings_en.dart'; +import 'package:pangolin_vpn/screens/invite_page.dart'; +import 'package:pangolin_vpn/services/invite_api.dart'; +import 'package:pangolin_vpn/state/invite_provider.dart'; + +import '../helpers/harness.dart'; + +void main() { + testWidgets('邀请页显示真实码 + 战绩 + TG 任务卡', (tester) async { + const info = InviteInfo(code: 'ABCD2345', link: 'https://x/i/ABCD2345', + invited: 3, converted: 1, earnedDays: 16, tgEnabled: true, tgJoined: false, channel: '@pangolin_app'); + await tester.binding.setSurfaceSize(const Size(400, 1200)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(wrapThemed( + InviteScreen(t: StringsEn()), + overrides: [inviteProvider.overrideWith(() => _FakeNotifier(info))], + )); + await tester.pumpAndSettle(); + expect(find.text('ABCD2345'), findsOneWidget); + expect(find.text('Join channel'), findsOneWidget); // TG 任务卡 + }); +} + +class _FakeNotifier extends InviteNotifier { + _FakeNotifier(this._v); + final InviteInfo _v; + @override + Future build() async => _v; +} diff --git a/client/test/widget/notifications_page_test.dart b/client/test/widget/notifications_page_test.dart new file mode 100644 index 0000000..49648b7 --- /dev/null +++ b/client/test/widget/notifications_page_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pangolin_vpn/l10n/strings_en.dart'; +import 'package:pangolin_vpn/screens/notifications_page.dart'; +import 'package:pangolin_vpn/services/notices_api.dart'; +import 'package:pangolin_vpn/state/notices_provider.dart'; + +import '../helpers/harness.dart'; + +void main() { + testWidgets('通知页真实数据渲染:type pill + 未读红点 + version 去更新按钮 + 进页清读', + (tester) async { + final now = DateTime.now(); + final data = NoticesData(items: [ + NoticeItem( + id: 1, + type: 'reward', + titleZh: '测试到账', + titleEn: 'Reward credited', + bodyZh: '你获得了 7 天会员', + bodyEn: 'You earned 7 days membership', + link: '', + publishedAt: now, + unread: true, + ), + NoticeItem( + id: 2, + type: 'version', + titleZh: '新版本发布', + titleEn: 'New version available', + bodyZh: '', + bodyEn: '', + link: '', + publishedAt: now, + unread: false, + ), + NoticeItem( + id: 3, + type: 'news', + titleZh: '产品新闻', + titleEn: 'Product news', + bodyZh: '', + bodyEn: '', + link: '', + publishedAt: now, + unread: false, + ), + ], unreadCount: 1); + + final fake = _FakeNotifier(data); + + await tester.binding.setSurfaceSize(const Size(420, 900)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget(wrapThemed( + NotificationsScreen(t: StringsEn()), + overrides: [noticesProvider.overrideWith(() => fake)], + )); + await tester.pumpAndSettle(); + + // reward 行 type pill 文案(StringsEn 的 'Credited')在。 + expect(find.text('Credited'), findsOneWidget); + // reward 行未读红点在(fake.markAllRead 不改写数据,仅记录调用)。 + expect(find.byKey(const ValueKey('notif-unread-1')), findsOneWidget); + // version 行「去更新」按钮在(t.lang.updateNow == 'Update now')。 + expect(find.text('Update now'), findsOneWidget); + // 首帧后调用了 markAllRead()。 + expect(fake.markAllReadCalled, isTrue); + }); +} + +/// 照 invite_page_test.dart 的 `_FakeNotifier extends InviteNotifier` 模式:override +/// build() 直接返回固定数据;markAllRead 只记录调用、不改写数据(便于断言未读态渲染 +/// 与「已调用清读」两件事互不干扰)。 +class _FakeNotifier extends NoticesNotifier { + _FakeNotifier(this._v); + final NoticesData _v; + bool markAllReadCalled = false; + + @override + Future build() async => _v; + + @override + Future markAllRead() async { + markAllReadCalled = true; + } +} diff --git a/client/test/widget/orders_page_test.dart b/client/test/widget/orders_page_test.dart new file mode 100644 index 0000000..5a9f152 --- /dev/null +++ b/client/test/widget/orders_page_test.dart @@ -0,0 +1,86 @@ +// orders_page_test.dart — 订单列表/详情页行为测试(不打网络,provider 注入)。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pangolin_vpn/l10n/strings_zh.dart'; +import 'package:pangolin_vpn/models/payment.dart'; +import 'package:pangolin_vpn/screens/orders_page.dart'; +import 'package:pangolin_vpn/state/orders_provider.dart'; +import 'package:pangolin_vpn/widgets/status_pill.dart'; + +import '../helpers/harness.dart'; + +final t = StringsZh(); + +final _orders = [ + PayOrderSummary( + orderNo: 'o-year', sku: 'pro_year', plan: 'pro', days: 366, method: 'crypto', + status: 'paid', amountMinor: 34990000, currency: 'USDT', + createdAt: DateTime.utc(2026, 7, 10), paidAt: DateTime.utc(2026, 7, 10)), + PayOrderSummary( + orderNo: 'o-month', sku: 'pro_month', plan: 'pro', days: 31, method: 'alipay', + status: 'created', amountMinor: 2999, currency: 'CNY', + createdAt: DateTime.utc(2026, 7, 11)), + PayOrderSummary( + orderNo: 'o-q', sku: 'pro_quarter', plan: 'pro', days: 92, method: 'nezha', + status: 'canceled', amountMinor: 6888, currency: 'CNY', + createdAt: DateTime.utc(2026, 7, 9)), +]; + +void main() { + setUpAll(disableGoogleFontsFetching); + + testWidgets('订单列表:三单渲染 + 结算币种金额 + 状态 pill', (tester) async { + tester.view.physicalSize = const Size(800, 2400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpWidget(wrapThemed( + OrdersScreen(t: t, embedded: true), + overrides: [ordersProvider.overrideWith((ref) async => _orders)], + )); + await tester.pumpAndSettle(); + + // 套餐名(逐档) + expect(find.text(t.proYearly), findsOneWidget); + expect(find.text(t.proMonthly), findsOneWidget); + expect(find.text(t.proQuarterly), findsOneWidget); + // 金额按结算币种(USDT $ / CNY ¥),在「金额 · 日期」子行里 + expect(find.textContaining(r'$34.99'), findsOneWidget); + expect(find.textContaining('¥29.99'), findsOneWidget); + // 状态 pill(created→等待付款 / paid→已开通 / canceled→已取消) + expect(find.byType(StatusPill), findsNWidgets(3)); + expect(find.text(t.awaitingPayment), findsOneWidget); + expect(find.text(t.orderStatusPaid), findsOneWidget); + expect(find.text(t.orderStatusCanceled), findsOneWidget); + }); + + testWidgets('订单空态', (tester) async { + await tester.pumpWidget(wrapThemed( + OrdersScreen(t: t, embedded: true), + overrides: [ordersProvider.overrideWith((ref) async => const [])], + )); + await tester.pumpAndSettle(); + expect(find.text(t.ordersEmpty), findsOneWidget); + }); + + testWidgets('点 created 单进详情:显订单号 + 继续支付', (tester) async { + tester.view.physicalSize = const Size(800, 2400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpWidget(wrapThemed( + OrdersScreen(t: t, embedded: true), + overrides: [ + ordersProvider.overrideWith((ref) async => _orders), + orderDetailProvider('o-month').overrideWith((ref) async => PayOrderStatus( + orderNo: 'o-month', payStatus: 'pending', activated: false, summary: _orders[1])), + ], + )); + await tester.pumpAndSettle(); + // 点 created 那单(月付)进详情 + await tester.tap(find.text(t.proMonthly)); + await tester.pumpAndSettle(); + // 详情字段行 + created 态动作:继续支付(主)+ 取消订单(ghost,原型 pending 双按钮) + expect(find.text(t.cancelOrder), findsOneWidget); + expect(find.text(t.orderNoLabel), findsOneWidget); + expect(find.text(t.orderContinuePay), findsWidgets); + }); +} diff --git a/client/test/widget/payment_pages_test.dart b/client/test/widget/payment_pages_test.dart index aec726e..58bcd93 100644 --- a/client/test/widget/payment_pages_test.dart +++ b/client/test/widget/payment_pages_test.dart @@ -13,9 +13,16 @@ import 'package:pangolin_vpn/widgets/plan_card.dart'; import '../helpers/harness.dart'; const _items = [ - PayCatalogItem(sku: 'pro_month', plan: 'pro', days: 31, priceMinor: 2999, currency: 'CNY'), - PayCatalogItem(sku: 'pro_quarter', plan: 'pro', days: 92, priceMinor: 6888, currency: 'CNY'), - PayCatalogItem(sku: 'pro_year', plan: 'pro', days: 366, priceMinor: 19999, currency: 'CNY'), + PayCatalogItem(sku: 'pro_month', plan: 'pro', days: 31, priceMinor: 2999, currency: 'CNY', priceUsdtMicro: 4990000), + PayCatalogItem(sku: 'pro_quarter', plan: 'pro', days: 92, priceMinor: 6888, currency: 'CNY', priceUsdtMicro: 12990000), + PayCatalogItem(sku: 'pro_year', plan: 'pro', days: 366, priceMinor: 19999, currency: 'CNY', priceUsdtMicro: 34990000), +]; + +const _itemsWithPromo = [ + PayCatalogItem( + sku: 'pro_month_promo', plan: 'pro', days: 31, priceMinor: 600, currency: 'CNY', + priceUsdtMicro: 1000000, promo: true), + ..._items, ]; /// 轮询生命周期测试专用假 API:记 orderStatus / cancel 调用次数,其余按最小实现返回。 @@ -26,6 +33,9 @@ class _PollingFakePaymentApi implements PaymentApi { @override Future> catalog() async => const []; + @override + Future> listOrders() async => const []; + @override Future createOrder({required String sku, required String method, Map? metadata}) async => const PayOrder( @@ -66,9 +76,38 @@ void main() { )); await tester.pumpAndSettle(); expect(find.byType(PlanCard), findsNWidgets(3)); + expect(find.text(t.proQuarterly), findsOneWidget); + // 默认支付渠道 = USDT → 套餐价显 USDT + expect(find.text('\$4.99'), findsOneWidget); + expect(find.text('\$34.99'), findsOneWidget); + // 切到人民币渠道 → 套餐价改显 ¥ + await tester.tap(find.text(t.payChannelCny)); + await tester.pumpAndSettle(); expect(find.text('¥29.99'), findsOneWidget); expect(find.text('¥199.99'), findsOneWidget); - expect(find.text(t.proQuarterly), findsOneWidget); + }); + + testWidgets('购买页:全员优惠月付特殊卡片(随渠道显价 + 原价划线)', (tester) async { + tester.view.physicalSize = const Size(800, 2400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await tester.pumpWidget(wrapThemed( + PurchaseScreen(t: t, embedded: true), + overrides: [payCatalogProvider.overrideWith((ref) async => _itemsWithPromo)], + )); + await tester.pumpAndSettle(); + // 普通 3 档照旧渲染 + 1 张特殊卡片(非 PlanCard)。 + expect(find.byType(PlanCard), findsNWidgets(3)); + expect(find.text(t.promoBadge), findsOneWidget); + // 默认 USDT → 现价 \$1.00;原价(pro_month)\$4.99 同时出现在划线原价 + 下方普通 + // pro_month PlanCard(两处同值,断言 2 处)。 + expect(find.text('\$1.00'), findsOneWidget); + expect(find.text('\$4.99'), findsNWidgets(2)); + // 切到人民币 → 现价 ¥6.00;原价 ¥29.99 同理 2 处。 + await tester.tap(find.text(t.payChannelCny)); + await tester.pumpAndSettle(); + expect(find.text('¥6.00'), findsOneWidget); + expect(find.text('¥29.99'), findsNWidgets(2)); }); testWidgets('支付页 crypto_address:地址/金额/复制,金额用 mono', (tester) async { diff --git a/client/test/widget/settings_ia_test.dart b/client/test/widget/settings_ia_test.dart new file mode 100644 index 0000000..0938f28 --- /dev/null +++ b/client/test/widget/settings_ia_test.dart @@ -0,0 +1,85 @@ +// settings_ia_test.dart — 设置页 IA 结构断言:三个分组标题存在 / 购买入口不在设置页(归 Account)。 +// +// 桌面态(windows 平台 + 宽 surface),经 HomeShell 渲染真实 DesktopShell → SettingsPage, +// 与 account_ia_test.dart 同一套 harness。 +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pangolin_vpn/bridge/vpn_bridge_mock.dart'; +import 'package:pangolin_vpn/bridge/vpn_bridge_provider.dart'; +import 'package:pangolin_vpn/l10n/app_text.dart'; +import 'package:pangolin_vpn/l10n/strings_zh.dart'; +import 'package:pangolin_vpn/pangolin_theme.dart'; +import 'package:pangolin_vpn/screens/settings_page.dart'; +import 'package:pangolin_vpn/services/token_store.dart'; +import 'package:pangolin_vpn/shell/desktop_shell.dart'; +import 'package:pangolin_vpn/shell/home_shell.dart'; +import 'package:pangolin_vpn/state/app_providers.dart'; +import 'package:pangolin_vpn/state/auth_provider.dart'; +import 'package:pangolin_vpn/state/navigation_provider.dart'; + +import '../helpers/harness.dart'; + +final AppText t = StringsZh(); + +class _NullTokenStore implements TokenStore { + const _NullTokenStore(); + @override + Future saveTokens({required String access, required String refresh}) async {} + @override + Future loadAccessToken() async => null; + @override + Future loadRefreshToken() async => null; + @override + Future clear() async {} + @override + Future markOnboarded() async {} + @override + Future isOnboarded() async => true; + @override + Future saveLastEmail(String email) async {} + @override + Future loadLastEmail() async => null; +} + +void main() { + setUpAll(disableGoogleFontsFetching); + + testWidgets('settings page IA: three group labels present / purchase entry moved out to account', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + tester.view.physicalSize = const Size(920, 1400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + try { + await tester.pumpWidget(ProviderScope( + overrides: [ + tokenStoreProvider.overrideWithValue(const _NullTokenStore()), + vpnBridgeProvider.overrideWithValue(VpnBridgeMock()), + appTextProvider.overrideWithValue(t), + navViewProvider.overrideWith((ref) => NavView.settings), + ], + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: PangolinTheme.light, + home: const HomeShell(), + ), + )); + await tester.pumpAndSettle(); + + expect(find.byType(DesktopShell), findsOneWidget); + expect(find.byType(SettingsPage), findsOneWidget); + + // 三个分组标题存在(连接/外观/关于)。 + expect(find.descendant(of: find.byType(SettingsPage), matching: find.text(t.settingsGroupConn)), findsOneWidget); + expect(find.descendant(of: find.byType(SettingsPage), matching: find.text(t.settingsGroupAppearance)), findsOneWidget); + expect(find.descendant(of: find.byType(SettingsPage), matching: find.text(t.settingsGroupAbout)), findsOneWidget); + + // 购买入口不在设置页(归 Account)。 + expect(find.descendant(of: find.byType(SettingsPage), matching: find.text(t.purchaseTitle)), findsNothing); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); +} diff --git a/client/windows/installer/pangolin.iss b/client/windows/installer/pangolin.iss index bfb65ce..4166cec 100644 --- a/client/windows/installer/pangolin.iss +++ b/client/windows/installer/pangolin.iss @@ -3,7 +3,7 @@ ; 前置: 先在 client/ 跑 `flutter build windows`(Release),产物在 ; ..\..\build\windows\x64\runner\Release #define MyAppName "穿山甲 Pangolin" -#define MyAppVersion "1.0.47" +#define MyAppVersion "1.0.73" #define MyAppPublisher "Pangolin" #define MyAppExeName "pangolin_vpn.exe" #define BuildDir "..\..\build\windows\x64\runner\Release" diff --git a/design/CLAUDE.md b/design/CLAUDE.md index 6ad46dc..bff10e7 100644 --- a/design/CLAUDE.md +++ b/design/CLAUDE.md @@ -96,7 +96,7 @@ SKILL.md ← Agent Skill 入口 - 登录/注册(`AuthFlow` / `auth_screen.dart`):登录=邮箱+密码;注册=邮箱→发验证码→填码→设密码(多端登录) - 首次引导(`Onboarding` / `onboarding_screen.dart`):3 屏(连接全球→授权配置→安全无日志),可跳过 - 主框架 4 Tab(`home_shell.dart`):**连接 / 节点 / 统计 / 账户**;内容区支持**左右滑动切换 Tab**(>60px 且横向位移明显大于纵向,200ms 方向感知滑入;子页不响应) - - 连接:核心连接键三态(off 暖灰底+虚线轨道环 / connecting 旋转弧 / on 绿底+满环+计时)+ 当前节点卡 + 实时速率;**免费版额外显示额度卡**(今日剩余分钟 + 进度条(≤3 分钟变 warning 色)+ 「看广告开始使用」→ 点击后变绿色「已解锁 · 今日可用」) + - 连接:核心连接键**四态**(off 暖灰底+虚线轨道环 / connecting 旋转弧 / on 绿底+满环+计时 / **disabled 免费到点切断:暖灰底+lock 图标+not-allowed,点击弹「看广告加时」**)+ 当前节点卡 + 实时速率;**免费版额外显示额度卡**(今日剩余分钟 + 进度条(≤3 分钟变 warning、耗尽变 danger)+ 「看广告加时」;**连接期用倒计时收缩条表达剩余,无「已解锁」绿态** —— 旧「点击后变绿色已解锁」描述已废,以线上 `quota_card.dart` 为准) - 节点:顶部置顶**「智能选择」推荐卡**(常驻 accent-subtle 底 + clay 渐变 zap 图标 + 「推荐」胶囊 + 文案「根据当前网络环境,自动选择最优节点」,默认选中);其下才是搜索 + 节点列表(国家码块·延迟·信号条·选中态) - 统计:流量/延迟/时长指标卡 + 本周柱状图 - 账户:套餐横幅(免费版/PRO 自适应)+ 账户信息(邮箱/密码/退出)+ 设备管理 + 兑换/联系入口 @@ -108,10 +108,11 @@ SKILL.md ← Agent Skill 入口 **Web 用户中心**(`ui_kits/usercenter/`):概览(套餐状态/用量)· 订阅导入(订阅链接+二维码+一键导入三方客户端)· 兑换 · 邀请返利 · 设置(含 **2FA/TOTP 开关**,用户可自选开启)。响应式,移动端底部 Tab + 左右滑动切换。 -**核心连接键**(最重要的组件,务必还原):圆形,三态—— -- off:暖灰底 `sand-100` + clay power 图标 + 虚线轨道环 + 「点击连接」 +**核心连接键**(最重要的组件,务必还原):圆形,**四态**(对齐线上 `connect_button.dart`)—— +- off:暖灰底 `bg-subtle` + clay power 图标 + 虚线轨道环 + 「点击连接」 - connecting:clay 实底 + 旋转进度弧 + 旋转 loader - on:success 实底 + 白盾勾 + 满白环 + 圆内计时 + 「已加密」+ 绿光晕 +- **disabled**(免费到点切断/额度耗尽):`bg-subtle` 底 + `fg3` 前景 + **lock 图标**(非 power)+ not-allowed;点击走「看广告加时」。原型原子 `.connect-btn.is-disabled` > 注意:背景在渐变↔纯色间切换时,过渡只用 `box-shadow`/`background-color`,**不要 `transition: all`**(会导致背景计算失效)。 --- @@ -169,5 +170,5 @@ SKILL.md ← Agent Skill 入口 - [ ] 明/暗两主题均验证过;中/英两语言均验证过(单显不并排) - [ ] 文案无铁律 13 红线词;套餐数字与 §7 一致 - [ ] 图标只用 Lucide;国家码块无 emoji 国旗;状态胶囊无 emoji -- [ ] 连接键三态、智能选择默认选中、免费版额度/广告解锁流程与 React 原型一致 +- [ ] 连接键四态(含免费到点 disabled 锁态)、智能选择默认选中、免费版额度(倒计时收缩条)/看广告加时流程与原型单源一致 - [ ] App/官网内无任何支付表单,购买只引导到外部渠道 diff --git a/design/codegen/gen_flutter_icons.mjs b/design/codegen/gen_flutter_icons.mjs new file mode 100644 index 0000000..b6fd06b --- /dev/null +++ b/design/codegen/gen_flutter_icons.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node +// gen_flutter_icons.mjs —— 图标单源 codegen(ds-flow step 2a)。 +// 图标真源 = design/prototype/icons.js(Lucide sprite)。读其 key 集,kebab→camel, +// 生成 client/lib/widgets/pangolin_icons.dart(每个 sprite key 一行 static const)。 +// 零依赖(Node fs)。运行:node design/codegen/gen_flutter_icons.mjs +import { readFileSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, '..', '..'); +const SPRITE = resolve(root, 'design/prototype/icons.js'); +const OUT = resolve(root, 'client/lib/widgets/pangolin_icons.dart'); + +const src = readFileSync(SPRITE, 'utf8'); +// 抽 key:形如 'kebab-name': '<... +const keys = []; +const re = /'([a-z0-9-]+)'\s*:\s*' (seen.has(k) ? false : (seen.add(k), true))); + +const camel = (k) => k.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase()); + +const lines = ordered.map((k) => { + const c = camel(k); + return ` static const ${c} = LucideIcons.${c};`; +}); + +const out = `// GENERATED by design/codegen/gen_flutter_icons.mjs —— 勿手改;图标真源=design/prototype/icons.js +// 重新生成:node design/codegen/gen_flutter_icons.mjs +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +class PangolinIcons { + PangolinIcons._(); + +${lines.join('\n')} +} +`; + +writeFileSync(OUT, out); +console.log(`generated ${OUT} (${ordered.length} icons)`); diff --git a/design/prototype/assets b/design/prototype/assets new file mode 120000 index 0000000..ec2e4be --- /dev/null +++ b/design/prototype/assets @@ -0,0 +1 @@ +../assets \ No newline at end of file diff --git a/design/prototype/atoms.css b/design/prototype/atoms.css index cbc0307..410a8bd 100644 --- a/design/prototype/atoms.css +++ b/design/prototype/atoms.css @@ -82,8 +82,8 @@ } .btn-subtle:hover { border-color: var(--accent); color: var(--accent); } -/* Danger — 危险动作(断开 / 移除设备) */ -.btn-danger { background: var(--danger-subtle); color: var(--red-600); } +/* Danger — 危险动作(断开 / 移除设备)。文字用语义 --danger(对齐线上 c.danger,随主题翻色) */ +.btn-danger { background: var(--danger-subtle); color: var(--danger); } .btn-danger:hover { background: var(--danger); color: var(--fg-on-accent); } /* 尺寸 / 形态修饰 */ @@ -92,14 +92,25 @@ .btn-block { width: 100%; } +/* ============================================================= + VIEW BODY · .view-body (+ .is-wide) + 桌面客户端内容视图统一宽度容器 —— 对齐 Flutter PageBody(client/lib/widgets/page_body.dart)。 + 默认 600px 居中(单栏内容页:账户/设置/联系我们/统计/订单/通知/邀请); + .is-wide 840px(多列网格页:购买套餐)。宽度标量本身无对应 token,字面写死属正常。 + ============================================================= */ +.view-body{ max-width:600px; margin:0 auto; padding:var(--space-5) var(--space-6) var(--space-6); } +.view-body.is-wide{ max-width:840px; } + + /* ============================================================= CARD · .card - surface 底 + border + radius-xl + shadow-sm + surface 底 + border + radius-lg + shadow-sm + 圆角对齐线上 cardTheme(PangolinRadius.lg=14);阴影保留 shadow-sm(暖阴影铁律)。 ============================================================= */ .card { background: var(--surface); border: 1px solid var(--border); - border-radius: var(--radius-xl); + border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); padding: var(--space-5); } @@ -164,27 +175,29 @@ line-height: 1; padding: var(--space-2) var(--space-3); border-radius: var(--radius-full); - background: var(--sand-100); + background: var(--bg-subtle); color: var(--fg2); } .pill .dot { width: 7px; height: 7px; border-radius: var(--radius-full); - background: var(--sand-400); + background: var(--fg3); flex-shrink: 0; } /* alias:.badge === .pill(语义等价,命名习惯不同) */ .badge { } -.pill.is-success { background: var(--success-subtle); color: var(--green-600); } +/* 状态色对齐线上 status_pill.dart:文字用语义档 --success/--warning/--danger(非 -600 深档), + neutral 用 --bg-subtle/--fg3(原 sand-100/sand-400 硬编码在深色不翻色,已修)。 */ +.pill.is-success { background: var(--success-subtle); color: var(--success); } .pill.is-success .dot { background: var(--success); } -.pill.is-warning { background: var(--warning-subtle); color: var(--amber-600); } +.pill.is-warning { background: var(--warning-subtle); color: var(--warning); } .pill.is-warning .dot { background: var(--warning); } -.pill.is-danger { background: var(--danger-subtle); color: var(--red-600); } +.pill.is-danger { background: var(--danger-subtle); color: var(--danger); } .pill.is-danger .dot { background: var(--danger); } -.pill.is-neutral { background: var(--sand-100); color: var(--fg2); } -.pill.is-neutral .dot { background: var(--sand-400); } +.pill.is-neutral { background: var(--bg-subtle); color: var(--fg2); } +.pill.is-neutral .dot { background: var(--fg3); } /* 实心 accent(PRO 会员等强调标签) */ .pill-accent { background: var(--accent); color: var(--fg-on-accent); } @@ -246,6 +259,8 @@ gap: 1px; z-index: 60; } +/* hidden 属性生效:菜单默认收起,靠 JS 去/加 hidden 或 .is-open 控制显隐 */ +.menu[hidden] { display: none; } .menu-item { display: block; width: 100%; @@ -270,3 +285,432 @@ color: var(--accent); font-weight: 700; } + + +/* ============================================================= + PLAN CARD · .plan-card (购买套餐卡) + surface 底常规档;.is-featured 走 clay 渐变高亮(年付/最优惠)。 + 镜像 client/lib/widgets/plan_card.dart 的视觉配方(原型先行单源)。 + 子件:.plan-name / .plan-price / .plan-per / .plan-feats / .plan-feat + / .plan-pop(顶部「最受欢迎」浮标)。 + ============================================================= */ +.plan-card { + position: relative; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-sm); + padding: var(--space-6) var(--space-5) var(--space-5); +} +.plan-name { + font-family: var(--font-sans); + font-size: var(--text-sm); + font-weight: 600; + color: var(--fg2); +} +.plan-price-row { display: flex; align-items: baseline; gap: var(--space-2); margin-top: var(--space-2); } +.plan-price { + font-family: var(--font-display); + font-size: var(--text-display); + font-weight: 700; + line-height: 1; + letter-spacing: var(--tracking-tight); + color: var(--fg1); +} +.plan-per { font-family: var(--font-sans); font-size: var(--text-sm); color: var(--fg3); } +.plan-feats { margin: var(--space-4) 0 var(--space-5); display: flex; flex-direction: column; gap: var(--space-2); } +.plan-feat { display: flex; align-items: center; gap: var(--space-3); font-family: var(--font-sans); font-size: var(--text-sm); color: var(--fg2); } +.plan-feat > svg { width: 15px; height: 15px; flex-shrink: 0; color: var(--success); } + +/* 「最受欢迎」浮标:白底胶囊悬于卡顶 */ +.plan-pop { + position: absolute; + top: calc(-1 * var(--space-3)); + left: 50%; + transform: translateX(-50%); + background: #fff; + color: var(--clay-700); + font-family: var(--font-sans); + font-size: var(--text-caption); + font-weight: 700; + padding: var(--space-1) var(--space-3); + border-radius: var(--radius-full); + box-shadow: var(--shadow-sm); + white-space: nowrap; +} + +/* Featured — clay 渐变高亮(年付/最优惠)。渐变内文字恒为白,靠 opacity 分级。 */ +.plan-card.is-featured { + background: linear-gradient(135deg, var(--clay-600), var(--clay-800)); + border-color: transparent; + box-shadow: var(--shadow-md); +} +.plan-card.is-featured .plan-name { color: #fff; opacity: 0.85; } +.plan-card.is-featured .plan-price { color: #fff; } +.plan-card.is-featured .plan-per { color: #fff; opacity: 0.85; } +.plan-card.is-featured .plan-feat { color: #fff; opacity: 0.92; } +.plan-card.is-featured .plan-feat > svg { color: #fff; } +/* Featured 卡内 CTA 反色:白底 clay 字 */ +.plan-card.is-featured .btn-primary { background: #fff; color: var(--clay-700); } +.plan-card.is-featured .btn-primary:hover { background: #fff; opacity: 0.92; } + + +/* ============================================================= + PAY METHOD · .pay-method (选择支付方式列表项) + 底部弹层里的一行:leading 图标 + 标题 + trailing chevron。 + 资金外流:点选后拉起外部渠道 / 展示地址,App 内无卡号录入表单(铁律 #10)。 + ============================================================= */ +.pay-method { + display: flex; + align-items: center; + gap: var(--space-4); + width: 100%; + padding: var(--space-4) var(--space-4); + border: none; + background: transparent; + border-radius: var(--radius-md); + cursor: pointer; + text-align: left; + transition: background-color var(--dur-fast) var(--ease-out); +} +.pay-method:hover { background: var(--bg-subtle); } +.pay-method > .pm-ic { width: 22px; height: 22px; flex-shrink: 0; color: var(--fg2); } +.pay-method > .pm-title { flex: 1; font-family: var(--font-sans); font-size: var(--text-body); color: var(--fg1); } +.pay-method > .pm-chev { width: 18px; height: 18px; flex-shrink: 0; color: var(--fg3); } + + +/* ============================================================= + KV ROW · .kv-row (可复制键值行 — 支付明细:网络/地址/金额) + label(次要)+ 等宽值 + 复制按钮。用于 crypto_address / qr 支付态。 + ============================================================= */ +.kv-row { display: flex; align-items: flex-start; gap: var(--space-3); padding: var(--space-2) 0; } +.kv-label { width: 76px; flex-shrink: 0; font-family: var(--font-sans); font-size: var(--text-sm); color: var(--fg3); padding-top: 2px; } +.kv-val { flex: 1; min-width: 0; font-family: var(--font-mono); font-size: var(--text-sm); color: var(--fg1); word-break: break-all; font-feature-settings: 'tnum' 1; } +.kv-copy { + flex-shrink: 0; + border: none; + background: transparent; + color: var(--fg3); + cursor: pointer; + padding: var(--space-1); + border-radius: var(--radius-sm); + transition: color var(--dur-fast) var(--ease-out), background-color var(--dur-fast) var(--ease-out); +} +.kv-copy:hover { color: var(--accent); background: var(--accent-subtle); } +.kv-copy > svg { width: 16px; height: 16px; display: block; } + + +/* ============================================================= + RESULT STATE · .result (支付成功 / 失败终态) + 居中大图标 + 标题 + 说明。成功用 --success,失败用 --danger。 + ============================================================= */ +.result { display: flex; flex-direction: column; align-items: center; text-align: center; padding: var(--space-8) var(--space-6); } +.result-icon { + width: 72px; height: 72px; + border-radius: var(--radius-full); + display: flex; align-items: center; justify-content: center; + background: var(--success-subtle); + color: var(--success); + margin-bottom: var(--space-4); +} +.result-icon > svg { width: 38px; height: 38px; } +.result.is-fail .result-icon { background: var(--danger-subtle); color: var(--danger); } +.result-title { font-family: var(--font-display); font-size: var(--text-h2); font-weight: 700; color: var(--fg1); letter-spacing: var(--tracking-snug); } +.result.is-fail .result-title { color: var(--danger); } +.result-sub { margin-top: var(--space-2); font-family: var(--font-sans); font-size: var(--text-sm); color: var(--fg2); } + + +/* ============================================================= + CONNECT BUTTON · .connect-btn (核心连接键 — 产品招牌组件) + 圆形一键连接键,严格三态:.is-off / .is-connecting / .is-on。 + 镜像 client/lib/widgets/connect_button.dart 的视觉配方(原型先行单源)。 + ⚠️ 铁律(design/CLAUDE.md §5):背景在渐变↔纯色间切换,过渡只走 + box-shadow / background-color,禁 transition:all(否则背景计算失效)。 + 注:圆内文字/图标/环在 clay·green 实底上恒为白(对齐 Flutter PangolinColors.white), + 故用白名单 #fff;轨道环半透明白靠 stroke-opacity 属性表达(非 rgba 硬编码色)。 + ============================================================= */ +.connect-btn { + position: relative; + width: 140px; + height: 140px; + border: none; + border-radius: var(--radius-full); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-1); + cursor: pointer; + transition: background-color var(--dur-slow) var(--ease-out), + box-shadow var(--dur-slow) var(--ease-out); +} +.connect-btn:active { transform: scale(0.97); } +.connect-btn:focus-visible { outline: none; box-shadow: var(--shadow-focus); } + +/* 轨道环:铺满按钮的 svg,垫在圆内文字/图标之下 */ +.cb-ring { position: absolute; inset: 0; z-index: 0; } +.cb-ring circle { fill: none; } +.cb-icon, .cb-label, .cb-timer, .cb-sub { position: relative; z-index: 1; } +.cb-icon { width: 38px; height: 38px; color: currentColor; } +.cb-label { + font-family: var(--font-sans); + font-size: var(--text-caption); + font-weight: 700; + letter-spacing: var(--tracking-wide); +} +.cb-timer { + font-family: var(--font-mono); + font-size: var(--text-body-lg); + font-weight: 600; + line-height: 1; + font-feature-settings: 'tnum' 1; +} +.cb-sub { + font-family: var(--font-sans); + font-size: var(--text-caption); + font-weight: 700; + letter-spacing: var(--tracking-caps); + opacity: 0.85; +} +@keyframes cb-spin { to { transform: rotate(360deg); } } +.cb-spin { animation: cb-spin 1.1s linear infinite; transform-origin: 50% 50%; } + +/* OFF:暖灰底(微渐变)+ clay 前景 + 虚线轨道环 */ +.connect-btn.is-off { + background: radial-gradient(circle at 50% 38%, var(--sand-50), var(--sand-100)); + color: var(--accent); + box-shadow: inset 0 0 0 1px var(--sand-200), var(--shadow-md); +} +.connect-btn.is-off .cb-sub { color: var(--fg3); opacity: 1; } +.cb-track { stroke: var(--sand-200); stroke-width: 3; stroke-linecap: round; stroke-dasharray: 2 7; } + +/* CONNECTING:clay 实底 + accent 光晕 + 旋转进度弧 */ +.connect-btn.is-connecting { + background: var(--accent); + color: #fff; + box-shadow: 0 0 0 8px var(--accent-subtle), var(--shadow-lg); +} +.cb-arc-track { stroke: #fff; stroke-opacity: 0.3; stroke-width: 4; } +.cb-arc { stroke: #fff; stroke-width: 4; stroke-linecap: round; stroke-dasharray: 100 302; } + +/* ON:success 实底 + 绿光晕 + 满白环 + 圆内计时 */ +.connect-btn.is-on { + background: var(--success); + color: #fff; + box-shadow: 0 0 0 8px var(--success-subtle), var(--shadow-lg); +} +.cb-ring-full { stroke: #fff; stroke-opacity: 0.85; stroke-width: 4; stroke-linecap: round; } + +/* DISABLED(免费到点切断 / 额度耗尽)——对齐线上 connect_button.dart 第 4 态: + bg-subtle 底 + fg3 前景 + lock 图标 + not-allowed;点击走「看广告加时」。仅 off 场景触发。 */ +.connect-btn.is-disabled { + background: var(--bg-subtle); + color: var(--fg3); + box-shadow: var(--shadow-md); + cursor: not-allowed; +} +.connect-btn.is-disabled .cb-track { stroke: var(--border-strong); } + + +/* ============================================================= + SERVER ROW · .server-row (节点列表行) + 镜像 client/lib/widgets/server_tile.dart + design/preview/comp-server-row.html。 + 放进 .card.card-flush 列表容器;一行 = 国家码块 + 名称/子名 + 属性胶囊 + (.pill-outline) + 延迟(mono·按值变色) + 信号条 + 选中/推荐/不可用态。 + ============================================================= */ +.server-row { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--border); + cursor: pointer; + transition: background-color var(--dur-fast) var(--ease-out); +} +.server-row:last-child { border-bottom: none; } +.server-row:hover { background: var(--bg-subtle); } + +/* 国家码块(2 字母码,绝不用 emoji 国旗 · 铁律 #8) */ +.sr-cc { + width: 34px; height: 34px; flex-shrink: 0; + display: flex; align-items: center; justify-content: center; + border-radius: var(--radius-md); + background: var(--sand-100); + font-family: var(--font-mono); font-size: var(--text-caption); font-weight: 600; + color: var(--fg2); +} +.sr-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.sr-title { font-family: var(--font-sans); font-size: var(--text-sm); font-weight: 600; color: var(--fg1); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.sr-sub { font-family: var(--font-sans); font-size: var(--text-caption); font-weight: 400; color: var(--fg3); } +.sr-tags { display: flex; flex-wrap: wrap; gap: var(--space-2); margin-top: 2px; } +.sr-tags .pill { padding: 3px var(--space-2); font-size: 11px; } + +/* 延迟(等宽 · tabular nums)——对齐线上 server_tile:**恒 fg2,不按阈值变色**; + 快慢改由信号条格数编码。旧 .is-good/.is-warn/.is-bad 一律回落 fg2(兼容不变色)。 */ +.sr-latency { flex-shrink: 0; font-family: var(--font-mono); font-size: var(--text-caption); color: var(--fg2); font-feature-settings: 'tnum' 1; } +.sr-latency.is-good, .sr-latency.is-warn, .sr-latency.is-bad { color: var(--fg2); } + +/* 信号条(纯 div + token 色)——默认暗格 border-strong,按等级点亮 success: + lv3=3 格(ping<40) / lv2=前 2 格(<90) / lv1=第 1 格(≥90)。对齐线上 SignalBars。 */ +.sr-signal { display: flex; align-items: flex-end; gap: 2px; height: 14px; flex-shrink: 0; } +.sr-signal i { width: 3px; border-radius: 1px; background: var(--border-strong); } +.sr-signal i:nth-child(1) { height: 5px; } +.sr-signal i:nth-child(2) { height: 9px; } +.sr-signal i:nth-child(3) { height: 14px; } +.sr-signal.lv3 i, +.sr-signal.lv2 i:nth-child(-n+2), +.sr-signal.lv1 i:nth-child(1) { background: var(--success); } +.sr-signal i.is-off { background: var(--border-strong); } + +.sr-check { flex-shrink: 0; color: var(--accent); display: inline-flex; } +.sr-check svg { width: 18px; height: 18px; } + +/* 选中态:accent-subtle 底 + 码块反色实心 clay */ +.server-row.is-selected { background: var(--accent-subtle); } +.server-row.is-selected:hover { background: var(--accent-subtle); } +.server-row.is-selected .sr-cc { background: var(--accent); color: var(--fg-on-accent); } + +/* 不可用态(agent 离线) */ +.server-row.is-down { opacity: 0.55; cursor: not-allowed; } +.server-row.is-down:hover { background: transparent; } +.sr-unavail { flex-shrink: 0; font-family: var(--font-sans); font-size: var(--text-caption); font-weight: 600; color: var(--danger); } + +/* 智能选择推荐行(节点页置顶 · design/CLAUDE.md §5) */ +.server-row.is-smart { background: var(--accent-subtle); } +.sr-smart-ic { + width: 34px; height: 34px; flex-shrink: 0; + display: flex; align-items: center; justify-content: center; + border-radius: var(--radius-md); + background: linear-gradient(135deg, var(--clay-500), var(--clay-700)); /* 对齐线上 smart_select_card */ + color: #fff; /* ds-allow: clay 渐变块上恒为白,对齐 .ds-brand .mk / plan-card featured */ +} +.sr-smart-ic svg { width: 18px; height: 18px; } + + +/* ============================================================= + 线上对齐补建原子(2026-07 回填:atoms 对齐 client/lib/widgets) + ============================================================= */ + +/* ICON BOX · accent-subtle 圆角图标框(设备/设置行前导) */ +.icon-box { width: 36px; height: 36px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-md); background: var(--accent-subtle); color: var(--accent); } +.icon-box > svg { width: 18px; height: 18px; } +.icon-box.is-danger { background: var(--danger-subtle); color: var(--danger); } + +/* STATUS DOT · 独立状态点 */ +.status-dot { width: 8px; height: 8px; border-radius: var(--radius-full); background: var(--fg3); flex-shrink: 0; display: inline-block; } +.status-dot.is-online { background: var(--success); } +.status-dot.is-offline { background: var(--fg3); } +.status-dot.is-busy { background: var(--warning); } + +/* TOGGLE · 开关(对齐线上 dtoggle) */ +.toggle { width: 42px; height: 24px; flex-shrink: 0; border-radius: var(--radius-full); background: var(--border-strong); border: none; cursor: pointer; position: relative; transition: background-color var(--dur-fast) var(--ease-out); } +.toggle > i { position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: var(--radius-full); background: #fff; box-shadow: var(--shadow-sm); transition: transform var(--dur-fast) var(--ease-out); } /* ds-allow: 开关滑块恒为白 */ +.toggle.is-on { background: var(--accent); } +.toggle.is-on > i { transform: translateX(18px); } + +/* SEGSWITCH · 二选一段控开关(支付渠道/币种等的横向 switch;iOS 式滑块高亮) */ +.segswitch { display: flex; background: var(--bg-subtle); border: 1px solid var(--border); border-radius: var(--radius-full); padding: var(--space-1); gap: var(--space-1); } +.segswitch > .segswitch-opt { flex: 1; border: none; background: transparent; cursor: pointer; font-family: var(--font-sans); font-size: var(--text-sm); font-weight: 600; color: var(--fg2); padding: var(--space-2) var(--space-4); border-radius: var(--radius-full); display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2); transition: background-color var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out); } +.segswitch > .segswitch-opt > svg { width: 16px; height: 16px; flex-shrink: 0; } +.segswitch > .segswitch-opt.is-active { background: var(--surface); color: var(--fg1); box-shadow: var(--shadow-sm); } + +/* FIELD BOX · 复合输入(前置图标 + 裸 input + 后置槽;对齐 pangolin_field.dart) */ +.field-box { display: flex; align-items: center; gap: var(--space-3); background: var(--surface); border: 1.5px solid var(--border-strong); border-radius: var(--radius-md); padding: 0 var(--space-4); transition: border-color var(--dur-fast) var(--ease-out), box-shadow var(--dur-fast) var(--ease-out); } +.field-box.is-focused { border-color: var(--accent); box-shadow: var(--shadow-focus); } +.field-box .fb-lead { width: 18px; height: 18px; flex-shrink: 0; color: var(--fg3); } +.field-box .fb-input { flex: 1; min-width: 0; border: none; background: transparent; font-family: var(--font-sans); font-size: var(--text-sm); color: var(--fg1); padding: var(--space-3) 0; } +.field-box .fb-input:focus { outline: none; } +.field-box .fb-input::placeholder { color: var(--fg3); } +.field-box .fb-trailing { flex-shrink: 0; display: inline-flex; color: var(--fg3); } +.field-box.is-plain { border-color: transparent; background: var(--bg-subtle); box-shadow: none; } + +/* TOAST · 轻提示(对齐 pangolin_toast / snackBarTheme) */ +.toast { display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2); text-align: center; background: var(--surface); color: var(--fg1); border: 1px solid var(--border); border-radius: var(--radius-md); box-shadow: var(--shadow-lg); padding: var(--space-3) var(--space-4); font-family: var(--font-sans); font-size: var(--text-sm); font-weight: 600; } +.toast > svg { width: 14px; height: 14px; flex-shrink: 0; } + +/* QUOTA CARD · 免费版今日额度(对齐 quota_card.dart:倒计时收缩条 + 免费版标签 + 三档阈值;无绿色「已解锁」态) */ +.quota-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); padding: var(--space-3) var(--space-4); display: flex; flex-direction: column; gap: var(--space-2); } +.quota-head { display: flex; align-items: center; gap: var(--space-2); } +.quota-head > svg { width: 15px; height: 15px; color: var(--accent); flex-shrink: 0; } +.quota-card.is-exhausted .quota-head > svg { color: var(--danger); } +.quota-label { font-family: var(--font-sans); font-size: var(--text-caption); font-weight: 600; color: var(--fg2); } +.quota-val { font-family: var(--font-mono); font-size: 14px; font-weight: 600; color: var(--fg1); font-feature-settings: 'tnum' 1; } +.quota-tag { margin-left: auto; background: var(--bg-subtle); color: var(--fg3); font-family: var(--font-sans); font-size: 11px; font-weight: 600; padding: 3px var(--space-2); border-radius: var(--radius-full); } +.quota-bar { height: 6px; border-radius: var(--radius-full); background: var(--bg-subtle); overflow: hidden; } +.quota-bar > i { display: block; height: 100%; border-radius: var(--radius-full); background: var(--accent); transition: width var(--dur-base) var(--ease-out); } +.quota-bar.is-low > i { background: var(--warning); } +.quota-bar.is-exhausted > i { background: var(--danger); } + +/* METRIC · 统计指标瓦片 */ +.metric { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); padding: var(--space-3); } +.metric .m-ic { width: 26px; height: 26px; border-radius: var(--radius-sm); background: var(--accent-subtle); color: var(--accent); display: flex; align-items: center; justify-content: center; margin-bottom: var(--space-2); } +.metric .m-ic > svg { width: 15px; height: 15px; } +.metric .m-val { font-family: var(--font-mono); font-size: var(--text-body); font-weight: 600; color: var(--fg1); font-feature-settings: 'tnum' 1; } +.metric .m-label { font-family: var(--font-sans); font-size: 11px; color: var(--fg3); margin-top: 2px; } + +/* DEVICE ROW · 设备管理行(对齐 DevicesScreen:icon-box + 名 + 最后在线 + 本机/移除/⋯) */ +.device-row { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--border); } +.device-row:last-child { border-bottom: none; } +.device-row .dv-body { flex: 1; min-width: 0; } +.device-row .dv-name { font-family: var(--font-sans); font-size: var(--text-sm); font-weight: 600; color: var(--fg1); } +.device-row .dv-sub { font-family: var(--font-sans); font-size: var(--text-caption); color: var(--fg3); margin-top: 2px; display: flex; align-items: center; gap: var(--space-2); } +.device-row .dv-trail { flex-shrink: 0; display: inline-flex; align-items: center; gap: var(--space-2); } + +/* SETTING ROW · 设置/导航行(对齐 settings _Row/_NavRow:icon-box + 标题 + 副标 + trailing) */ +.setting-row { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4); width: 100%; box-sizing: border-box; background: transparent; border: none; border-bottom: 1px solid var(--border); text-align: left; } +.setting-row:last-child { border-bottom: none; } +.setting-row.is-tap { cursor: pointer; transition: background-color var(--dur-fast) var(--ease-out); } +.setting-row.is-tap:hover { background: var(--bg-subtle); } +.setting-row .st-body { flex: 1; min-width: 0; } +.setting-row .st-title { font-family: var(--font-sans); font-size: var(--text-sm); font-weight: 600; color: var(--fg1); } +.setting-row .st-sub { font-family: var(--font-sans); font-size: var(--text-caption); color: var(--fg3); margin-top: 2px; } +.setting-row .st-trail { flex-shrink: 0; display: inline-flex; align-items: center; gap: var(--space-2); color: var(--fg3); } +.setting-row .st-trail > svg { width: 18px; height: 18px; } +.setting-row .st-val { font-family: var(--font-sans); font-size: var(--text-sm); color: var(--fg2); } + +/* PLAN BANNER · 账户页套餐横幅(免费=surface / .is-pro=clay 渐变;对齐 _PlanBanner) */ +.plan-banner { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); padding: var(--space-5); } +.plan-banner .pbn-row { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; row-gap: var(--space-4); } +.plan-banner .pbn-info { flex: 1 1 auto; min-width: 96px; } /* 给名字保底宽度,不被按钮挤成 me… */ +.plan-banner .pbn-row > .btn { flex-shrink: 0; } +.plan-banner .pbn-row > .btn:last-child { margin-left: auto; } /* 宽屏靠右;窄屏换行到下方(整行) */ +@media (max-width: 380px){ .plan-banner .pbn-row > .btn:last-child { flex: 1 1 100%; margin-left: 0; } } +.plan-banner .pbn-badge { width: 44px; height: 44px; flex-shrink: 0; border-radius: var(--radius-lg); display: flex; align-items: center; justify-content: center; background: var(--accent-subtle); color: var(--accent); } +.plan-banner .pbn-badge > svg { width: 24px; height: 24px; } +.plan-banner .pbn-name { font-family: var(--font-display); font-size: var(--text-h3); font-weight: 700; color: var(--fg1); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.plan-banner .pbn-meta { font-family: var(--font-sans); font-size: var(--text-caption); color: var(--fg3); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.plan-banner.is-pro { background: linear-gradient(135deg, var(--clay-600), var(--clay-800)); border-color: transparent; box-shadow: var(--shadow-md); } +.plan-banner.is-pro .pbn-badge { background: transparent; border: 1.5px solid #fff; color: #fff; } +.plan-banner.is-pro .pbn-name { color: #fff; } +.plan-banner.is-pro .pbn-meta { color: #fff; opacity: 0.85; } +.plan-banner.is-pro .btn-primary { background: #fff; color: var(--clay-700); } + +/* PLAN BADGE · 桌面侧栏底部会员卡(对齐 plan_badge_card.dart) */ +.plan-badge { display: flex; align-items: center; gap: var(--space-2); background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-md); padding: var(--space-2) var(--space-3); cursor: pointer; transition: background-color var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out); } +.plan-badge.is-active { background: var(--accent-subtle); border-color: var(--accent); } +.plan-badge .plb-crown { width: 30px; height: 30px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-full); background: linear-gradient(135deg, var(--clay-500), var(--clay-700)); color: #fff; } /* ds-allow: clay 渐变块上恒为白 */ +.plan-badge .plb-crown > svg { width: 15px; height: 15px; } +.plan-badge .plb-body { flex: 1; min-width: 0; display: flex; flex-direction: column; } +.plan-badge .plb-name { font-family: var(--font-sans); font-size: var(--text-caption); font-weight: 600; color: var(--fg1); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.plan-badge .plb-sub { font-family: var(--font-sans); font-size: var(--text-caption); color: var(--fg3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.plan-badge .plb-chev { width: 14px; height: 14px; flex-shrink: 0; color: var(--fg3); } + +/* UPDATE BANNER · jiu 式更新顶部横条(对齐 UpdateBanner;非强制态) */ +.update-banner { display: flex; align-items: center; gap: var(--space-3); background: var(--accent-subtle); padding: var(--space-2) var(--space-3) var(--space-2) var(--space-4); } +.update-banner .ub-ic { width: 17px; height: 17px; flex-shrink: 0; color: var(--accent); } +.update-banner .ub-title { flex: 1; min-width: 0; font-family: var(--font-sans); font-size: var(--text-sm); font-weight: 700; color: var(--fg1); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.update-banner .ub-cta { flex-shrink: 0; background: var(--accent); color: var(--fg-on-accent); border: none; border-radius: var(--radius-full); padding: var(--space-1) var(--space-4); font-family: var(--font-sans); font-size: var(--text-caption); font-weight: 700; cursor: pointer; } +.update-banner .ub-close { flex-shrink: 0; width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; border: none; background: transparent; color: var(--fg3); cursor: pointer; border-radius: var(--radius-sm); } +.update-banner .ub-close > svg { width: 16px; height: 16px; } + +/* TAB BAR · 移动端底部 4-Tab(对齐 bottom_tab_bar.dart) */ +.tabbar { display: flex; height: 60px; border-top: 1px solid var(--border); background: var(--surface); } +.tab { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; border: none; background: none; cursor: pointer; color: var(--fg3); font-family: var(--font-sans); font-size: 11px; font-weight: 600; } +.tab > svg { width: 21px; height: 21px; } +.tab.is-active { color: var(--accent); } + +/* NAV ITEM · 桌面/iPad 左侧栏导航项(对齐 nav_sidebar.dart) */ +.nav-item { display: flex; align-items: center; gap: var(--space-3); width: 100%; padding: var(--space-3); border: none; background: transparent; border-radius: var(--radius-md); cursor: pointer; text-align: left; color: var(--fg2); font-family: var(--font-sans); font-size: var(--text-sm); font-weight: 600; transition: background-color var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out); } +.nav-item > svg { width: 20px; height: 20px; flex-shrink: 0; } +.nav-item:hover { background: var(--bg-subtle); color: var(--fg1); } +.nav-item.is-active { background: var(--accent-subtle); color: var(--accent); } diff --git a/design/prototype/icons.js b/design/prototype/icons.js index 470444a..4327c39 100644 --- a/design/prototype/icons.js +++ b/design/prototype/icons.js @@ -12,6 +12,7 @@ 'link': '', 'ticket': '', 'users': '', + 'bell': '', 'copy': '', 'refresh-cw': '', 'qr-code': '', @@ -56,7 +57,6 @@ 'arrow-right': '', 'compass': '', 'chart-no-axes-column': '', - 'chart': '', 'home': '', 'map-pin': '', 'laptop': '', diff --git a/design/prototype/index.html b/design/prototype/index.html index 777fbbb..3c78cbe 100644 --- a/design/prototype/index.html +++ b/design/prototype/index.html @@ -37,10 +37,33 @@ /* 三栏文档布局 */ .doc{ display:flex; align-items:flex-start; max-width:1360px; margin:0 auto; } .doc-nav{ width:238px; flex:none; position:sticky; top:60px; height:calc(100vh - 60px); - overflow:auto; border-right:1px solid var(--border); padding:var(--space-5) var(--space-4); } - .dgroup{ font-size:var(--text-caption); font-weight:700; color:var(--fg3); letter-spacing:var(--tracking-caps); - text-transform:uppercase; padding:var(--space-4) var(--space-2) var(--space-2); } - .dnav{ display:block; padding:var(--space-2) var(--space-3); border-radius:var(--radius-md); + overflow-y:auto; overscroll-behavior:contain; border-right:1px solid var(--border); + padding:var(--space-5) var(--space-4) calc(var(--space-16) * 2); + transition:width var(--dur-base) var(--ease-out); } + /* 收起态(宽度由 JS 置 0,此处收内边距/边框) */ + .doc.nav-collapsed .doc-nav{ padding-left:0; padding-right:0; border-right:none; overflow:hidden; } + .doc.nav-collapsed .nav-resize{ display:none; } + /* 拖拽把手(改侧栏宽度) */ + .nav-resize{ flex:none; align-self:stretch; position:sticky; top:60px; height:calc(100vh - 60px); + width:9px; margin-left:-5px; cursor:col-resize; z-index:40; background:transparent; } + .nav-resize::after{ content:""; position:absolute; left:4px; top:0; bottom:0; width:2px; border-radius:var(--radius-full); + background:transparent; transition:background-color var(--dur-fast) var(--ease-out); } + .nav-resize:hover::after, .nav-resize.dragging::after{ background:var(--accent); } + body.nav-dragging{ cursor:col-resize; user-select:none; } + /* 顶栏收起按钮 */ + .nav-toggle{ display:inline-flex; align-items:center; justify-content:center; width:34px; height:34px; flex:none; + border:1px solid var(--border); border-radius:var(--radius-md); background:var(--bg-subtle); + color:var(--fg2); cursor:pointer; transition:color var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out); } + .nav-toggle:hover{ color:var(--fg1); border-color:var(--border-strong); } + .nav-toggle svg{ width:18px; height:18px; } + + /* L1 分组头 —— 分隔线 + 提亮加粗,与二级明显区分 */ + .dgroup{ font-size:var(--text-caption); font-weight:700; color:var(--fg2); letter-spacing:var(--tracking-caps); + text-transform:uppercase; padding:var(--space-2) var(--space-2); + margin-top:var(--space-5); border-top:1px solid var(--border); } + .doc-nav .dgroup:first-child{ margin-top:var(--space-1); border-top:none; } + /* L2 条目 —— 缩进内嵌于分组下 */ + .dnav{ display:block; padding:var(--space-2) var(--space-3) var(--space-2) var(--space-5); border-radius:var(--radius-md); font-size:var(--text-sm); color:var(--fg2); text-decoration:none; cursor:pointer; transition:background-color var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out); } .dnav:hover{ background:var(--bg-subtle); color:var(--fg1); } @@ -102,13 +125,36 @@ @media (max-width:840px){ .doc-nav{ display:none; } + .nav-resize{ display:none; } + .nav-toggle{ display:none; } .doc-main{ padding:var(--space-6) var(--space-5) var(--space-12); } } + + /* ── 品牌 / 字体 specimen 展示用(登记页 chrome,非 atoms;全走 token) ── */ + .brand-appicon{ display:flex; align-items:flex-end; gap:var(--space-8); flex-wrap:wrap; } + .brand-appicon .unit{ display:flex; flex-direction:column; align-items:center; gap:var(--space-2); } + .brand-appicon .unit .n{ font-family:var(--font-mono); font-size:11px; color:var(--fg3); } + .brand-appicon img{ display:block; box-shadow:var(--shadow-md); border-radius:22%; } + .lockup{ display:flex; align-items:center; gap:var(--space-3); } + .lockup img{ display:block; } + .lockup .tw{ display:flex; flex-direction:column; line-height:1; } + .lockup .cn{ font-family:var(--font-display); font-weight:700; font-size:22px; letter-spacing:var(--tracking-snug); color:var(--fg1); } + .lockup .en{ font-family:var(--font-display); font-weight:600; font-size:10px; letter-spacing:0.22em; color:var(--accent); margin-top:4px; } + .lockup-panel{ flex:1; min-width:220px; display:flex; flex-direction:column; align-items:center; gap:var(--space-3); + padding:var(--space-8); border-radius:var(--radius-lg); border:1px solid var(--border); } + .lockup-panel .plab{ font-family:var(--font-mono); font-size:10px; color:var(--fg3); } + .spec-mono-grid{ display:grid; grid-template-columns:repeat(3,1fr); gap:var(--space-5); width:100%; } + .spec-mono-grid .k{ font-family:var(--font-mono); font-size:11px; color:var(--fg3); margin-bottom:4px; } + .spec-mono-grid .v{ font-family:var(--font-mono); font-size:22px; font-weight:500; letter-spacing:var(--tracking-snug); + color:var(--fg1); font-feature-settings:'tnum' 1; } + .spec-mono-grid .v .u{ font-size:12px; color:var(--accent); font-weight:400; } + @media (max-width:560px){ .spec-mono-grid{ grid-template-columns:repeat(2,1fr); } }
-
穿山甲 Pangolin
+ +
穿山甲穿山甲 Pangolin
设计系统登记簿 · Prototype
+
+

内容容器 · .view-body

+

桌面客户端内容视图统一宽度容器:默认居中 600px(账户/设置/联系我们/统计/订单/通知/邀请等单栏页),.is-wide 修饰 840px(购买套餐等多列网格页)。padding 固定 space-5 space-6 space-6。对齐 Flutter PageBody(client/lib/widgets/page_body.dart)。

+

默认 · 600px

.view-body
+
+
+
.view-body
+
单栏内容视图
+
max-width:600px · margin:0 auto 居中 · padding 5/6/6
+
+
+
+

宽 · 840px

.view-body.is-wide
+
+
+
.view-body.is-wide
+
多列网格页(购买套餐)
+
max-width:840px · padding 沿用默认 5/6/6
+
+
+
+
+

输入 · .input / .field / .flabel

强描边 + radius-md + focus clay 光环。状态 .is-focused / .is-error / :disabled

@@ -305,21 +489,331 @@

语言下拉 · .langsel

不依赖原生 <select>:触发药丸 .langsel-trigger + 自控绝对定位菜单 .menu / .menu-item(选中态 .is-selected)。点触发按钮可实际展开/收起。

-
+
- ↖ 默认展开演示,可点击触发按钮收起 / 展开
+ +
+

核心连接按钮 · .connect-btn

+

产品招牌组件:圆形一键连接键,三态 .is-off / .is-connecting / .is-on。背景在渐变↔纯色间切换仅过渡 box-shadow/background-color transition:all,否则背景计算失效)。轨道环用内联 <svg>,圆内为图标 + 标签 / 计时。镜像 client/lib/widgets/connect_button.dart

+
+ +
+ + 未连接 Off +
+ +
+ + 建立隧道 Connecting +
+ +
+ + 已连接 Connected +
+ +
+
+ + +
+

服务器列表行 · .server-row

+

节点列表一行:国家码块(2 字母,绝不用 emoji 国旗)+ 名称/子名 + 属性胶囊(.pill-outline)+ 延迟(--font-mono·按值变色)+ 信号条 + 选中/推荐/不可用态。镜像 client/lib/widgets/server_tile.dart。放进 .card.card-flush 列表容器。

+ +

列表行

.server-row · .is-selected · .is-down
+
+
+
HK
+
+
香港 · 加速线路
+
流媒体
+
+ 18ms + +
+
+
JP
+
+
日本 东京
+
Tokyo
+
+ 32ms + +
+
+
SG
+
+
新加坡
+
P2P
+
+ 54ms + +
+
+
US
+
+
美国 洛杉矶
+
Los Angeles
+
+ 不可用 +
+
+
+ +

智能选择推荐行

.server-row.is-smart · zap + 推荐胶囊
+
+
+
+
+
智能选择
+
根据当前网络环境,自动选择最优节点
+
+ 推荐 + +
+
+
+
+ + +
+

状态徽章 · .pill 场景

+

徽章复用胶囊原子 .pill(见「胶囊 .pill / .badge」),此处按业务场景分组:连接态(色点 + 文字,非 emoji · 铁律 #7)· 套餐 · 节点属性。镜像 client/lib/widgets/status_pill.dart

+ +

连接态

.pill.is-success/-warning/-danger/-neutral
+ 已连接 + 连接中 + 已断开 + 未登录 +
+ +

套餐

.pill-accent · .pill.is-neutral
+ PRO 会员 + 免费版 +
+ +

节点属性

.pill-outline · .pill.is-neutral(数据)
+ 流媒体优化 + P2P + 延迟 24ms +
+
+ + +
+

复合输入 · 开关 · 图标框 · Toast

+

对齐线上 pangolin_field.dart / dtoggle / pangolin_toast.dart.field-box 复合输入(前置图标 + 裸 input + 后置槽),.toggle 开关,.segswitch 二选一段控开关(支付渠道/币种),.icon-box 图标框,.status-dot 状态点,.toast 轻提示。

+
+ + + +
+
+ + + + + 在线 + 离线 + 已复制 +
+
+
+ + +
+
+ + +
+
+
+ +
+

额度卡 · 指标卡

+

对齐线上 quota_card.dart(免费版今日额度:倒计时收缩条 + 「免费版」短标签 + 三档阈值;无绿色「已解锁」态,线上以倒计时收缩条表达)与统计 .metric 指标瓦片。

+
+
+
今日剩余10 分钟免费版
+
+
+
+
剩余02:18免费版
+
+
+
+
今日已用完免费版
+
+
+
+
+
12.4G
本周流量
+
6.2h
在线时长
+
+
+ +
+

设备行 · 设置行

+

对齐线上 DevicesScreen / settings_page.dart.device-row(图标框 + 名 + 最后在线 + 本机/移除/⋯)与 .setting-row(图标框 + 标题 + 副标 + trailing:chevron / 开关 / 值)。

+

设备管理

.device-row
+
+
iPhone 15
当前设备 · 刚刚在线
本机
+
MacBook Pro
最后在线 2 小时前
+
+
+

设置

.setting-row
+
+
智能分流
海外应用走加速,国内直连
+
语言
简体中文
+
协议
REALITY
+
+
+
+ +
+

套餐横幅 · 侧栏会员卡

+

对齐线上 _PlanBanner(账户页)与 plan_badge_card.dart(桌面侧栏底部)。免费=surface 卡;.is-pro=clay 渐变。

+
+
+
免费版
注册享 7 天免费试用
+
+
+
PRO 会员
有效期至 2027-07-11
+
+
+

侧栏会员卡

.plan-badge
+
PRO 会员至 2027-07-11
+
+
+ +
+

更新条 · 底部 Tab · 侧栏项

+

app-chrome 组件。.update-banner(jiu 式更新顶部横条,对齐 UpdateBanner)、.tabbar/.tab(移动底部 4-Tab,对齐 bottom_tab_bar.dart)、.nav-item(桌面/iPad 侧栏项,对齐 nav_sidebar.dart)。

+
+
发现新版本 v1.1.0,建议更新
+
+
+
+ + + + +
+
+
+
+ + + + + +
+
+
+ + +
+

套餐卡 · .plan-card

+

购买套餐档位卡。常规档 surface 底;.is-featured 走 clay 渐变高亮(年付/最优惠),内含反色 CTA,顶部可挂 .plan-pop「最受欢迎」浮标。镜像 client/lib/widgets/plan_card.dart。金额仅展示,扣款以 pay 服务为准。

+
+
+
按月
+
¥29.99/ 月
+
+
不限时长 · 不限流量
+
80+ 全球加速线路
+
5 台设备同时在线
+
+ +
+ +
+
+ +
+

支付方式 · .pay-method

+

底部弹层选择支付渠道,一行 = leading 图标 + 标题 + trailing chevron。资金外流(拉起支付宝 / 展示加密地址),App 内无卡号录入表单(设计铁律 #10)。生产渠道:哪吒聚合(支付宝)+ 加密货币 USDT。

+
+
+ + +
+
+
+ +
+

明细行 · .kv-row

+

可复制键值行,用于加密支付态展示「网络 / 收款地址 / 精确金额」。值用等宽字(--font-mono + tabular nums),右侧复制按钮。

+
+
+
网络USDT · TRC20
+
收款地址TB1e7q1333iuLMLc2iQaoaqeuDF2U7cxan
+
精确金额4.990001
+
+
+
+ +
+

结果态 · .result

+

支付终态:居中大图标 + 标题 + 说明。成功用 --success,失败加 .is-fail--danger

+
+
+
+
支付成功
+
有效期至 2027-07-11
+
+
+
+
支付失败
+
订单已超时,请重试
+
+
+
+

Lucide 图标库

@@ -390,10 +884,52 @@ function renderIcons(){ /* ── 导航 scrollspy ── */ const navLinks=[...document.querySelectorAll('.dnav')]; -navLinks.forEach(a=>a.onclick=(e)=>{ e.preventDefault(); const el=document.getElementById(a.dataset.s); if(el) el.scrollIntoView({behavior:'smooth',block:'start'}); }); +navLinks.forEach(a=>a.onclick=(e)=>{ if(!a.dataset.s) return; /* 外链(如屏预览)正常跳转 */ e.preventDefault(); const el=document.getElementById(a.dataset.s); if(el) el.scrollIntoView({behavior:'smooth',block:'start'}); }); const obs=new IntersectionObserver(es=>{ es.forEach(en=>{ if(en.isIntersecting){ const id=en.target.id; navLinks.forEach(a=>a.classList.toggle('on',a.dataset.s===id)); } }); },{rootMargin:'-70px 0px -75% 0px'}); document.querySelectorAll('.section').forEach(s=>obs.observe(s)); +/* ── 侧栏:收起 + 拖拽改宽(持久化 localStorage) ── */ +(function(){ + const doc = document.querySelector('.doc'); + const nav = document.querySelector('.doc-nav'); + const handle = document.getElementById('navResize'); + const toggle = document.getElementById('navToggle'); + if(!doc || !nav || !handle || !toggle) return; + const MIN = 180, MAX = 460, DEF = 238; + let curW = parseInt(localStorage.getItem('pangolin-ds-navw') || '', 10); + if (!(curW >= MIN && curW <= MAX)) curW = DEF; + let collapsed = localStorage.getItem('pangolin-ds-navcollapsed') === '1'; + function apply(){ + if (collapsed) { doc.classList.add('nav-collapsed'); nav.style.width = '0px'; } + else { doc.classList.remove('nav-collapsed'); nav.style.width = curW + 'px'; } + } + apply(); + // 收起 / 展开 + toggle.onclick = () => { + collapsed = !collapsed; + localStorage.setItem('pangolin-ds-navcollapsed', collapsed ? '1' : '0'); + apply(); + }; + // 拖拽改宽(纯 JS 内联宽度,避免自定义 CSS 变量触发闸误报) + let dragging = false; + handle.addEventListener('mousedown', (e) => { + if (collapsed) return; + dragging = true; handle.classList.add('dragging'); document.body.classList.add('nav-dragging'); + e.preventDefault(); + }); + window.addEventListener('mousemove', (e) => { + if (!dragging) return; + const rect = nav.getBoundingClientRect(); + curW = Math.max(MIN, Math.min(MAX, e.clientX - rect.left)); + nav.style.width = curW + 'px'; + }); + window.addEventListener('mouseup', () => { + if (!dragging) return; + dragging = false; handle.classList.remove('dragging'); document.body.classList.remove('nav-dragging'); + localStorage.setItem('pangolin-ds-navw', curW); + }); +})(); + /* ── init(sprite 注入可能在 DOMContentLoaded,稍等确保 PANGOLIN_ICONS 就绪) ── */ function boot(){ applyTheme(localStorage.getItem('pangolin-ds-theme') || 'light'); diff --git a/design/prototype/screens/orders.html b/design/prototype/screens/orders.html new file mode 100644 index 0000000..687a939 --- /dev/null +++ b/design/prototype/screens/orders.html @@ -0,0 +1,347 @@ + + + + + +Pangolin · Orders (Interactive Prototype) + + + + + + +
+
PangolinPangolin 穿山甲
+ Orders · Design preview +
+ ← Registry + + +
+
+ +
+

+ Single phone frame walking the order flow: Orders list → tap a row → Order detail → back. Four lifecycle states (awaiting payment / paid · activating / completed / expired). Copyable order & trade numbers. Toggle language (EN / 中) and theme top-right. +

+ +
+
+ + Orders +
+ +
+ + +
+
+
+
+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+ + +
+ +
+
+
+ + + + diff --git a/design/prototype/screens/purchase.html b/design/prototype/screens/purchase.html new file mode 100644 index 0000000..15ac9de --- /dev/null +++ b/design/prototype/screens/purchase.html @@ -0,0 +1,403 @@ + + + + + +穿山甲 · 购买流程(交互原型) + + + + + + +
+
穿山甲穿山甲 Pangolin
+ 购买流程 · 交互原型 + ← 返回登记簿 + +
+ +
+

+ 单手机框走完整购买流程:购买(特性统一置顶 + 支付渠道 switch + 精简套餐卡)→ 创建订单(顶部订单信息按所选渠道币种显价)→ 成功 / 失败。 + 支付渠道即币种即方式:USDT → 加密货币(默认)/ 人民币 → 支付宝;套餐价随渠道切换(各币种一口价,不做汇率换算)。结算币种一经下单锁定,跨币种需重新下单(pay 侧 currency_mismatch)。资金外流全在 App 外,App 内无卡号录入(铁律 #10)。「▸ 模拟到账 / 失败」为评审用。切右上角看深色。 +

+ +
+
+ + 升级 PRO +
+ +
+ + +
+
+ + +
+

所有套餐均包含

+
+
+
不限时长 · 不限流量
+
80+ 全球加速线路
+
5 台设备同时在线
+
优先客服
+
+
+
+ + +
+

支付渠道

+
+ + +
+
+ + +
+
+
+
按月
+
+
+
+
+ +
+ +
+
+
+
按季
+
+
+
+
+ +
+ + + +
+
+ + +
+
+ + +
+
订单信息
+
套餐按年 · PRO
+
支付渠道加密货币 · USDT
+
应付金额$34.99
+
到期日2027-07-11
+
+ + +
+
网络USDT · TRC20
+
收款地址TB1e7q1333iuLMLc2iQaoaqeuDF2U7cxan
+
精确金额34.990001
+
请转账 34.990001 USDT(含小数末位),金额用于自动对账识别,多退少不到账。
+
+ +
+ +
将跳转至支付宝完成付款,付款后自动返回本页并开通。
+
+ +
等待到账确认…
+ + + + +
+
评审演示
+ + +
+
+
+ + +
+
+
+
+
支付成功
+
PRO 有效期至 2027-07-11
+
+ +
+
+ + +
+
+
+
+
支付失败
+
订单已超时,请重试
+
+ +
+
+ + +
+ +
+
+
+ + + + diff --git a/design/prototype/screens/site.html b/design/prototype/screens/site.html new file mode 100644 index 0000000..a17fe24 --- /dev/null +++ b/design/prototype/screens/site.html @@ -0,0 +1,802 @@ + + + + + +穿山甲 · 官网 Site 完整原型(可交互) + + + + + + + +
+
穿山甲穿山甲 Pangolin
+ 官网 Site · 可交互原型 + ← 返回登记簿 + +
+ +
+

+ 官网宣传页完整版式(顶栏 / Hero / 特性 / 定价 / 下载 / 页脚),装进 mock 浏览器窗口,已加交互: + 导航平滑滚动 + scrollspy 高亮、语言下拉(六语单显切换:简体中文 / EN / 日 / 韩 / 俄 / 西)、明暗主题、定价「月付 / 年付」段控切价。 + 仅引 ../tokens.css / ../atoms.css / ../icons.js,业务色全走 var(--token); + 套餐数字对齐 §7;文案脱敏(铁律 #13)——只讲「网络加速 / 极速畅连 / 隐私保护 / 无日志」。切右上角看深色。 +

+
此页自适应:窗口 < 720px 时导航折叠、各区块单列堆叠
+ +
+
+
+ pangolin.example +
+ +
+ +
+ + +
+ +
+ + +
+ 立即下载 +
+
+ + +
+
+
+
极速 · 稳定 · 省心
+

极速畅连, +网络如丝顺滑

+

轻盈、亲和、即开即用的跨平台网络加速应用。一键连接,智能选线,稳定不掉线。

+ +
+ 全球加速线路 + 严格无日志 + 端到端加密 +
+
+ +
+ +
+
下载
86.4 Mb/s
+
上传
12.1 Mb/s
+
+
+ HK +
香港 · 流媒体
延迟 18ms · 已连接
+ +
+
+
+ +
+ 已支持 + iOS + Android + macOS + Windows + Linux +
+
+ + +
+
+
+
产品
+

为日常而生的连接体验

+

没有复杂配置,没有技术门槛。打开就用,把稳定与隐私留给我们。

+
+
+
+
+

一键连接

+

一颗按钮,智能挑选最快线路。无需手动配置,也能秒连。

+
+
+
+

智能分流

+

按规则智能分流,该加速的加速,本地服务直连,互不打扰。

+
+
+
+

全球加速线路

+

覆盖多地优质线路,视频与游戏优化随心选。

+
+
+
+

严格无日志

+

我们不记录你的浏览数据。隐私是底线,不是卖点。

+
+
+
+

Kill Switch

+

一旦连接中断,立即阻断网络,杜绝真实 IP 泄露。

+
+
+
+

多端同步

+

一个账户,手机、平板、电脑同时在线,最多 5 台设备。

+
+
+
+
+ + +
+
+
+
定价
+

简单透明的价格

+

随时升级或取消。所有套餐均含核心加密与无日志承诺。

+
+
+ + +
+ +
+
+
+ +
+
免费版
+
适合轻度使用与尝鲜
+
¥0
+
+
每日 10 分钟时长
+
仅 1 个基础节点
+
使用前观看广告
+
核心加密 · 无日志
+
+ +
+ + + + + +
+
团队版
+
小团队集中管理
+
¥99/月 · 10 席位
+
+
专业版全部功能
+
10 个成员席位
+
集中计费与管理
+
优先客服
+
+ +
+
+ + +
+ +
+ 为什么网页上没有支付按钮? +

出于风控与隐私考虑,穿山甲不在网页或 App 内直接收款。你可以通过以下任一渠道获取激活码,在客户端内兑换即可开通。

+
+ 自助发卡商店 + USDT (TRC20) · 最隐私 + Telegram + LINE + 邮箱 +
+
+
+
+
+ + +
+
+
+
下载
+

全平台,随处可用

+

一个账户,所有设备同步。下载即用,无需配置。

+
+
+
+
+
iOS
iOS 16+
+ 敬请期待 +
+
+
+
Android
Android 9+
+ +
+
+
+
macOS
macOS 12+
+ +
+
+
+
Windows
Win 10/11
+ +
+
+
+
Linux
deb / rpm
+ 敬请期待 +
+
+
+
+ + +
+
+
+
+ +

极简、轻量、亲和的跨平台网络加速应用。守护你的每一次连接。

+
+
+

产品

+ 功能 + 定价 + 下载 +
+ +
+

联系与渠道

+ Telegram + LINE + 邮箱 + 服务时间 每日 9:00–24:00 (GMT+8) +
+
+
+ © 2026 穿山甲 · Pangolin + 京ICP备 0000000 号-0 + App 内不支持直接支付 · 资金流全走外部渠道 + + + + + + +
+
+
+
+
+
+ + + + diff --git a/design/prototype/screens/ui-desktop.html b/design/prototype/screens/ui-desktop.html new file mode 100644 index 0000000..44e686b --- /dev/null +++ b/design/prototype/screens/ui-desktop.html @@ -0,0 +1,1889 @@ + + + + + +穿山甲 · 桌面客户端交互原型 + + + + + + +
+
穿山甲穿山甲 Pangolin
+ 桌面客户端 · 交互原型 + ← 返回登记簿 +
+
主题 +
+
语言 + +
+
视角 +
+
更新条 +
+
挡板 +
+
+
+ +
+

+ 单窗口可交互桌面客户端(980×640)。左侧栏切视图、连接键状态机、设置开关真能开关、语言中/EN 单显切换、视角 免费版/PRO 切换。品牌区与底部会员卡均可进账户。顶栏演示控制段与设置页开关双向联动;「挡板」段可唤出登录后设备超限挡板(盖满整窗、可关闭)。登录另附独立静态帧。 +

+ +
+
+ + + + +
+
连接
○ 未连接
+
+ + +
+ + 发现新版本 v2.5.0,建议更新 + + +
+ + +
+
+ +
+
未连接
+
点击圆钮开始极速畅连
+
智能选择 · 香港 · 28ms
+
+ +
+
+ + +
+
+ +
+
+ +
+ 智能选择 + 根据当前网络环境,自动选择最优节点 +
+ 推荐 + +
+
+ HK +
+ 香港 · 优选 +
流媒体
+
+ 28 ms + +
+
+ JP +
+ 日本 · 东京 +
P2P
+
+ 42 ms + +
+
+ SG +
新加坡
+ 96 ms + +
+
+ US +
美国 · 洛杉矶
+ 168 ms + +
+
+ DE +
德国 · 法兰克福
+ 暂不可用 +
+
+
+
+ + +
+
+
+ 统计 + + + + +
+
+
+
本月
+
+
下行
38.2 GB
+
上行
4.4 GB
+
时长
86.4 h
+
+
+
+
本周
+
+
下行
9.1 GB
+
上行
1.2 GB
+
时长
21.5 h
+
+
+
+
今日
+
+
下行
1.4 GB
+
上行
0.2 GB
+
时长
2.1 h
+
+
+
+
+
最近两周流量GB
+
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
连接
+
+
+
开机自启
系统启动时自动运行
+ +
+
+
自动连接
启动时自动连接节点
+ +
+
+
智能分流
海外应用走加速,国内直连
+ +
+
+
断连保护 Kill Switch
断线时阻断网络,防止数据泄露
+ +
+
+ +
外观
+
+
+
语言
+ + + + + + +
+
+
深色外观
跟随浅色
+ +
+
+ +
关于
+
+
+
协议
+ REALITY / Hysteria2 +
+
+
检查更新
+ +
+
+
Version
+ v2.4.0 (macOS) +
+
+
网页版用户中心
+ +
+
+
+
+ + +
+
+

遇到问题?通过以下任一渠道联系我们,通常数分钟内回复。

+
+
+ +
Telegram
@pangolin_app
+ +
+
+ +
邮箱客服
support@yanmeiai.com
+ +
+
+ +
官网
pangolin.yanmeiai.com
+ +
+
+
+ +
+
服务时间
+
每日 9:00 – 24:00 (GMT+8)
+
+
+
+
+ + +
+
+ + + +
+
+
PRO 全部特性
+
+
不限时长 · 不限流量
+
80+ 全球加速线路
+
5 台设备同时在线
+
优先客服支持
+
+
+ + +
+
支付渠道
+
+ + +
+
+ +
+
+
按月
+
+
+ +
+
+
按季
+
+
+ +
+ +
+
+ + +
+
+
+
按年 · PRO¥199.99
+
加密货币 · USDT
+
有效期至 2027-07-11
+
+
+
网络USDT · TRC20
+
收款地址TB1e7q1333iuLMLc2iQaoaqeuDF2U7cxan
+
精确金额34.990001
+
请按精确金额(含小数末位)转账,金额用于自动对账识别,多退少不到账。
+
+
+
+
¥199.99
+
用支付宝扫码支付
+
付款后自动开通,无需手动确认。
+
+
等待到账确认…
+ + +
+
评审演示
+ + +
+
+
+ + +
+
+
+
+
支付成功
+
PRO 有效期至 2027-07-11
+
+ +
+
+ + +
+
+
+
+
支付失败
+
订单已超时,请重试
+
+ +
+
+
+
+ + +
+
+ + + +
+
+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+ +
通知
+
+ +
+ +
+
功能1d
+
新增日本 · 大阪加速线路
+
三条低延迟线路已上线,欢迎体验。
+
+
+
+ +
+
新闻3d
+
邀请好友活动进行中
+
邀请好友注册,双方都可获得会员天数。
+
+
+
+ +
+
奖励6h
+
邀请奖励 +3 天已到账
+
好友已完成注册,会员天数已计入你的账户。
+
+
+
+ +
+
版本2d
+
v1.0.73 已发布
+
修复已知问题,建议尽快更新。
+
+
+
+
+ +
+
优惠4d
+
¥6 月付优惠上线
+
限时活动,PRO 月付低至 ¥6,先到先得。
+
+
+
+
+
+ + +
+
+ +
邀请好友
+
+
分享你的邀请码或链接,好友注册后双方都得会员天数。
+
+ PANGO-7F3A + +
+ +
+ +
奖励说明 · 双方都得
+
+
+ +
好友注册
双方各得 3 天
+ +3d +
+
+ +
好友首次购买
双方各得 7 天
+ +7d +
+
+ +
加入 TG 频道
获得 3 天
+ +3d +
+
+ +
我的奖励
+
+
+
3已邀请
+
1已转化
+
16累计获赠(天)
+
+
+ +
更多得会员
+
+
+ +
+
加入 Telegram 频道
+
+3 天
+
+
+
+ + +
+
演示态 · 待验证
+
+
+
+ +
+
加入 Telegram 频道
+
+3 天
+
+
+
+ +
+
演示态 · 已领取
+
+
+
+ + +
+
+ +
兑换码
+
+
+ + +
+ +
输入卡密即可激活对应时长,激活后立即生效。
+
+
+
+ + +
+ +
+
+ + +
+
+ + +
设备数已达上限
+
你的套餐最多 5 台设备,请移除一台以继续。
+ +
+
+ +
iPhone 15
最后在线 2 小时前
+
+
+
+ +
MacBook Pro
本机
+
+
+
+ +
iPad Air
最后在线 昨天
+
+
+
+ +
+
+
+
+ + +
+
登录分屏卡 · 品牌左 / 表单右(独立静态屏,不并入交互窗口)
+ +
+ + +
+ + + + diff --git a/design/prototype/screens/ui-mobile.html b/design/prototype/screens/ui-mobile.html new file mode 100644 index 0000000..a561ca3 --- /dev/null +++ b/design/prototype/screens/ui-mobile.html @@ -0,0 +1,2085 @@ + + + + + +穿山甲 · 移动 App 交互原型 + + + + + + +
+
穿山甲穿山甲 Pangolin
+ 移动 App · 交互原型 + ← 返回登记簿 +
+
+ 主题 +
+ + +
+
+
+ 语言 +
+ + +
+
+
+ 视角 +
+ + +
+
+
+ 更新 +
+ + +
+
+
+ 挡板 +
+
+
+
+ +
+

+ 单机可交互原型:底部四 Tab、连接键三态状态机(未连接 → 连接中 → 已连接)、 + 节点选择联动、套餐视角切换。全部复用 ../atoms.css 原子,vanilla JS 无依赖。用顶栏右侧演示控制段切主题 / 语言 / 视角 / 更新提示,「挡板」可唤出登录后设备超限挡板(盖满 App 窗口、可关闭)。 +

+ +
+ +
+
交互主框架 · 四 Tab 状态机
+
+ +
+ 9:41 +
+ + + +
+
+ + +
+ 连接 +
+
+ + +
+ + 发现新版本 v1.1.0 + + +
+ +
+ +
+
+ +
未连接
+
+ +
+
+ + +
+
+
+
+ +
+
+
+ +
+
智能选择
+
根据当前网络环境,自动选择最优节点
+
+ 推荐 + +
+
+
+
+ HK +
香港 01
+
流媒体
+ 28 ms + + +
+
+ JP +
日本 · 东京 02
低延迟
+ 42 ms + + +
+
+ SG +
新加坡 01
高速稳定
+ 96 ms + + +
+
+ US +
美国 · 洛杉矶 03
远距离
+ 168 ms + + +
+
+ TW +
台湾 01
维护中
+ 不可用 +
+
+
+ + +
+
+ 设备 +
+ + +
+
+
+
+
近两周流量GB
+
+
+
+ + +
+ +
+
+ +
+
me@pangolin.app
+
免费版
+
+ +
+
+ + + + +
购买 · 订单 · 兑换
+
+
+ +
购买套餐
+
+
+
+ +
我的订单
+
+
+
+ +
兑换码
+
+
+
+ + +
推广
+
+
+ +
邀请好友得会员
双方各得订阅天数
+
+
+
+ + +
账户信息
+
+
+ +
邮箱
me@pangolin.app
+
修改
+
+
+ +
密码
••••••••••
+
修改
+
+
+ + +
设备
+
+
+ +
我的设备
+
1 / 1
+
+
+ + +
通用
+
+
+ +
设置
+
+
+
+ +
联系我们
+
+
+
+ +
+
+ +
退出登录
+
+
+
+
+ + + + + +
+ + + + +
+ + +
+
+ + Upgrade to PRO +
+
+ + +
+
+
+
All plans include
+
+
Unlimited time and data
+
80+ global nodes
+
5 devices at once
+
Priority support
+
+
+
+
Payment channel
+
+ + +
+
+
+
Monthly
+
+ +
+
+
Quarterly
+
+ +
+ +
+
+ + +
+
+
+
Plan
+
Payment channel
+
Amount
+
Valid until
+
+
+
NetworkUSDT · TRC20
+
AddressTB1e7q1333iuLMLc2iQaoaqeuDF2U7cxan
+
Exact amount4.990001
+
+
+
+ +
You will be redirected to Alipay.
+
+ +
Waiting for confirmation...
+ + + + +
+
Demo
+ + +
+
+
+ + +
+
+
+
+
Payment successful
+
+
+ +
+
+ + +
+
+
+
+
Payment failed
+
Order timed out, please retry
+
+ +
+
+ + + +
+ +
+
+ + +
+
+ + Orders +
+
+ + +
+
+
+
+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+ +
+
+ +
+
+ + +
+
+ + +
+
+
连接
+
+
+ +
开机自启
+
+
+
+ +
自动连接
+
+
+
+ +
智能分流
+
+
+
+ +
断网保护
+
+
+
+ +
外观
+
+
+ +
语言
+
+
+ + +
+
+
+
+ +
深色外观
跟随浅色
+
+
+
+ +
关于
+
+
+ +
协议
+
REALITY / Hysteria2
+
+
+ +
检查更新
v1.1.0
+
+
+
+ +
网页用户中心
+
+
+
+
+
+ + +
+
+
+
+ +
Telegram
@Pangolin_Support
+ +
+
+ +
邮箱
support@pangolin.app
+ +
+
+ +
官网
pangolin.app
+ +
+
+ +
服务时间
每日 9:00–24:00 (GMT+8)
+
+
+
+
+ + +
+
+
+ +
+ +
+
功能1d
+
新增日本 · 大阪加速线路
+
三条低延迟线路已上线,欢迎体验。
+
+
+
+ +
+
新闻3d
+
邀请好友活动进行中
+
邀请好友注册,双方都可获得会员天数。
+
+
+
+ +
+
奖励6h
+
邀请奖励 +3 天已到账
+
好友已完成注册,会员天数已计入你的账户。
+
+
+
+ +
+
版本2d
+
v1.0.73 已发布
+
修复已知问题,建议尽快更新。
+
+
+
+
+ +
+
优惠4d
+
¥6 月付优惠上线
+
限时活动,PRO 月付低至 ¥6,先到先得。
+
+
+
+

仅显示最近通知(演示占位)

+
+
+ + +
+
+
+ +
邀请好友得会员
+
好友注册、首购、加入社区,双方都各得订阅天数。
+
+
+
+ +
邀请码
PANGO-8F3A2K
+ +
+
+ +
邀请链接
pangolin.app/i/8F3A2K
+ +
+
+
+
奖励规则
+
+
好友注册 · 双方各得 3 天
+
好友首次购买 · 双方各得 7 天
+
加入 TG 频道 · 获得 3 天
+
+
+
+
我的奖励
+
+
3已邀请
+
1已转化
+
16累计获赠(天)
+
+
+
更多得会员
+
+
+ +
+
加入 Telegram 频道
+
+3 天
+
+
+
+ + +
+
演示态 · 待验证
+
+
+
+ +
+
加入 Telegram 频道
+
+3 天
+
+
+
+ +
+
演示态 · 已领取
+
+ +
+
+ + + +
+
+
+
我的设备 · 1 / 1 台
+
+
+ +
+
iPhone 15本机
+
当前在线
+
+
+
+
+
+ +
+
+ +
+
+
+ +
+
+
+ +

输入卡密即可激活对应时长,激活后立即生效。

+
+
+ + +
+ +
+
+ + +
+
+
+ +
设备数已达上限
+
你的账户最多可同时登录 5 台设备,请移除一台后继续。
+
+ +
+
+ +
+
iPhone 15本机
+
在线
+
+
+
+
+ +
+
MacBook Pro
+
最后在线 2 小时前
+
+
+
+
+ +
+
iPad Air
+
最后在线 昨天
+
+
+
+
+ +
+
+
+
+ + +
+
静态登录 · 注册
+
+
+ 9:41 +
+ + + +
+
+
登录
+
+
+ 穿山甲 +
穿山甲
+
Pangolin
+
极速 · 稳定 · 省心
+
+
+
+ + +
+ +
+
+
+ +
+
+
+
+ +
+
+
+
忘记密码?
+ +
+
+ + +
继续即代表同意《服务条款》与《隐私政策》
+
+
+
+
+ +
+
+ + + + diff --git a/design/prototype/tools/check-l1-sync.mjs b/design/prototype/tools/check-l1-sync.mjs new file mode 100644 index 0000000..086a85d --- /dev/null +++ b/design/prototype/tools/check-l1-sync.mjs @@ -0,0 +1,62 @@ +#!/usr/bin/env node +// check-l1-sync.mjs —— L1 跨端同源闸(ds-flow)。零依赖。 +// ① 图标集合:Flutter 图标集 == 原型 sprite 图标集(camel 化后逐名相等)。 +// 真源 design/prototype/icons.js;镜像 client/lib/widgets/pangolin_icons.dart。 +// ② l10n 完整性:app_text.dart 声明的每个 getter,6 个语言文件必须全部实现 +// (谁少 key 就红 —— 防「某语言漏字串」漂移,如支付字串只加 zh/en)。 +// 任一不通过 → 列出并 exit 1。 +import { readFileSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, '..', '..', '..'); +let failed = 0; + +/* ① 图标集合 */ +const spriteKeys = new Set( + [...readFileSync(resolve(root, 'design/prototype/icons.js'), 'utf8') + .matchAll(/'([a-z0-9-]+)'\s*:\s*' m[1].replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase())) +); +const dartKeys = new Set( + [...readFileSync(resolve(root, 'client/lib/widgets/pangolin_icons.dart'), 'utf8') + .matchAll(/LucideIcons\.([A-Za-z0-9]+)/g)].map((m) => m[1]) +); +const iconMissing = [...spriteKeys].filter((k) => !dartKeys.has(k)).sort(); +const iconExtra = [...dartKeys].filter((k) => !spriteKeys.has(k)).sort(); +if (iconMissing.length === 0 && iconExtra.length === 0) { + console.log(`✓ 图标同集 OK —— ${spriteKeys.size} 个 sprite ↔ Flutter 逐名相等`); +} else { + failed++; + console.error('✗ 图标同集 MISMATCH:'); + if (iconMissing.length) console.error(' sprite 有 / Flutter 缺:', iconMissing.join(', ')); + if (iconExtra.length) console.error(' Flutter 多 / sprite 无:', iconExtra.join(', ')); + console.error(' 修复:node design/codegen/gen_flutter_icons.mjs'); +} + +/* ② Flutter l10n 完整性 */ +const L10N = resolve(root, 'client/lib/l10n'); +const BASE = resolve(L10N, 'app_text.dart'); +const LANGS = ['zh', 'en', 'ja', 'ko', 'ru', 'es']; +const getterSet = (src) => new Set([...src.matchAll(/\bString\s+get\s+(\w+)\b/g)].map((m) => m[1])); +if (!existsSync(BASE)) { + console.log('· l10n 完整性:跳过(无 app_text.dart)'); +} else { + // canonical = 仅抽象 getter(`String get X;` 分号结尾);块体默认 `String get X {…}` 是继承默认,不要求 langs 实现 + const canonical = new Set([...readFileSync(BASE, 'utf8').matchAll(/\bString\s+get\s+(\w+)\s*;/g)].map((m) => m[1])); + const gaps = []; + for (const lang of LANGS) { + const f = resolve(L10N, `strings_${lang}.dart`); + if (!existsSync(f)) { gaps.push(` ${lang}: 文件缺失`); continue; } + const miss = [...canonical].filter((k) => !getterSet(readFileSync(f, 'utf8')).has(k)).sort(); + if (miss.length) gaps.push(` ${lang}: 缺 ${miss.length} 个 → ${miss.slice(0, 12).join(', ')}${miss.length > 12 ? ' …' : ''}`); + } + if (gaps.length === 0) { + console.log(`✓ l10n 完整 OK —— ${canonical.size} 个 getter × ${LANGS.length} 语言全实现`); + } else { + failed++; + console.error('✗ l10n 不完整(某语言漏 key):'); + gaps.forEach((g) => console.error(g)); + } +} +process.exit(failed ? 1 : 0); diff --git a/docs/account-ia-reorg-design.html b/docs/account-ia-reorg-design.html new file mode 100644 index 0000000..fd981eb --- /dev/null +++ b/docs/account-ia-reorg-design.html @@ -0,0 +1,164 @@ + + + + + +账户 / 设置 / 联系 信息架构重构 — 设计 + + + +
+← 文档索引 +

账户 / 设置 / 联系 信息架构重构

+

设计文档 · 2026-07-12 · Spec ① / 3(② 推广激励 · ③ 系统通知 另立)· 状态 待评审

+ +
+问题:账户相关内容散在 App 的 Account / Settings / Contact / Redeem 四处 + Web 用户中心,大量重复(联系渠道 3 处、语言/主题 2 处、购买入口 3 处、Web 全套账户功能与 App 重叠)。用户找不到订单、点了没反应,根子是入口混乱。
+决策(已定):App 自足、Web 退居次要 —— 所有账户功能在 App 原生做完,每个功能只有一个家;Web 仅在 Settings 底部留一个低调入口。推广奖励双方都得,兑换码保留,Web 链接保留。 +
+ +

1 · 原则

+
    +
  • 一功能一个家 —— 任何条目只在一处出现,消灭重复。
  • +
  • 页面内容跨端共用一套,只有导航壳不同 —— 桌面走侧栏,移动把「设置/联系」收进 Account 下钻。
  • +
  • Account 是账户中心,提为一级导航(不再藏在左上品牌按钮里 —— 之前找不到订单即因此)。
  • +
  • 为后两个功能预留落点:顶栏「通知」铃铛、账户页「邀请好友」入口。
  • +
+ +

2 · 去重台账

+ + + + + + + +
重复项现在(乱)收敛到(唯一家)
联系渠道(TG/邮箱/官网)ContactPage + ContactScreen + Redeem「去哪买」×3Contact 页;Redeem 删「去哪买」
语言 / 主题Account 卡 + Settings ×2Settings·外观;Account 删
购买入口Account + Settings + Redeem 散 3 处Account·购买&订单块 + 会员横幅按钮;Settings 删
Web 用户中心Overview/订阅/设备/设置/兑换/邀请 全套,和 App 重叠弱化:Settings·关于 底部一个低调「网页版」链接
协议 / 版本Account 卡 + SettingsSettings·关于
+ +

3 · 目标结构

+
+

桌面版(侧栏)

+
侧栏 6 项 +├ Connect +├ Servers +├ Stats +├ Account ← 提为一级 +├ Settings +└ Contact +顶栏 ······· 🔔 通知 (红点) + +Account(账户中心) +├ 会员横幅 Renew/Upgrade→购买页 +├ 购买 & 订单 & 兑换 +│ ├ 购买套餐 → PurchaseScreen +│ ├ 我的订单 → OrdersScreen +│ └ 兑换码 → RedeemScreen(仅码) +├ 邀请好友 → InviteScreen(得会员) +├ 账户信息 邮箱 / 密码 +└ 我的设备 → DevicesScreen + +Settings(应用设置) +├ 连接 自启/自动连/智能分流/KillSwitch +├ 外观 语言 / 主题 +└ 关于 协议 / 版本 / 检查更新 + · 低调「网页版用户中心」 + +Contact +└ Telegram / 邮箱 / 官网 + 服务时间
+
+

移动版(底 Tab)

+
底 Tab 4 项 +├ Connect +├ Servers +├ Stats +└ Account +顶栏 ······· 🔔 通知 (红点) + +Account tab +├ 会员横幅 Renew/Upgrade→购买 +├ 购买 & 订单 & 兑换 +├ 邀请好友 +├ 账户信息 +├ 我的设备 +├ 设置 → 全屏下钻 +└ 联系我们 → 全屏下钻 + +设置 / 联系 / 购买 / 订单 / +设备 / 邀请 均从 Account +全屏 push;语言/主题在 +「设置」子页内。
+

规则:移动端无侧栏,故 Account 内多「设置/联系」两个入口;桌面端不显(侧栏已有)。判据 context.formFactor

+
+
+ +

4 · 新功能预留位(细节见 ②③)

+

🔔 通知(Spec ③)

+

顶栏铃铛 + 未读红点 → 通知收件箱(列表,已读态)。重要通知走顶部横幅(复用现有更新横幅)。后端已有 GET /v1/notices 底子;类型 新闻/功能/重要。参考 jiu。

+

邀请好友(Spec ②)

+

Account 一个「邀请好友得会员」入口 → 邀请页(邀请码/链接 + 奖励进度)。双方都得:邀请人 & 被邀人各得订阅天数。行为奖励:注册 / 首购 / 加入 TG channel 等。克制的偶发提示(账户页或特定动作后的低调 banner,不打扰)。

+ +

5 · 落地改动(Flutter)

+ + + + + + + + +
文件改动
navigation_provider.dartNavViewaccount 一级 + invite 子页;桌面侧栏列表加 Account
desktop_shell.dart侧栏加 Account 项;顶栏加通知铃铛;content() 加 invite/orders 等分支
account_page.dart按目标顺序重排卡片;加邀请入口;删语言/主题/协议卡(移 Settings);移动端才显 设置/联系
settings_page.dart删购买/订单卡;归拢 连接/外观/关于 三组;Web 链接降到关于底部
account_screens.dartRedeemScreen 删「去哪买」渠道卡(仅留兑换码);ContactScreen 保持渠道唯一
content_top_bar.dart / 顶栏加通知铃铛槽(Spec ③ 接线)
+ +

6 · 决策日志

+
    +
  • App×Web 分工:App 自足,Web 退居次要
  • +
  • 推广奖励:双方都得(拉新更猛)。
  • +
  • 兑换码:保留(账户·购买块内低调入口)。
  • +
  • Web 用户中心链接:保留(Settings·关于 底部低调)。
  • +
  • 拆解顺序:①IA(本文)→ ②推广激励 → ③系统通知,各自 spec→plan→实现。
  • +
+ +

7 · 不在本 spec

+

推广激励的奖励规则引擎/归因/防刷/邀请页细节(Spec ②);通知的类型/下发/定向/收件箱交互(Spec ③);Web 用户中心自身的精简(单独排期)。本文只定 App 的页面骨架与入口归位,并为 ②③ 预留落点。

+ +
+ + diff --git a/docs/index.html b/docs/index.html index d310d48..e766307 100644 --- a/docs/index.html +++ b/docs/index.html @@ -44,6 +44,21 @@

设计方案 / Specs

+ +
系统通知机制(Spec ③)HTML
+
App 内统一通知收件箱(铃铛+列表),六类:重要/新特性/新闻/到账(个人定向)/版本/活动。单 notices 表 + user_id(NULL=广播);已读用服务端 last_read_at 水位多端同步;GET /v1/notices(合并广播+定向,unread_count) + POST /read。生产三路:nodectl notice 子命令(手工) / 事件钩子同事务(购买开通·邀请·首充·TG 到账,零孤儿通知) / 发版脚本联动插 version 公告。邮件仅 important+显式 --email(SMTP 直发+email_sent_at 幂等)。不做:APNs/FCM(二期)/逐条已读/偏好开关/六语内容。原型先行:两端通知视图先统一(桌面pill vs 移动icon)再动代码。
+
docs/notifications-design.html
+
+ +
邀请奖励 + 奖励任务(加入 TG 频道)(Spec ②)HTML
+
用「送 Pro 会员天数」驱动增长,两条并行途径共用发天数原语(applySubscription source=invite/task)。① 邀请两段式双方都得:注册各 +3、被邀请人首充双方再各 +7(每转化 +10/+10)。② 奖励任务首个「加入 Telegram 频道」+3,经 Bot getChatMember 真校验(token 绑号→/tg/webhook→查成员→发)。防刷:自邀请拦截 + invitee 唯一 + dp_uuid 设备去重 + 注册段月度封顶(首充不封)+ telegram_id 全局唯一领。数据:users +invite_code/first_paid_at、referrals、通用 reward_claims。不做现金/多级/退群回收;通知集成留接缝待 Spec③。
+
docs/invite-task-rewards-design.html
+
+ +
账户 / 设置 / 联系 信息架构重构(Spec ①/3)HTML
+
去重 + 归位:App 自足、Web 退居次要;每功能一个家。桌面提 Account 为一级侧栏项,移动 Account tab 含设置/联系下钻。收敛联系渠道(×3→1)/语言主题(×2→1)/购买入口(散3→Account)。为「🔔 通知」(顶栏铃铛)+「邀请好友」(账户入口)预留落点。决策:奖励双方都得、兑换码保留、Web 链接保留。后续 ②推广激励 ③系统通知 另立 spec。
+
docs/account-ia-reorg-design.html
+
CI/CD 全流程(tag 触发编译/发版/部署)HTML
#30。参考 jiu 的 scripts/ci + .gitea/workflows:tag 触发(site-v*/server-v*/client-v*)→ 编译 → 测试 → Gitea release → 部署。runner 混合(nas=官网+服务端容器化 / mac=Android+macOS / windows=Windows)。服务端部署固化 F3/F4「备份→migrate→换二进制→重启→健康检查+回滚」;官网部署 pangolin.yanmeiai.com;客户端 apk/dmg/exe 挂 release 喂官网下载链接。密钥作用域:Apple/token 账户级、部署 key/Android keystore 仓库级。范围 A~F(排除 iOS/#26/#25)。
@@ -86,6 +101,16 @@

实现计划 / Plans

+ +
系统通知机制 实现计划(Spec ③)HTML
+
阅读版;执行真相源 docs/superpowers/plans/2026-07-13-notifications.md(11 任务 TDD,含 checkbox)。后端(1-7):迁移 000026(notices 表+users.notices_read_at) → notices.Store(广播∪定向合并/已读水位/事务内插) → GET /v1/notices+POST /read(替换空壳)+openapi 扩展 → 四事件钩子同事务(邀请注册/首充/TG任务/购买开通,零孤儿) → notices.Service(发布/撤回/important 邮件兜底幂等) → nodectl notice add/list/revoke → main.go 装配+发版脚本联动插 version 公告。原型与客户端(8-10):原型先行统一两端通知视图(桌面式pill+未读点)+reward/version/promo 示例 → notices api/provider(未读计数/延迟首拉) → 通知页真实化+铃铛红点接 provider+三新类型六语标签。Subagent 驱动执行。设计见 notifications-design.html。
+
docs/notifications-plan.html · 真相源 docs/superpowers/plans/2026-07-13-notifications.md
+
+ +
邀请奖励 + 奖励任务 实现计划(Spec ②)HTML
+
阅读版;执行真相源 docs/superpowers/plans/2026-07-12-invite-task-rewards.md(14 任务 TDD,含 checkbox)。后端(1-10):迁移 000024(referrals/reward_claims + source 扩容) → codes.GrantRewardTx 发奖原语 → reward.Store/Service(邀请码+防刷) → 接注册 ReferralHook → 接首充 pay webhook Rewarder → GET /v1/invite → TG 绑定 token → TG webhook getChatMember 真校验 → main.go 装配。客户端(11-13):invite api/provider → invite_page 真实化+六语 l10n → 注册页邀请码输入(deep-link 预填单列可选后置)。设计见 invite-task-rewards-design.html。
+
docs/invite-task-rewards-plan.html · 真相源 docs/superpowers/plans/2026-07-12-invite-task-rewards.md
+
前端设计系统治理重构(ds-flow 全端)HTML
阅读版;执行真相源 docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md(含 checkbox)。用 ds-flow 把 Flutter 五端 + 官网 + 用户中心收口到「设计单源·代码镜像·静态闸拦漂移·golden/fidelity 双级像素验收兜底」。非从零 bootstrap(已约 65% 达标):补原型三件套(atoms.css/icons.js/index.html 登记页)+ Web 共享原子层去重(各自实现+同源闸)+ 硬编码色/fidelity 闸 + 启用 pre-commit。6 阶段:CLAUDE.md → 原型单源 → Web token 同源 → Web 原子对齐 → Flutter golden 补齐 → 闸挂满。主题保持 light/dark。
@@ -128,6 +153,11 @@

知识库 / 调研

+ +
iOS 绕过 App Store 分发 VPN · 渠道调研与落地建议 HTML
+
场景锁定「个人账号 + 含 NetworkExtension 的 VPN + 大陆散户」。官方七渠道评级(TF ⭐⭐⭐⭐ 唯一现实可行 / Ad Hoc·企业·ABM·EU DMA·PWA·sideload 各为何不行)+ 国内灰产梯度(超级签 16–20 元/台最稳 / 企业签共享掉签严重 / 独立稳定签 / MDM 签 / TF 代上架)+ 稳定性成本对比表 + 合规封号风险 + 给 Pangolin 的落地建议(TF 打门面 + 超级签兜底 + 落地页重装引导,主账号务必隔离)。附中英来源 URL。2026-07 联网调研。
+
docs/ios-distribution-outside-appstore.html
+
Pangolin × pay v2 接入 · 终验交付说明 HTML
Task 8 终验:server/client 全量测试矩阵结果(含新增 SQLite 文件库带数据升级彩排 + MySQL 8 容器验证 000021 MODIFY ENUM)、OpenAPI 新端点登记、Self-Review 取舍、联调 checklist、部署附录(pay 种子/biz 配置/pangolin env/迁移顺序)。附带发现一处既有的 wangjia/codes 本地路径依赖会阻断异机构建,登记为部署前置阻断项。
diff --git a/docs/invite-task-rewards-design.html b/docs/invite-task-rewards-design.html new file mode 100644 index 0000000..4efa02a --- /dev/null +++ b/docs/invite-task-rewards-design.html @@ -0,0 +1,247 @@ + + + + + +邀请奖励 + 奖励任务(加入 TG 频道) — 设计 + + + +
+← 文档索引 +

邀请奖励 + 奖励任务

+

设计文档 · 2026-07-12 · Spec ②(推广激励)· 状态 待评审

+ +
+目标:用「送 Pro 会员天数」驱动增长。两条并行的得分途径,共用同一条发天数链路:
+① 邀请好友 —— 两段式,双方都得(注册各 +3 天,被邀请人首充双方再各 +7 天)。
+② 奖励任务 —— 首个任务「加入 Telegram 频道」+3 天,经 Bot getChatMember 真校验。框架可扩展(后续「关注推特 / App 评分」复用)。
+发天数原语:复用现有 subscriptions 延时逻辑(applySubscription(..., source) = max(到期,now)+days),与付费 / 兑换码同一条,不造新轮子。 +
+ +

1 · 邀请奖励模型(两段式,双方都得)

+ + + + +
阶段触发邀请人被邀请人封顶
注册被邀请人用邀请码注册成功+3 天 Pro+3 天 Pro邀请人 N 次/月(默认 10)
首充被邀请人首次成功付费(任何档,含 ¥6 优惠)+7 天 Pro+7 天 Pro不封顶
+

每转化一个付费用户:邀请人累计 +10 天、被邀请人累计 +10 天。注册段是唯一「免费面」故设月度封顶;首充段真金白银自带成本,不封顶。

+ +

邀请码 / 链接 / 绑定

+
    +
  • 邀请码:每用户一枚永久短码,从 users.uuid 派生 8 位 base32(去除易混字符 0/O/1/I),存 users.invite_code
  • +
  • 邀请链接:https://pangolin.yanmeiai.com/i/<code> → 落地页引导下载 App;App 内 deep link 自动预填邀请码。
  • +
  • 绑定时机:仅注册时(手动填码 或 点邀请链接注册,两条路都行),first-touch 永久不可改;注册后不可补绑。
  • +
+ +

2 · 防刷

+
    +
  • 自邀请拦截 —— 不能绑自己的码(inviter_id ≠ invitee_id)。
  • +
  • 新账户唯一绑定 —— 一个被邀请人一生只出现一次(referrals.invitee_id 唯一);老账户不可事后补绑。
  • +
  • 设备去重(复用 dp_uuid) —— 注册携带的 DeviceMeta.dp_uuid已注册过别的账户,则关系照记但注册奖励不发(status=rejected)。
  • +
  • 注册段月度封顶 —— 每邀请人每自然月最多 INVITE_REG_MONTHLY_CAP(默认 10)次注册奖励;超出仍记关系、不发注册奖。首充奖励不受此限。
  • +
  • TG 任务 —— 每账户一次 + telegram_id 全局唯一领取(挡「一个 TG 号刷多账户」)+ 真成员校验(见 §5)。
  • +
+ +

3 · 数据模型

+

users 加一列(SQLite / MySQL 两套迁移):

+ + + + +
类型说明
invite_codevarchar(16) UNIQUE本用户的永久邀请码;注册/首次访问邀请页时惰性生成。
first_paid_atdatetime NULL首次成功付费时刻;判「首充」+ 幂等首充奖励。
+ +

referrals —— 邀请关系(关系型,一对一绑定):

+ + + + + + + + + + +
类型说明
idPK
inviter_idbigint邀请人 user id(索引)。
invitee_idbigint UNIQUE被邀请人 user id;唯一 = 一人只被绑一次。
device_uuidvarchar被邀请人注册设备 dp_uuid(设备去重取证)。
statusvarcharboundreg_rewardedpaid_rewarded,或 rejected(防刷命中)。
reg_rewarded_atdatetime NULL注册段奖励发放时刻(幂等)。
paid_rewarded_atdatetime NULL首充段奖励发放时刻(幂等)。
created_atdatetime
+ +

reward_claims —— 通用一次性任务领取(TG 及后续任务复用):

+ + + + + + + + + +
类型说明
idPK
user_idbigint领取人。
task_keyvarchar任务标识,如 telegram_join
external_refvarchar外部去重键;TG 用 telegram_id
granted_daysint本次发放天数。
granted_atdatetime
约束:UNIQUE(user_id, task_key)(每账户每任务一次)+ UNIQUE(task_key, external_ref)(同一 telegram_id 只领一次)。
+

奖励发放本身落现有 subscriptions(source='invite' / 'task')+ sub_events 审计,不新增奖励台账表。

+ +

4 · 后端 API + 钩子

+ + + + + + + +
端点 / 钩子动作
Register(..., inviteCode)注册流程加 inviteCode 参:建号成功后解析码 → 邀请人 → 建 referrals(bound) → 过防刷闸(自邀请 / 设备去重 / 月度封顶)→ 发注册段双方 +3(reg_rewarded)。
pay webhook.settle()首充钩子:在 GrantPaidSubscriptionTx 之后,若 users.first_paid_at 本次由空转非空(=首充)且该用户是被邀请人(referrals 命中)→ 发首充段双方 +7(paid_rewarded,幂等)。
GET /v1/invite我的邀请码 / 链接 + 战绩:已邀请数、已转化(首充)数、累计获赠天数、明细列表;附奖励任务清单(TG 完成态)。
GET /v1/tasks/telegram/start签发绑定 token(10 分钟有效、绑当前账户),返回 bot 深链 t.me/<reward_bot>?start=<token>
POST /v1/tg/webhookTelegram Bot 更新回调(secret 校验,与 pay webhook 同款外部回调):收 /start <token> → 校验成员 → 发 TG 任务 +3。详见 §5。
+ +

5 · TG 任务验证流程(Bot getChatMember)

+
+ + + + + + + + + + App(客户端) + + pangolin-server + + Telegram(Bot/API) + + + + + + + + + ① GET /v1/tasks/telegram/start + + ② 返回 t.me/<bot>?start=<token> + + ③ 拉起 bot,用户按 Start(/start token) + + ④ POST /v1/tg/webhook(update:token + telegram_id) + + ⑤ getChatMember(频道, telegram_id) + + ⑥ status = member / administrator + + + ⑦ 发 +3 天(reward_claims) + + ⑧ sendMessage「✅ 已到账 +3 天」 + + ⑨ App 回前台 GET /v1/invite → 任务已完成 + + + 非成员 → bot 回「请先加入频道再验证」,不发 + +
+
    +
  1. token 短时有效(10 分钟)、一次性,绑当前账户 —— 防止链接被转发后他人领取。
  2. +
  3. webhook 安全:设置 Telegram secret_token,服务端校验 X-Telegram-Bot-Api-Secret-Token 头;路径也可带 secret 段。控制面经 Cloudflare Tunnel 已是公网 HTTPS,Telegram 可达,无需开入站端口
  4. +
  5. 成员判定:getChatMember 返回 member / administrator / creator 视为已加入;left / kicked / 查询失败视为未加入。
  6. +
  7. 不追溯退群:一次性发放,发后不因退群回收(回收体验差、收益低)。
  8. +
+ +

6 · 新基建(上线前置,需在 Telegram 侧配一次)

+
    +
  • 建一个面向用户的 Bot(与运维告警 bot 分开),token 存 TG_REWARD_BOT_TOKEN(Bitwarden,不入 git)。
  • +
  • 把该 Bot 设为频道管理员 —— 否则 getChatMember 查不到成员。
  • +
  • TG_REWARD_CHANNEL(如 @pangolin_app)、TG_WEBHOOK_SECRET;向 Telegram setWebhook 指向 https://api.yanmeiai.com/v1/tg/webhook(路由挂在 /v1 组下,真实路径含 /v1 前缀)。
  • +
  • 未配 TG_REWARD_BOT_TOKEN 时,TG 任务卡在 App 里整卡隐藏(邀请功能不受影响,优雅降级)。
  • +
+ +

7 · 客户端 UI

+

邀请页(占位屏已在)升级为「邀请 + 任务」两区,数据来自 GET /v1/invite:

+
+

邀请区(上)

+
    +
  • 真实邀请码 + 邀请链接,复制 / 系统分享。
  • +
  • 规则说明:注册双方各 +3、首充双方各 +7。
  • +
  • 战绩台账:已邀请 / 已转化 / 累计获赠天数 + 明细列表。
  • +
+

任务区(下)「更多得会员」

+
    +
  • 首张卡:加入 Telegram 频道 +3 天。
  • +
  • 未完成 → 「加入频道」+「验证领取」两步按钮。
  • +
  • 已完成 → 「已领 +3 天」置灰。
  • +
  • 未配 bot → 整卡不显示。
  • +
+
+

注册页:加「邀请码(选填)」输入框;deep link …/i/<code> 打开 App 自动预填并锁定。l10n 六语。

+ +

8 · 边界(YAGNI,本期不做)

+
    +
  • 不做 现金 / 提现奖励 —— 只发会员天数。
  • +
  • 不做 通知集成 —— 奖励发放留一个「事件」接缝,等 Spec ③ 通知系统建好再接;现阶段奖励在邀请页台账可见即可。
  • +
  • 不做 多级分销(邀请人的邀请人也分成)—— 只做一级。
  • +
  • 不做 注册后补填邀请码 —— 绑定仅注册时。
  • +
  • 不做 退群回收已发天数。
  • +
+ +

9 · 测试要点

+
    +
  • 注册段:正常双方各 +3;自邀请拒;已注册设备拒(rejected 不发);月度封顶到点后只记不发;无效码正常注册(不报错、不发)。
  • +
  • 首充段:首充双方各 +7;二次付费不再发(幂等,first_paid_at 已非空);未绑定用户首充不触发。
  • +
  • TG 任务:成员校验通过发 +3;非成员不发;同账户重复领拒;同 telegram_id 换账户领拒(UNIQUE);token 过期 / 伪造拒;webhook secret 校验。
  • +
  • 发天数原语:max(到期,now)+days 语义,已是 Pro 的用户正确顺延(不缩短)。
  • +
  • SQLite + MySQL 两套迁移与查询一致(遵项目多 DB 方言层,时间 Go 端算好传 ?)。
  • +
+ +

10 · 涉及文件(实现锚点)

+
# 后端
+server/internal/auth/service.go        # Register 加 inviteCode + 建 referrals + 注册段发奖
+server/internal/reward/                 # 新包:邀请/任务发奖 + 防刷闸 + getChatMember
+server/internal/pay/webhook.go          # settle() 首充钩子接首充段发奖
+server/internal/httpapi/                 # GET /v1/invite、/v1/tasks/telegram/start、POST /v1/tg/webhook
+server/migrations/{mysql,sqlite}/        # users +2 列、referrals、reward_claims
+# 客户端
+client/lib/screens/invite_page.dart      # 占位 → 真实(邀请区 + 任务区)
+client/lib/widgets/auth_screen.dart      # 注册页加邀请码输入 + deep link 预填
+client/lib/services/ + state/            # invite api + provider(auth_api.Register 加 inviteCode)
+client/lib/l10n/                          # 六语文案
+ +

下一步:定稿后进 writing-plans 出逐任务实现计划(docs/superpowers/plans/2026-07-12-invite-task-rewards.md),Subagent 驱动执行。TG 新基建(bot / 频道管理员 / webhook)在实现前由你在 Telegram 侧配好。

+ +
+ + diff --git a/docs/invite-task-rewards-plan.html b/docs/invite-task-rewards-plan.html new file mode 100644 index 0000000..d827f9c --- /dev/null +++ b/docs/invite-task-rewards-plan.html @@ -0,0 +1,255 @@ + + + + + +邀请奖励 + 奖励任务(加入 TG 频道) — 实现计划 + + + +
+← 文档索引 +

邀请奖励 + 奖励任务(加入 TG 频道)

+

实现计划阅读版 · 2026-07-12 · Spec ② · 14 任务 TDD · 状态 待执行

+

配套设计文档 邀请奖励 + 奖励任务 — 设计。执行真相源(含 checkbox,驱动 subagent-driven-development/executing-plans):docs/superpowers/plans/2026-07-12-invite-task-rewards.md

+ +
+Goal:用「送 Pro 会员天数」驱动增长 —— 邀请两段式(注册双方各 +3、被邀请人首充双方各 +7)+ 奖励任务(加入 TG 频道 +3,Bot 真校验),全部复用现有 subscriptions 发天数链路,不造新轮子。

+Architecture:新增 server/internal/reward 包承载发奖 + 防刷;发天数复用 codes.Service(新增 GrantRewardTx)。注册段在 auth.Service.Register 后 best-effort 触发(自有 tx,不拖累注册主流程);首充段挂 pay/webhook.settle 同事务内(幂等、失败即整笔回滚重试);TG 走 POST /tg/webhook(Telegram getChatMember 真校验)。客户端 invite_page 由占位转真实(邀请区 + 任务区),注册页加邀请码输入。

+Tech Stack:Go(chi + 裸 SQL + golang-migrate 双 DB)、Redis(TG 绑定 token)、Flutter/Riverpod、Telegram Bot API。 +
+ +

Global Constraints

+
    +
  • 发天数一律复用 codes.Service(Pro plan);迁移须把 subscriptions.source CHECK/ENUM 从 ('trial','code','pay') 扩到 ('trial','code','pay','invite','task')(sqlite 重建表 / mysql MODIFY)。
  • +
  • 奖励天数:注册段各 3、首充段各 7、TG 任务 3;注册段邀请人月度封顶默认 10(env INVITE_REG_MONTHLY_CAP)。
  • +
  • 审计一律走 audit_log(store.WriteAuditLog),无 sub_events 表
  • +
  • 多 DB:server/migrations/{mysql,sqlite}/ 两套一一对应;裸 SQL + internal/db 方言层;时间一律 Go 端 time.Now().UTC()?, NOW()/UTC_TIMESTAMP()
  • +
  • 邀请码 = 8 位 base32 大写、去 0/O/1/I;users.invite_code UNIQUE、惰性生成。
  • +
  • 绑定仅注册时、first-touch 永久不可改。防刷四道:自邀请拦截 + referrals.invitee_id UNIQUE + 设备去重(devices.uuid 已属别人则注册奖励不发、记 rejected)+ 注册段月度封顶 + reward_claims 双唯一 (user_id,task_key)/(task_key,external_ref)
  • +
  • TG:getChatMember 返回 member/administrator/creator 视为已加入;bot 未配(TG_REWARD_BOT_TOKEN 空)则 /tg/webhook 404 且 App 任务卡隐藏。webhook 校验 X-Telegram-Bot-Api-Secret-Token
  • +
  • 客户端 l10n 无 codegen:新 string 须 app_text.dart 加抽象 getter + 6 个 strings_{zh,en,es,ja,ko,ru}.dart 各加实现。
  • +
  • deep-link 自动预填 = 可选后置任务(Task 13 Step 6);MVP 走注册页手填邀请码。
  • +
  • TG 新基建(reward bot / 设为频道管理员 / setWebhook)由用户在实现前于 Telegram 侧配好。
  • +
  • 命令:后端 cd server && go test ./...;客户端 cd client && flutter analyze && flutter test
  • +
+ +

File Structure(总览)

+
# 后端(新建)
+server/migrations/{mysql,sqlite}/000024_invite_rewards.{up,down}.sql
+server/internal/reward/{store,service,telegram,handler}.go
+# 后端(修改)
+server/internal/codes/paygrant.go        # + GrantRewardTx
+server/internal/auth/{service,handler}.go # Register 加 inviteCode + ReferralHook
+server/internal/pay/webhook.go            # settle 首充钩子 + Rewarder
+server/cmd/server/main.go                 # 装配 + 路由 + env
+# 客户端(新建/修改)
+client/lib/services/invite_api.dart · state/invite_provider.dart
+client/lib/screens/invite_page.dart       # 占位转真实
+client/lib/widgets/auth_screen.dart + services/auth_api.dart # 邀请码输入
+client/lib/l10n/app_text.dart + strings_*.dart ×6
+ +

后端(Task 1–10)

+ +
+
1

迁移 000024 — 新表 + source 扩容

+

referrals(邀请关系,一对一绑定)、reward_claims(通用一次性任务领取)两张表;usersinvite_code/first_paid_at;subscriptions.source CHECK/ENUM 扩容到含 invite/task(SQLite 需重建表,MySQL 直接 MODIFY)。sqlite/mysql 各一对 up/down,四个文件。

+
新建 server/migrations/{sqlite,mysql}/000024_invite_rewards.{up,down}.sql
+
    +
  • 验收:server/internal/store/migrate_sqlite_test.go 现有 up→down→up 全量测试(run_sqlite_test.sh)通过。
  • +
+
+ +
+
2

codes.Service.GrantRewardTx — 发奖天数原语

+

codes 包加一个薄封装:复用既有 applySubscription(max(到期,now)+days 顺延语义)发 Pro 天数,source∈{invite,task},并写一条 audit_log。这是后续所有发奖调用的唯一入口。

+
server/internal/codes/paygrant.go · 新建 reward_grant_sqlite_test.go
+

接口:GrantRewardTx(ctx, tx, userID, days, source, auditAction, ref) (subID, expiresAt, err)

+
    +
  • 验收:新用户发奖建订阅、source 落对、audit_log 命中 1 条。
  • +
+
+ +
+
3

reward.Store — 数据访问层

+

新建 reward 包的纯数据访问层:邀请码惰性生成/解析、设备复用查询、月度计数、referrals/reward_claims 的 CRUD,唯一冲突统一映射为 ErrClaimExists

+
新建 server/internal/reward/{store.go, store_sqlite_test.go}
+

关键方法:EnsureInviteCode · ResolveInviteCode · DeviceUsedByOther · RegRewardCountThisMonth · InsertReferralTx · MarkFirstPaidTx · ReferralByInvitee · MarkPaidRewardedTx · InsertClaimTx · Summary · TelegramClaimed

+
    +
  • 验收:邀请码生成幂等(二次 ensure 不变);同用户/同 telegram_id 重复领取皆命中 ErrClaimExists
  • +
+
+ +
+
4

reward.Service — 邀请码生成、注册段发奖 + 防刷

+

Service(依赖 Store + Granter 接口,由 *codes.Service 满足)。核心 OnRegister:best-effort(自有事务,失败只 log、不回滚注册)解析邀请码 → 自邀请/设备复用/月度封顶三道防刷判定 → 建 referrals → 未被拒则双方各发注册段天数。

+
新建 server/internal/reward/{service.go, service_sqlite_test.go}
+

关键方法:GenInviteCode()(8 位 base32,去 0/O/1/I)· EnsureCode · OnRegister(ctx, inviteeID, inviteCode, deviceUUID)

+
    +
  • 验收:正常注册双方各得 RegDays;自邀请不建关系;复用他人设备时 status=rejected 且不发奖。
  • +
+
+ +
+
5

接入注册 — auth.Register 加 inviteCode

+

auth.Service.Register 签名加 inviteCode 参数;新增 ReferralHook 接口 + SetReferralHook setter,注册成功(recordLogin 之后)best-effort 调用钩子。handler.goregisterRequestinvite_code 字段并透传。

+
server/internal/auth/{service.go, handler.go} · 新建 register_invite_test.go
+
    +
  • 验收:注册后 hook 收到正确 inviteeID/code/deviceID;全库搜 .Register( 更新所有调用点后包内测试全绿。
  • +
+
+ +
+
6

首充钩子 — pay webhook 接首充段发奖

+

reward.ServiceOnFirstPaidTx(同事务内调用):先 MarkFirstPaidTx 判是否真首充,是则查 ReferralByInvitee,未发过/未拒则双方各发首充段天数并置 paid_rewardedpay/webhook.goRewarder 接口 + SetRewarder,在 GrantPaidSubscriptionTx 之后、tx.Commit() 之前调用 —— 失败则整笔回滚,靠 webhook 重试保证最终发放。

+
server/internal/reward/service.go(追加)、server/internal/pay/webhook.go · 新建 webhook_referral_sqlite_test.go
+
    +
  • 验收:首充双方各得 PaidDaysreferrals.status→paid_rewardedusers.first_paid_at 落值;二次付费不再重复发(幂等)。
  • +
+
+ +
+
7

GET /v1/invite 端点

+

新建 reward.Handler:惰性拿邀请码 + 邀请战绩汇总(已邀请/已转化/累计获赠天数)+ TG 任务态(enabled/joined/channel)一次性打包返回。

+
新建 server/internal/reward/handler.go(GetInvite 部分)、handler_invite_test.go
+

响应:{invite_code, invite_link, invited, converted, earned_days, telegram:{enabled, joined, channel}}

+
    +
  • 验收:返回 200,invite_code 非空,telegram.enabled 与构造参数一致。
  • +
+
+ +
+
8

TG 绑定 token — GET /v1/tasks/telegram/start

+

签发一次性 10 分钟 token 绑定当前账户(Redis SETEX,Redis 为 nil 时内存 map 兜底供测试)、消费即失效(GETDEL)。端点返回 bot 深链 t.me/<bot>?start=<token>

+
新建 server/internal/reward/telegram.go(token 部分)· handler.go · 新建 telegram_token_test.go
+
    +
  • 验收:token 签发后可消费一次拿回正确 userID,二次消费返回 not-ok。
  • +
+
+ +
+
9

TG webhook — getChatMember 真校验 + 发奖

+

ChatMemberChecker 接口(默认实现打 Telegram getChatMember API,测试可注入 fake)。ClaimTelegram:真是成员 → InsertClaimTx(唯一守卫)+ GrantRewardTx(source='task')Handler.TelegramWebhook:校验 X-Telegram-Bot-Api-Secret-Token → 解析 /start <token> → 消费 token → 领取 → sendMessage 回执(成功/已领/非成员话术不同)。

+
server/internal/reward/{telegram.go, handler.go} · 新建 telegram_webhook_test.go
+
    +
  • 验收:成员一次性发 TgDays,二次领取不再发;非成员不发;全包 go test ./internal/reward/... 绿。
  • +
+
+ +
+
10

装配 main.go — 构造 reward svc + 注入 + 路由 + env

+

纯装配任务,无独立单测(逻辑单测已在各包)。读 INVITE_REG_MONTHLY_CAP/TG_REWARD_BOT_TOKEN/TG_REWARD_BOT_USER/TG_REWARD_CHANNEL/TG_WEBHOOK_SECRET 构造 rewardSvc;注入 authSvc.SetReferralHook + payWebhook.SetRewarder;挂路由 protected: GET /invite, GET /tasks/telegram/startpublic v1: POST /tg/webhook

+
server/cmd/server/main.go
+
    +
  • 验收:go build ./... && go vet ./cmd/server/ 无错;go test ./... 全绿。
  • +
+
+ +

客户端(Task 11–13)

+ +
+
11

客户端 invite api + provider

+

新建 InviteApi(fetch()GET /v1/invitetelegramStartLink() 拉深链)+ InviteInfo 数据类;Riverpod InviteNotifier(未登录返回 null、不打网络)。auth_api.dartregister 加可选 inviteCode 参并入请求体。

+
新建 client/lib/services/invite_api.dart · state/invite_provider.dart · services/auth_api.dart
+
    +
  • 验收:invite_api_test.dartMockClient 断言 /v1/invite 响应正确解析到 InviteInfo 各字段。
  • +
+
+ +
+
12

客户端 invite_page 真实化 + l10n

+

邀请页占位转真实:邀请区(真实码/链接 + 复制/分享 + 战绩三格)+ 任务区「更多得会员」(TG 任务卡:未完成显「加入频道」+「验证领取」两步,已完成显「已领 +3 天」置灰,bot 未配整卡隐藏)。先加 9 个 l10n getter(app_text.dart + 6 语言实现),再改页面引用。颜色一律走 token,禁硬编码。

+
client/lib/screens/invite_page.dart · l10n/app_text.dart + strings_{zh,en,es,ja,ko,ru}.dart
+
    +
  • 验收:flutter test test/widget/invite_page_test.dart 通过(注入 fake InviteInfo,断言邀请码文本 + TG 任务卡文案可见);flutter analyze 对改动文件无 issue。
  • +
+
+ +
+
13

注册页加邀请码输入 + deep-link 预填(可选后置)

+

Step 1–5(必做):auth_screen.dart 注册表单密码步下方加一个可选「邀请码(选填)」输入框(key invite-code-field),提交时传 inviteCodeauth_api.register

+

Step 6(可选后置,MVP 不含):app_links 依赖 + 四端原生配置(Android intent-filter / iOS associated domains / macOS URL scheme),解析邀请链接自动预填并锁定注册页邀请码字段。单列为独立后续任务。

+
client/lib/widgets/auth_screen.dart · 新建 test/widget/auth_invite_field_test.dart
+
    +
  • 验收:widget 测试断言注册表单存在邀请码输入框;flutter analyze 对该文件无 issue。
  • +
+
+ +

文档(Task 14)

+ +
+
14

计划 HTML 阅读版 + 索引登记

+

即本文档:按项目「设计/计划双产物」规范,把 docs/superpowers/plans/2026-07-12-invite-task-rewards.md(执行真相源,含 checkbox)生成同内容 HTML 阅读版,并登记进 docs/index.html「实现计划 / Plans」分类、与设计文档互链。

+
新建 docs/invite-task-rewards-plan.html · docs/index.html
+
    +
  • 验收:两文件登记完整、互链可点;.md 保持执行真相源不变(供 subagent-driven-development 继续驱动 Task 1–13)。
  • +
+
+ +

验收(端到端)

+
    +
  • 后端:cd server && go test ./... 全绿(含 reward 包 + auth/pay 回归)。
  • +
  • 客户端:cd client && flutter analyze && flutter test 全绿。
  • +
  • 真机联调(需 TG 新基建就绪):A 注册拿邀请码 → B 用 A 的码注册 → A/B 各 +3;B 用人民币下一单付成 → A/B 各再 +7;B 在 App 点「加入频道」+「验证领取」→ bot 校验成员 → +3;重复领被拒。
  • +
  • 防刷:自邀请无关系;同设备第二账号注册奖励被拒(status=rejected);同一 telegram_id 换账户领被拒。
  • +
+ +

不在本轮(YAGNI)

+
    +
  • 不做 现金/提现、多级分销、退群回收、注册后补填邀请码。
  • +
  • 不做 deep-link 自动预填(Task 13 Step 6 单列后续)。
  • +
  • 不做 通知集成(奖励事件接缝留给 Spec ③)。
  • +
+ +

本页为阅读版,不含逐步 TDD 代码细节;完整测试代码/实现片段见执行真相源 docs/superpowers/plans/2026-07-12-invite-task-rewards.md

+ +
+ + diff --git a/docs/ios-distribution-outside-appstore.html b/docs/ios-distribution-outside-appstore.html new file mode 100644 index 0000000..fdad55c --- /dev/null +++ b/docs/ios-distribution-outside-appstore.html @@ -0,0 +1,187 @@ + + + + + +iOS 绕过 App Store 分发 VPN · 渠道调研与落地建议(2026-07) + + + +
+ +

iOS 绕过 App Store 分发 VPN · 渠道调研与落地建议

+

场景锁定:个人 Apple Developer 账号 · 含 NetworkExtension/PacketTunnel · 面向中国大陆散户 · 中国区 App Store 无法上架未授权 VPN · 调研时点 2026-07

+ +
+一句话结论:对「个人账号 + 含 NE 的 VPN + 大陆散户」这个组合,官方框架内唯一现实可行的是 TestFlight(上万用户、支持 PacketTunnel、大陆可兑换),代价是 每个构建 90 天滚动重传 + 每次外部测试过一次 Beta 审核。其余官方渠道分别败于设备硬顶、企业门槛与吊销潮、B2B 定位、欧盟地理围栏、技术不可能。国内灰产的超级签 / 企业签 / MDM 签能补规模,但都建立在「滥用苹果通道」之上,掉签与账号吊销是常态、不是意外。行业真实做法 = TF 打门面 + 超级签/独立企业签冗余兜底 + 落地页「掉线重装」引导。 +
+ +

官方 / 半官方渠道(七条)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
渠道评级人数/设备上限NE 支持大陆可用为什么行 / 不行
TestFlight⭐⭐⭐⭐外测 10,000 / 内测 100✅ PacketTunnel 完全支持✅ 兑换链接不依赖中国区唯一「官方框架内、上万级、支持 NE、覆盖大陆」的路。代价:构建 90 天过期须重传、外测每次一次 Beta App Review(比正式审核宽松,1–2 天)。VPN 过审有随机性,长期当分发渠道属灰色但目前可行。
Ad Hoc每会员年每类 100 台 UDID(删了仍占额度)✅(付费账号有 entitlement)△ Diawi 等装机页大陆常连不通100 台硬顶 + 逐台收 UDID,只能内测。规模化分发不可行。
企业计划 ADEP(In-House)不限设备△ 灰市共享证书随时吊销$299/年,但要求法人 + 100+ 员工 + D-U-N-S + 苹果面谈,个人根本不符门槛;ToS 只准分发给「自己员工」,公开分发违规;吊销后 OCSP 缓存 3–7 天全体停跑。近年苹果大幅收紧。不可行。
ABM 自定义 App兑换码 2.5 万/周仍要过正常 App Review(VPN 审核关没绕过)+ 面向指定企业/机构,不是匿名散户。为 B2B 设计,不解决大陆 C 端。
EU DMA 第三方市场
(AltStore PAL / Epic)
✅ 政策对 VPN 较宽✗ 硬性欧盟限定装新 app 须人在欧盟 + 欧盟 Apple ID;走 Notarization 而非审核,对 VPN 宽松——但地理围栏使其对大陆用户完全无意义。还有 CTF €0.50/首年安装。
Web Clip / PWA✗ WKWebView 沙箱无 NE/TUN技术上不可能做 VPN。
个人账号 sideload
(AltStore / Sideloadly)
每设备 3 app△ 免费号拿不到 VPN entitlement,须付费号(又回 Ad Hoc 100 台)免费证书 7 天、付费 1 年,须连电脑/AltServer 定期重签。普通大陆用户门槛过高,规模化不现实。
+ +

国内灰产签名(补规模的现实手段)

+

官方只有 TF 一条路且有上限,大陆 VPN 实务里普遍叠加以下灰产渠道。全部建立在「滥用苹果分发通道」上,掉签/吊销是结构性风险

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
方案原理成本 / 计费稳定性掉签风险用户操作
超级签最稳的灰产档借个人号 Ad Hoc 通道,每号 100 台 UDID,自动收 UDID 装机,满即换号16–20 元/台(一次性)低(除非服务商用违规号被清)信任描述文件,免装 MDM
企业签(共享证书)共享企业证书($299)重签,免 UDID 免连电脑,网页直装300–800 元/月高(公开分发被苹果批量吊销)网页装 + 信任企业开发者
企业签(独立/稳定签)不外租的独享证书 + 多证容灾1500–5000+ 元/月中–较高<1% ~ 中网页装 + 信任
MDM 签MDM 描述文件把设备标为「企业内部设备」,底层仍企业证书,按下载量计费按下载量须装 MDM 描述文件 + 授管理权(信任/隐私成本最高)
TF 代上架= 官方 TestFlight第三方帮你过审、维护 TF 构建代上架 ~2000/月、5000/季最高(官方,不掉签)不掉签,但 90 天 / 审核限装官方 TF app + 链接
+

稳定性梯度:TF ≳ 超级签 > 独立企业签/稳定签 > MDM > 共享企业签。单用户成本:超级签一次性最省,稳定签包月最贵。

+ +

合规与账号风险(务必先读)

+
    +
  • 法律灰色:2017 年工信部要求 VPN 服务商须持经营资质、用国营基础设施;无资质对大陆公开提供翻墙服务处灰色/违法地带。
  • +
  • 平台层:中国区 App Store 已全量下架 VPN/代理工具(苹果称应工信部要求);苹果对翻墙类证书吊销执行较严
  • +
  • 开发者账号:用「功能开关(switch)规避审核」等手法一旦被识别,苹果可将账号标记为待移除直接封号(V2EX 有独立开发者被直接封号案例)。企业证书公开分发翻墙 App 尤其高危。
  • +
  • 👉 对 Pangolin 而言:拿你个人主账号去硬刚 VPN 分发,最坏结果是主账号被封。若走灰产,务必隔离账号(专用于分发的独立账号),别赌主力开发者身份。
  • +
+ +

给 Pangolin 的落地建议

+
第 1 步(低风险、马上能做):TestFlight。 你现在 iOS 已是本机直装联调。把 iOS 版提交 TF 外部测试 → 大陆用户装官方 TestFlight app + 你的公开兑换链接即可用,支持 PacketTunnel,上万名额。代价是 90 天重传 + 每次外测过 Beta 审核。审核时别在描述里写"翻墙/VPN 突破防火墙",按通用「安全代理/隐私网络」类表述提交。
+
第 2 步(掉线兜底):超级签。 若 TF 被拒或名额紧,叠加超级签(16–20 元/台、稳定性最高的灰产档)。官网/落地页备一条「iOS 安装」入口,TF 链接失效时切超级签重装页——和你已有的官网下载引导天然契合。
+
不建议:企业签(共享)掉签太频、用户体验差;MDM 签要用户交设备管理权,对隐私敏感的 VPN 用户是劝退项;ADEP/ABM/EU 市场对你的场景直接不成立。
+

现实运营模型(大陆 VPN 通行做法):TF 打门面 + 超级签冗余兜底 + 落地页"无法打开?点此重装"话术。没有单一稳定渠道,靠组合冗余 + 快速重装引导消化掉签。若想彻底避开吊销风险,唯一"正规"路是海外区 App Store 上架 + 大陆用户自行换区,但那把安装门槛推给了用户。

+ +

来源

+

官方渠道

+ +

国内灰产签名

+ + +

调研时点 2026-07,联网检索汇总。苹果政策(尤其 EU DMA、企业证书吊销、TF 审核尺度)变动频繁,落地前请以官方文档最新状态为准。相关:客户端发版链路(若存在)。

+ +
+ + diff --git a/docs/notifications-design.html b/docs/notifications-design.html new file mode 100644 index 0000000..a0d29c7 --- /dev/null +++ b/docs/notifications-design.html @@ -0,0 +1,235 @@ + + + + + +系统通知机制 — 设计 + + + +
+← 文档索引 +

系统通知机制

+

设计文档 · 2026-07-13 · Spec ③(系统通知)· 状态 已评审定稿 · 配套实现计划见 notifications-plan.html

+ +
+目标:App 内统一通知收件箱(顶栏铃铛 + 通知列表),覆盖六类内容:重要通知 / 新特性 / 新闻 / 到账(购买·邀请·任务奖励,个人定向)/ 版本 / 活动。
+送达:App 内为主(打开/回前台拉取),邮件仅兜底「重要通知」且需显式指定;系统推送(APNs/FCM)明确二期。
+决策(已定):单表 + audience(user_id 空=广播)· 已读用服务端 last_read_at 水位(多端同步)· 发版脚本联动插 version 公告 · 发布走 nodectl notice 子命令。
+现状底子:GET /v1/notices 空壳已在(恒空列表);openapi 已有双语 Notice schema(需扩 type/link/unread);客户端占位屏/铃铛/五条 l10n 已备;jiu 无可参考实现(其通知为二期占位)。 +
+ +

1 · 类型口径(单一来源)

+ + + + + + + + +
type标签(zh/en)受众典型内容生产方式
important重要 / Important广播(可定向)服务条款、安全、合规、线路维护nodectl(可 --email)
feature功能 / Feature广播新增大阪线路、协议选择上线nodectl
news新闻 / News广播产品动态、博客nodectl
reward到账 / Credited个人定向购买开通 ×31 天、邀请注册 +3、首充 +7、TG 任务 +3事件钩子自动(同事务)
version版本 / Update广播vX.Y.Z 发布 + 要点;点击接更新流程发版脚本联动
promo活动 / Promo广播¥6 限购上线、邀请双倍周、年付折扣nodectl
+

promo 与 feature/news 的边界:让用户「花钱/参与活动」的归 promo;产品功能上新归 feature;动态资讯归 news。类型集与客户端类型 pill、原型登记、l10n 标签三处须同集(L1 闸)。

+ +

2 · 数据流总览

+
+ + + + + + nodectl notice add + important/feature/news/promo + + 事件钩子(同事务) + 购买开通 · 邀请/首充/TG 到账 + + release-client.sh + 发版联动插 version 公告 + + + + notices 表 + user_id NULL=广播 + 非空=个人定向 + + + + + + + + GET /v1/notices + 广播+我的 · unread 按水位算 · unread_count + + POST /v1/notices/read + users.notices_read_at = now(多端同步) + + + + + + + 客户端 noticesProvider + 启动延迟拉 + 回前台拉;铃铛红点=unread_count>0 + 打开列表 → 调 read 清红点 + + + + 邮件兜底(SMTP) + 仅 important + 显式 --email + + 系统推送(APNs/FCM)二期;openapi 描述过的「静态镜像 JSON 副本」意图保留、本期不实现。 + +
+ +

3 · 数据模型(迁移 000026,sqlite/mysql 两套)

+

notices 新表:

+ + + + + + + + + + + + + +
类型说明
idPK 自增
typeCHECK/ENUM(六值)important|feature|news|reward|version|promo
user_idbigint NULLNULL=全员广播;非空=定向该用户(比字符串 audience 可索引);FK users(id)
title_zh / title_entext双语标题(内容仅 zh/en;ja/ko/ru/es 界面回落 en)
body_zh / body_entext NULL双语正文(Markdown;reward 类一行文案可空)
linktext NULL可选动作(version 类点击接更新流程等)
published_atdatetime排序与未读水位比较基准(Go 端算好传 ?)
expires_atdatetime NULL到期自动不再下发(promo 常用)
revoked_atdatetime NULL撤回(nodectl notice revoke)
email_sent_atdatetime NULL邮件兜底幂等标记(同公告只发一轮)
索引:(user_id, published_at);广播查询走 user_id IS NULL 分支
+

users 加一列:notices_read_at DATETIME NULL —— 已读水位:published_at > notices_read_at 即未读。不做逐条已读(YAGNI,水位方案多端同步且零关联表)。

+ +

4 · API(扩现有空壳 accountAPI.ListNotices)

+ + + + +
端点行为
GET /v1/notices(protected)返回 {notices:[…], unread_count}:合并「user_id IS NULL + user_id=me」,过滤 revoked_at IS NOT NULLexpires_at < now,published_at 倒序 limit 50;每条含 id/type/title_zh/title_en/body_zh/body_en/link/published_at/unread(unread 服务端按水位算)。
POST /v1/notices/read(protected)UPDATE users SET notices_read_at=?(now)。客户端打开通知列表时调,红点全端清零。
+
    +
  • openapi:Notice schema 增 type/link/unread,响应对象增 unread_count(现有 id/title/body/published_at 必填保持兼容)。
  • +
  • 不做独立未读计数端点——列表响应自带,客户端启动/回前台拉列表即得红点。
  • +
+ +

5 · 生产者(三路)

+

5.1 nodectl notice 子命令(手工发布,important/feature/news/promo)

+
nodectl notice add --type important --title-zh 线路维护通知 --title-en "Line Maintenance" \
+  [--body-zh …] [--body-en …] [--link …] [--expires 2026-08-01] [--email]
+nodectl notice list [--all]          # 含已撤回/过期
+nodectl notice revoke <id>
+
    +
  • 接入方式沿 nodectl 既有模式(直连 DB 或管理通道,以现状为准);校验:type 合法、双语标题必填、--email 仅 important 可用。
  • +
  • 发布/撤回写 audit_log(action notice_publish/notice_revoke)。
  • +
+

5.2 事件自动通知(reward 类,个人定向,同事务)

+ + + + + + +
事件接缝(既有事务内追加一条 INSERT)文案模板(双语,服务端渲染入库)
购买开通pay webhook.settle(Grant→MarkPaid 之后、Commit 前)「已开通 Pro ·31 天 / Pro activated · 31 days」
邀请注册奖励(双方)reward OnRegister 两次 Grant 后「邀请奖励 +3 天已到账 / Invite reward +3 days credited」
首充奖励(双方)reward OnFirstPaidTx 两次 Grant 后「好友首购奖励 +7 天已到账 / …+7 days credited」
TG 任务reward ClaimTelegram Grant 后 Commit 前「任务奖励 +3 天已到账 / Task reward +3 days credited」
+

同事务不变量:通知与发放同 COMMIT/同回滚——用户看到「到账」通知 ⟺ 权益真到账,零孤儿通知(与钱路既有纪律一致)。实现为 reward/notices 侧一个 InsertNoticeTx(ctx, tx, userID, type, …) 小函数,各接缝一行调用。

+

5.3 发版联动(version 类)

+
    +
  • scripts/ci/release-client.sh 在推 version.yaml 到 pangolin1 后,追加一步经 ssh 调 nodectl notice add --type version --title-zh "vX.Y.Z 已发布" …(版本号/要点来自 tag 与 release notes)。
  • +
  • 客户端 version 类行点击 → 走现有 update_provider/更新流程(link 承载)。
  • +
+ +

6 · 邮件兜底

+
    +
  • 范围:仅 important 且发布时显式 --email;广播=遍历 active 用户逐发,定向=单发。复用现有 SMTP mailer(auth/mailer.go 同款配置)。
  • +
  • 幂等:notices.email_sent_at 置位后不重发;发送失败记日志继续(best-effort,不阻塞发布)。用户量增大后再演进为队列(明确非本期)。
  • +
  • 模板一套双语合排(邮件不受 App 单显铁律约束):标题 + 正文 + 「在 App 内查看」。
  • +
+ +

7 · 客户端

+
    +
  • noticesProvider(AsyncNotifier,照 update_provider 惯例):启动延迟数秒首拉 + 回前台重拉;缓存内存;unread_count>0 驱动铃铛红点(替换现在两处硬编码 hasUnread: true——content_top_bar 与 account_page)。
  • +
  • 通知页真实化(notifications_page):删静态示例;行 = 类型 pill + 标题 + 相对时间(复用 relativeTime 单源)+ 未读点;点击行内展开正文(Markdown 降级纯文本渲染即可);进入页面即调 POST /read 清红点;空态用现有 notifEmpty;version 类行尾「去更新」动作接现有更新流程。
  • +
  • l10n:类型标签补 notifTypeReward / notifTypeVersion / notifTypePromo(六语);通知内容按 App 语言取 zh 或 en 字段(非中即英)。
  • +
  • 原型先行(铁律):动代码前先统一两端原型通知视图——现状桌面用文字 pill、移动用图标,不一致;统一为「类型 pill + 未读点」桌面式(移动同构),并补 reward/version/promo 三类示例行 + 展开态;同一提交回填两份原型。
  • +
+ +

8 · 边界(YAGNI,本期不做)

+
    +
  • 不做 APNs/FCM 系统推送(二期;大陆 FCM 可达性另议)。
  • +
  • 不做 逐条已读 / 通知偏好按类型开关 / 管理后台页。
  • +
  • 不做 六语内容(内容 zh/en,其余界面语言回落 en)。
  • +
  • 不做 openapi 描述里的「多镜像签名静态 JSON 副本」(意图保留,后续抗封锁专项再做)。
  • +
  • 不做 邮件队列/重试体系(现量级直发 + email_sent_at 幂等够用)。
  • +
+ +

9 · 测试要点

+
    +
  • 查询合并:广播+定向合并、他人定向不可见;revoked/expired 过滤;limit 50;倒序。
  • +
  • 未读水位:read 前后 unread/unread_count 变化;新公告晚于水位再变未读;多“设备”(两次会话)读同一水位。
  • +
  • 同事务:TG 领奖/首充/注册奖励成功 → reward 通知在;Grant 失败回滚 → 通知不留(孤儿为零);购买开通同款。
  • +
  • nodectl:类型校验、--email 限 important、revoke 后列表即消失、audit_log 两 action。
  • +
  • 邮件:仅 important+--email 触发;email_sent_at 幂等(重复执行不再发);失败不阻塞发布。
  • +
  • 客户端:红点由 unread_count 驱动(0 不显);进入列表调 read 后红点清;空态;version 行动作;golden(通知页多主题)。
  • +
  • 双 DB:迁移 up/down round-trip;时间 Go 端传 ?。
  • +
+ +

10 · 涉及文件(实现锚点)

+
# 后端
+server/migrations/{mysql,sqlite}/000026_notices.{up,down}.sql   # notices 表 + users.notices_read_at
+server/internal/notices/                    # 新包:store(查询合并/水位/InsertNoticeTx)+ service(邮件兜底)+ handler
+server/internal/httpapi/account.go          # ListNotices 空壳替换为真实现(或移交 notices 包)
+server/internal/reward/service.go           # OnRegister/OnFirstPaidTx/ClaimTelegram 接 InsertNoticeTx
+server/internal/pay/webhook.go              # settle 接购买开通通知
+server/cmd/nodectl/                          # notice add/list/revoke 子命令
+scripts/ci/release-client.sh                 # 发版联动插 version 公告
+server/api/openapi.yaml                      # Notice schema 扩 type/link/unread + /notices/read
+# 客户端
+design/prototype/screens/{ui-desktop,ui-mobile}.html  # 原型先行:两端通知视图统一 + 三类新示例
+client/lib/state/notices_provider.dart       # 新:拉取/未读/read
+client/lib/services/notices_api.dart         # 新:GET /v1/notices + POST /read
+client/lib/screens/notifications_page.dart   # 占位 → 真实
+client/lib/widgets/{notification_bell,content_top_bar}.dart + screens/account_page.dart  # 红点接 provider
+client/lib/l10n/                              # notifTypeReward/Version/Promo 六语
+ +

下一步:writing-plans 出逐任务实现计划(md 真相源 + HTML 阅读版),Subagent 驱动执行。与 邀请奖励设计(Spec ②)的「奖励事件接缝」在 5.2 节正式接通。

+ +
+ + diff --git a/docs/notifications-plan.html b/docs/notifications-plan.html new file mode 100644 index 0000000..40147c6 --- /dev/null +++ b/docs/notifications-plan.html @@ -0,0 +1,226 @@ + + + + + +系统通知机制 — 实现计划 + + + +
+← 文档索引 +

系统通知机制

+

实现计划阅读版 · 2026-07-13 · Spec ③ · 11 任务 TDD · 状态 待执行

+

配套设计文档 系统通知机制设计。执行真相源(含 checkbox,驱动 subagent-driven-development/executing-plans):docs/superpowers/plans/2026-07-13-notifications.md

+ +
+Goal:App 内统一通知收件箱(铃铛红点 + 六类通知列表),含个人到账通知(同事务零孤儿)、nodectl 发布、发版联动与 important 邮件兜底。

+Architecture:单 notices 表(user_id NULL=广播/非空=定向)+ users.notices_read_at 已读水位;新 server/internal/notices 包(store/service/handler)替换现有 GET /v1/notices 空壳;生产三路(nodectl 手工、reward/pay 事务内钩子、发版脚本);客户端 noticesProvider 驱动铃铛与列表,原型先行统一两端通知视图。

+Tech Stack:Go(chi+裸 SQL 双方言)、SMTP(复用 auth mailer 配置)、Flutter/Riverpod、shared 设计原型。 +
+ +

Global Constraints

+
    +
  • 类型集六值恒定:important|feature|news|reward|version|promo(DB CHECK/ENUM、openapi、客户端 pill、原型登记四处同集)。
  • +
  • 内容仅 zh/en 双语字段;客户端 ja/ko/ru/es 界面取 en 内容。
  • +
  • 已读=水位:published_at > users.notices_read_at 即未读;不做逐条已读
  • +
  • 事件到账通知必须与发放同事务(传入 tx,不自开;失败随事务回滚)。
  • +
  • 邮件 important 且显式 --email;notices.email_sent_at 幂等;发送失败只记日志不阻塞发布。
  • +
  • 多 DB:迁移 server/migrations/{mysql,sqlite}/ 两套一一对应,下一编号 000026;裸 SQL;时间 Go 端 time.Now().UTC()?,禁 NOW()/UTC_TIMESTAMP()。
  • +
  • GET /v1/notices 响应契约(客户端按此解析):{"notices":[{id,type,title_zh,title_en,body_zh,body_en,link,published_at,unread}],"unread_count":N};列表=广播∪我的定向,过滤 revoked_at IS NOT NULL/expires_at<now,published_at 倒序 limit 50
  • +
  • 客户端改 UI 前原型先行(两端通知视图统一为「类型 pill+未读点」桌面式,补 reward/version/promo 示例),原型与代码同批次落地。
  • +
  • 颜色走 token(context.pangolin/var(--token)),文案经 AppText,红线词禁用。
  • +
  • 命令:cd server && go test ./...;cd client && flutter analyze && flutter test;bash server/run_sqlite_test.sh
  • +
+ +

File Structure(总览)

+
# 后端(新建)
+server/migrations/{mysql,sqlite}/000026_notices.{up,down}.sql
+server/internal/notices/{store,service,handler}.go
+# 后端(修改)
+server/internal/reward/service.go         # OnRegister×2/OnFirstPaidTx×2/ClaimTelegram 接 InsertNoticeTx
+server/internal/pay/webhook.go            # settle 插「购买开通」通知
+server/cmd/nodectl/main.go + notice.go    # notice add/list/revoke 子命令
+server/cmd/server/main.go                 # 装配 notices + 注入 reward/pay + 路由替换空壳
+server/internal/httpapi/account.go        # 删 ListNotices 空壳
+server/api/openapi.yaml                   # Notice schema 扩展 + /notices/read
+scripts/ci/release-client.sh              # 发版联动插 version 公告
+# 客户端/原型(新建/修改)
+design/prototype/screens/{ui-desktop,ui-mobile}.html  # 通知视图统一+新类型示例
+client/lib/services/notices_api.dart · state/notices_provider.dart
+client/lib/screens/notifications_page.dart # 真实化
+client/lib/widgets/content_top_bar.dart + screens/account_page.dart # 红点接 provider
+client/lib/l10n/app_text.dart + strings_*.dart ×6
+ +

后端(Task 1–7)

+ +
+
1

迁移 000026 — notices 表 + users.notices_read_at

+

新建 notices 表(六值 CHECK/ENUM、user_id NULL=广播、双语标题/正文、link/published_at/expires_at/revoked_at/email_sent_at、索引 (user_id,published_at))+ usersnotices_read_at。sqlite/mysql 各一对 up/down,四个文件。

+
新建 server/migrations/{sqlite,mysql}/000026_notices.{up,down}.sql
+
    +
  • 验收:run_sqlite_test.sh + 迁移 up→down→up 全量测试通过(断言若因新迁移顶偏,按 000024/000025 既有修法更新计数)。
  • +
+
+ +
+
2

notices.Store — 数据访问层

+

新建 notices 包的纯数据访问层:广播∪定向合并查询(过滤 revoked/expired,倒序 limit)+ 已读水位判定;事务内插通知(事件钩子用,不自开事务)+ 自管连接的广播插入(nodectl/发版用)。

+
新建 server/internal/notices/{store.go, store_sqlite_test.go}
+

关键方法:ListForUser · MarkRead · InsertNoticeTx(事务内定向)· InsertBroadcast · Revoke · ListAdmin · MarkEmailSent · ListActiveUserEmails

+
    +
  • 验收:广播+我的定向合并可见、他人定向/撤回/过期均排除;水位判定正确(空水位全未读);InsertNoticeTx 随外部事务回滚则零孤儿。
  • +
+
+ +
+
3

notices HTTP handler + openapi 扩展 + 替换空壳

+

新建 Handler.List(GET /v1/notices,未登录 401)+ Handler.MarkRead(POST /v1/notices/read);删 httpapi/account.goListNotices 空壳(若编译顺序需要可延到 Task 7);openapi Notice schema 增 type/link/unread,响应增 unread_count,新增 /notices/read

+
新建 server/internal/notices/{handler.go, handler_test.go} · server/internal/httpapi/account.go、server/api/openapi.yaml
+
    +
  • 验收:list 返回契约字段齐全(type/unread/unread_count);read 后 unread_count 清零;未登录 401;openapi 校验通过;go build ./... 绿。
  • +
+
+ +
+
4

事件钩子 — 四处到账通知(同事务)

+

reward/pay 各自声明小接口 Noticer(照 Granter/Rewarder 惯例避免 import cycle),四接缝在既有事务内、Grant 成功之后追加 InsertNoticeTx:OnRegister(邀请注册双方)、OnFirstPaidTx(首充双方)、ClaimTelegram(TG 任务)、pay webhook.settle(购买开通)。插入失败视为该事务失败(随之回滚),非吞错。

+
server/internal/reward/service.go、server/internal/pay/webhook.go · 新建 notices_hook_sqlite_test.go、webhook_notice_sqlite_test.go
+
    +
  • 验收:四事件各自触发一条 type='reward' 定向通知;Grant 失败回滚时通知同回滚(零孤儿);双语文案模板落对天数。
  • +
+
+ +
+
5

notices.Service — 发布/撤回 + 邮件兜底

+

校验(type∈六值、标题非空、Email=true 仅 important 可用)→ Publish:InsertBroadcast → 写 audit(notice_publish)→ Email 时逐发 active 用户(失败仅 log)→ MarkEmailSent 幂等。RevokeByID:Revoke + audit(notice_revoke)。

+
新建 server/internal/notices/{service.go, service_test.go}
+
    +
  • 验收:非法 type/缺标题/email 配非 important 均报错;成功发布落库+audit;email=true 全员发送、email_sent_at 置位、二次 Publish 是新公告不误判幂等;Revoke 后列表不可见+audit 行。
  • +
+
+ +
+
6

nodectl notice 子命令

+

notice add --type … --title-zh … --title-en … [--body-zh] [--body-en] [--link] [--expires] [--email] / notice list [--all] [--limit] / notice revoke <id>。add 组 PublishInputService.Publish(SMTP mailer 复用 auth 配置或包内极简 net/smtp 实现;无配置且 --email 明确报错拒发)。

+
新建 server/cmd/nodectl/{notice.go, notice_test.go} · server/cmd/nodectl/main.go
+
    +
  • 验收:参数解析层单测覆盖 type 非法/缺标题/email 非 important/expires 格式各红;go build ./... && go test ./cmd/nodectl/ 绿。
  • +
+
+ +
+
7

main.go 装配 + 发版联动

+

装配 noticesStore/noticesHandler,注入 rewardSvc.SetNoticer/payWebhook.SetNoticer;路由 protected 组替换空壳:GET /notices、新增 POST /notices/readscripts/ci/release-client.sh 在 version.yaml 推送成功后追加 nodectl notice add --type version(失败不阻断发版)。

+
server/cmd/server/main.go、server/internal/httpapi/account.go、scripts/ci/release-client.sh
+
    +
  • 验收:go build ./... && go vet ./cmd/server/ && go test ./... -count=1 全绿;shellcheck release-client.sh 通过。
  • +
+
+ +

原型与客户端(Task 8–10)

+ +
+
8

原型先行 — 两端通知视图统一 + 新类型示例

+

统一形态为桌面式「类型 pill + 标题 + 相对时间 + 未读点」:改造 ui-mobile 通知子屏对齐桌面 .notif-row 结构(放弃 icon 区分);两端各补三类新示例行(reward/version/promo),i18n 字典补对应示例文案(zh/en),两端占位内容统一为同一组;点击行展开正文的简单交互示意。

+
design/prototype/screens/{ui-desktop,ui-mobile}.html
+
    +
  • 验收:node design/prototype/serve.mjs 起两文件均 200;新增行无裸 hex,全走 token。
  • +
+
+ +
+
9

客户端 notices api + provider

+

新建 NoticesApi(fetch()GET /v1/noticesmarkRead()POST /v1/notices/read)+ NoticeItem/NoticesData 数据类(fromJson 安全默认,缺字段不 crash);Riverpod noticesProvider(AsyncNotifierProvider):未登录 null、已登录延迟 2s 首拉(照 update_provider 模式),refresh()/markAllRead() 本地态同步。

+
新建 client/lib/services/notices_api.dart · state/notices_provider.dart · test/unit/notices_api_test.dart
+
    +
  • 验收:notices_api_test.dart 用 MockClient 断言 fetch 解析(2 条/unread_count/类型/双语字段)+ markRead 打对路径;flutter analyze 新文件 0 issue。
  • +
+
+ +
+
10

通知页真实化 + 铃铛红点接 provider + l10n

+

l10n 加 notifTypeReward/Version/Promo(六语实现)。notifications_page 删静态数据,ref.watch(noticesProvider) 三态(loading/空/error);行=类型 pill + 双语标题 + 相对时间 + 未读点,点击展开正文,version 行「去更新」接现有更新流程,进页调 markAllRead()content_top_bar/account_pageNotificationBell(hasUnread:true) 硬编码 → 接 unreadCount>0 真值;回前台刷新接壳层 lifecycle。

+
client/lib/screens/notifications_page.dart、client/lib/widgets/content_top_bar.dart、client/lib/screens/account_page.dart、client/lib/l10n/app_text.dart + strings_{zh,en,ja,ko,ru,es}.dart · 新建 test/widget/notifications_page_test.dart
+
    +
  • 验收:widget 测试断言类型 pill 文案在/未读点在/进页触发 markAllRead;flutter analyze 0 新增;flutter test 全绿(通知页 golden 若有则重录)。
  • +
+
+ +

文档(Task 11)

+ +
+
11

计划 HTML 阅读版 + 索引登记 + 设计文档互链

+

即本文档:按项目「设计/计划双产物」规范,把 docs/superpowers/plans/2026-07-13-notifications.md(执行真相源,含 checkbox)生成同内容 HTML 阅读版,登记进 docs/index.html「实现计划 / Plans」分类,并把 notifications-design.html 顶部「配套实现计划」链接改为直链本文档。

+
新建 docs/notifications-plan.html · docs/index.html、docs/notifications-design.html
+
    +
  • 验收:三文件登记完整、互链可点;.md 保持执行真相源不变(供 subagent-driven-development 继续驱动 Task 1–10)。
  • +
+
+ +

验收(端到端)

+
    +
  • 后端:cd server && go test ./... 全绿;nodectl notice add --type news …GET /v1/notices(带 JWT)可见、红点计数对;revoke 后消失。
  • +
  • 事件:TG 领奖/邀请注册/首充/购买各触发一次 → 对应用户列表出现 reward 通知,与权益同事务。
  • +
  • 邮件:important + --email → active 用户收信一轮,email_sent_at 置位,重复 add 是新公告不误判。
  • +
  • 客户端:铃铛红点=unread_count 驱动;进通知页红点清零(另一设备登录同账号也清);version 行可跳更新;六语标签;空态正常。
  • +
  • 部署:迁移 000026 + server + nodectl 上 pangolin1;发一条真实 news 公告真机验证。
  • +
+ +

不在本轮(YAGNI)

+
    +
  • 不做 APNs/FCM 推送、逐条已读、通知偏好开关、管理后台页、六语内容、静态镜像 JSON 副本、邮件队列。
  • +
+ +

本页为阅读版,不含逐步 TDD 代码细节;完整测试代码/实现片段见执行真相源 docs/superpowers/plans/2026-07-13-notifications.md

+ +
+ + diff --git a/docs/pay-v2-dev-log.md b/docs/pay-v2-dev-log.md new file mode 100644 index 0000000..f3341db --- /dev/null +++ b/docs/pay-v2-dev-log.md @@ -0,0 +1,54 @@ +# pay v2 支付+订单 — 自动开发决策日志 + +> 2026-07-11 起,自动模式落地。用户明早 double check。每个决断记这里。 + +## 背景 +用户指令:把今天设计的支付/订单功能全部落成真实代码(Flutter + Go),前后端测试,增量提交,自行决断,留记录。 + +## 最终 UX 模型(prototype 已定稿) +- 购买页一个**支付渠道 segswitch**:默认 **USDT→加密货币**,可切 **人民币→支付宝**。渠道=币种=方式,一步定,无弹层。 +- 套餐价随渠道切换(一口价不换算):月 ¥29.99/$4.99 · 季 ¥68.88/$12.99 · 年 ¥199.99/$34.99。 +- 买完直达支付页:crypto=USDT 地址+精确金额;alipay=桌面二维码/移动拉起 App。 +- 订单:列表→详情,状态 pending/paid/settled/expired。 +- nezha(哪吒)已从购买流程移除。 + +## 决策日志 +- **[step0]** prototype 支付渠道 switch 收口(3 屏 + `.segswitch` 原子入真相源+登记)。→ 提交后进开发。 + +## [step1] prototype 支付渠道 switch 收口(2026-07-11) +- `.segswitch` 原子入 atoms.css + 登记 index.html;purchase.html/ui-mobile/ui-desktop 三屏改成购买页渠道 switch(默认 USDT→crypto / 人民币→alipay),删两步弹层 + nezha。 +- 闸全绿:check-ds 5 项 / check-l1-sync(58 图标 + 190 l10n×6)。 + +## [step2] 服务端 pay orders + 分币种 catalog(2026-07-11) +- catalog.go:`CatalogItem.PriceUsdtMicro`(USDT 展示价,一口价)+ `SettlementCurrency`/`DisplayAmountMinor` helper(crypto→USDT / 法币→CNY)。 +- store.go:`PurchaseRow.CreatedAt`;`Insert` 增 amount/currency 参(下单落展示金额,webhook 覆盖实际);新增 `ListByUser`。 +- handler.go:新增 `List`(GET /v1/pay/orders,纯本地台账倒序)+ `orderView` 富字段;`GetOrder` 详情回带 sku/plan/method/amount/currency/created_at/paid_at;下单按方式落展示金额。 +- main.go:注册 `GET /v1/pay/orders`。 +- 测试:新增 List 越权/倒序/富字段、下单展示金额分币种、详情富字段;**修 3 个 pre-existing 迁移测试**(renumber 21→23 遗留)。`go test ./...` 全绿。 +- 决策:USDT 展示价放服务端 catalog($4.99/$12.99/$34.99),客户端 switch 直接读,不硬编码;实际扣款仍以 pay 侧为准。 + +## [step3] 客户端 Flutter 落地(2026-07-11) +- 探查结论:购买/支付主干已存在(Riverpod + payment_api + payment_page 多态渲染);缺订单列表/详情整链 + 购买页 segswitch/分币种。 +- **锁定共享契约(主 agent 手写,防并行冲突)**: + - models/payment.dart:`PayChannel{usdt,cny}`(.currency/.method);`PayCatalogItem.priceUsdtMicro` + `priceLabel(ch)`/`perMonthLabel(ch)`/`months`(**priceLabel 改带参**);新 `PayOrderSummary`;`PayOrderStatus.summary` 富字段。 + - payment_api.dart:`listOrders()`。 + - l10n:+19 getter(支付渠道 switch 4 + 订单 15)×6 语言,l10n 闸绿(209×6)。 + - StatusPill 复用(created→connecting/等待付款、paid→connected/已开通、canceled→neutral/已取消)。 +- **两 agent 并行建屏**:A=SegSwitch widget + purchase_page 重构(渠道 switch 置顶、分币种价、删底部弹窗) + payment_page 订单卡;B=orders_provider + orders_page(列表+详情) + 导航(NavView.orders + desktop_shell + account 入口)。 +- 决策:USDT 展示价由服务端 catalog 下发(不客户端硬编码);「继续支付」MVP 走购买页重下单(retry-resume 列为后续)。 + +## [step4] 集成 + 测试 + 提交(2026-07-12) +- 整体 `flutter analyze` 0 error(3 个 pre-existing info:withOpacity×2 + unused import)。 +- `flutter test` 226 全绿:修 priceLabel 带参、假 API 补 listOrders、购买页测改成默认 USDT+切 CNY 断言、新增订单列表/空态/详情测试、golden 重录(icon 迁移+账户订单入口+连接键 loader)。 +- 服务端 `go test ./...` 全绿。 +- l10n 19 getter ×6 语言补 @override,check-l1-sync 绿(209×6)。check-ds 绿。 +- **提交**:a5adbf3 server / f6ce526 design / 272eca0 client。→ push + draft PR。 +- 遗留(非阻塞,明日可议):订单「继续支付」走重新下单(非 retry-resume);订单无 expired 本地态;`local-l13-start.sh` 是本地联调脚本(含 throwaway 密钥)不入 git。 + +## [step5] 账户 IA 重构落地 + 订单过期态(2026-07-12,SDD 执行) +- 计划 `docs/superpowers/plans/2026-07-12-account-ia-reorg.md`,9 任务 SDD(每任务实现+审查+修)全过。 +- 后端(Task 0):订单展示层过期态(created 超 30min TTL → orderView.Status=expired,零迁移),go test 绿。 +- 前端(Task 1-7.5):NavView 加 invite/notifications;l10n +21 getter×6;联系渠道单源+Redeem 删去哪买;账户页重排+邀请入口+删偏好卡+formFactor 门控+InviteScreen 占位;桌面侧栏加 Account 一级+顶栏通知铃铛+NotificationBell;通知占位屏+移动铃铛;设置页归连接/外观/关于三组+Web 下沉;订单页 expired pill。 +- 验收:flutter analyze 0 error;flutter test 232 全绿(7 desktop golden 因 IA 改壳重录);check-ds/check-l1-sync(59图标+230×6 l10n)绿;server go test 绿。 +- 待:server 重编部署 pangolin1(Task0 生效)+ 终审 + Windows 编译测试。 +- 遗留(终审triage):ContactChannel 死 class / chEmail·buyTitle·buySub 孤立 l10n / _SectionLabel 双份 —— 均 Minor。 diff --git a/docs/superpowers/plans/2026-07-12-account-ia-reorg.md b/docs/superpowers/plans/2026-07-12-account-ia-reorg.md new file mode 100644 index 0000000..c1f9780 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-account-ia-reorg.md @@ -0,0 +1,528 @@ +# 账户/设置/联系 信息架构重构 — Flutter 实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 把已评审的 IA(`docs/account-ia-reorg-design.html`)落成 Flutter:账户/设置/联系去重归位,Account 提为桌面一级项,顶栏加通知铃铛,账户页加邀请入口,并为 Spec ②推广激励 / ③系统通知 留占位屏与接口。 + +**Architecture:** 复用现有 Riverpod + shell(desktop/mobile/tablet) + `open(NavView, mobilePage)` 下钻模式。多数是改造现有页面(account_page/settings_page/account_screens)+ 导航(navigation_provider/desktop_shell);新建仅 2 个占位屏(InviteScreen/NotificationsScreen)+ 铃铛 widget。**唯一后端改动 = 订单过期态(Task 0,display-only 零迁移)**——付款/订单核心后端已完成并部署生产。邀请奖励引擎、通知下发机制**不在本计划**(各自 Spec ②③,前后端一起)。 + +**Tech Stack:** Flutter / Dart · flutter_riverpod 2.x · 现有 `PangolinIcons`(生成,勿手改) · `AppText` 抽象基类 + 6 语言 strings。 + +## Global Constraints + +- **l10n 完整性闸**:`app_text.dart` 每加一个抽象 getter,必须在 6 份 `strings_{zh,en,ja,ko,ru,es}.dart` 全部实现,否则 `node tools/check-l1-sync.mjs` red。默认语言英文。 +- **图标单源**:`client/lib/widgets/pangolin_icons.dart` 是生成文件**勿手改**;真源 `design/prototype/icons.js`。本计划所需图标(bell/gift/crown/ticket/monitorSmartphone/users/settings/messageCircle)均已在。 +- **颜色单源**:禁裸 `Color(0x)`/`Colors.x`,走 `context.pangolin` 语义色;例外加 `// ds-ignore: 理由`。 +- **验收**:每任务后 `cd client && flutter analyze`(0 error) + 相关 `flutter test` 绿;改到有 golden 的屏随功能 `--update-goldens` 重录。 +- **占位屏边界**:InviteScreen/NotificationsScreen 本计划只做**静态占位 UI + 导航接线**;数据/奖励/下发逻辑属 Spec ②③,用 `// TODO(spec-2)` / `// TODO(spec-3)` 标注接口点。 + +--- + +### Task 0: 后端 — 订单过期态(display-only,零迁移) + +**Files:** +- Modify: `server/internal/pay/handler.go`(`orderViewFromRow` + `orderPendingTTL` 常量) +- Test: `server/internal/pay/handler_test.go` + +**Interfaces:** +- Produces: 订单视图 `status` 新增可能值 `"expired"`(供前端映射)。判定:本地台账 `status=="created"` 且 `created_at + orderPendingTTL < now` → 视图显 `expired`(DB 不改,与金额兜底同为展示层)。 + +- [ ] **Step 1: 写过期判定测试(失败)** + +在 `handler_test.go` 加: +```go +// 老 created 单超 TTL → 列表视图显 expired;近期 created → 仍 created。 +func TestList_ExpiredWhenStale(t *testing.T) { + router, st := newHandlerRig(t, nil) + ctx := context.Background() + // 直接构造两条 created 单,改 created_at 到很久以前 / 刚刚 + if err := st.Insert(ctx, 1, "uuid-1", "pro_month", "old", "alipay", 2999, "CNY"); err != nil { t.Fatal(err) } + if err := st.Insert(ctx, 1, "uuid-1", "pro_month", "fresh", "alipay", 2999, "CNY"); err != nil { t.Fatal(err) } + // 把 old 的 created_at 回拨到 TTL 之前 + if _, err := st.db.ExecContext(ctx, + `UPDATE pay_purchases SET created_at = ? WHERE out_trade_no = 'old'`, + time.Now().Add(-2*orderPendingTTL).UTC()); err != nil { t.Fatal(err) } + w := httptest.NewRecorder() + router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodGet, "/v1/pay/orders", nil), 1)) + var resp struct{ Orders []orderView `json:"orders"` } + _ = json.Unmarshal(w.Body.Bytes(), &resp) + got := map[string]string{} + for _, o := range resp.Orders { got[o.OrderNo] = o.Status } + if got["old"] != "expired" { t.Fatalf("old 应过期, got %q", got["old"]) } + if got["fresh"] != "created" { t.Fatalf("fresh 应 created, got %q", got["fresh"]) } +} +``` +> 注:`st.db` 是 `Store` 的私有字段,测试同包(`package pay`)可访问。 + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd server && go test ./internal/pay/ -run TestList_ExpiredWhenStale` +Expected: FAIL(old 仍为 created)。 + +- [ ] **Step 3: 实现过期判定** + +`handler.go` 加常量 + 在 `orderViewFromRow` 里判定: +```go +// 未付订单展示层过期窗口(近似 pay 会话时效;仅影响列表/详情显示,不改台账)。 +const orderPendingTTL = 30 * time.Minute +``` +在 `orderViewFromRow` 组装 `v` 后、`return v` 前加: +```go + if row.Status == "created" && row.CreatedAt.Add(orderPendingTTL).Before(time.Now()) { + v.Status = "expired" // 展示层:台账仍 created,可继续支付/取消 + } +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd server && go test ./internal/pay/` +Expected: ok(含新测试 + 原有全绿)。 + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/pay/handler.go server/internal/pay/handler_test.go +git commit -m "feat(server/pay): 订单展示层过期态(created 超 TTL 显 expired,零迁移)" +``` + +> **部署**:本任务改 server,需重编 linux 二进制 + 部署 pangolin1(Task 8 统一部署,或单独发)。 + +--- + +### Task 1: 导航枚举加 invite / notifications + +**Files:** +- Modify: `client/lib/state/navigation_provider.dart:7,10` + +**Interfaces:** +- Produces: `NavView.invite`、`NavView.notifications` 枚举值;`kAccountSubViews` 含 `invite`。 + +- [ ] **Step 1: 加枚举值 + 子页登记** + +`navigation_provider.dart` 第 7 行 `enum NavView` 末尾加 `, invite, notifications`: +```dart +enum NavView { connect, servers, stats, account, contact, settings, plans, redeem, devices, purchase, payment, orders, invite, notifications } +``` +第 10 行 `kAccountSubViews` 加 `invite`(notifications 走全屏,不入子页返回集): +```dart +const Set kAccountSubViews = {NavView.plans, NavView.redeem, NavView.devices, NavView.purchase, NavView.payment, NavView.orders, NavView.invite}; +``` + +- [ ] **Step 2: 编译校验** + +Run: `cd client && flutter analyze lib/state/navigation_provider.dart` +Expected: 出现「missing case NavView.invite/notifications」类 error(desktop_shell 的穷举 switch 未覆盖)——**预期**,Task 5 修复。仅确认本文件无语法 error。 + +- [ ] **Step 3: Commit** + +```bash +git add client/lib/state/navigation_provider.dart +git commit -m "feat(nav): NavView 加 invite/notifications + kAccountSubViews 收录 invite" +``` + +--- + +### Task 2: l10n — 邀请 + 通知 + IA 归组文案 + +**Files:** +- Modify: `client/lib/l10n/app_text.dart`(抽象 getter) +- Modify: `client/lib/l10n/strings_{zh,en,ja,ko,ru,es}.dart`(6 份实现) + +**Interfaces:** +- Produces getter(供后续任务用):`inviteEntry, inviteEntrySub, inviteTitle, inviteCodeLabel, inviteLinkLabel, inviteCopyCode, inviteCopyLink, inviteRewardsTitle, inviteRewardRegister, inviteRewardFirstBuy, inviteRewardJoinTg, inviteProgressTitle`;`notifTitle, notifEmpty, notifTypeNews, notifTypeFeature, notifTypeImportant`;`settingsGroupConn, settingsGroupAppearance, settingsGroupAbout`。 + +- [ ] **Step 1: app_text.dart 加抽象 getter** + +在 `app_text.dart` 的「购买/支付」段后加一段(照现有注释风格): +```dart + // ── 邀请好友(Spec ②占位) ── + String get inviteEntry; // 邀请好友得会员 / Invite friends, earn membership + String get inviteEntrySub; // 好友注册 · 双方都得会员天数 / Friend signs up · both earn days + String get inviteTitle; // 邀请好友 / Invite friends + String get inviteCodeLabel; // 邀请码 / Invite code + String get inviteLinkLabel; // 邀请链接 / Invite link + String get inviteCopyCode; // 复制邀请码 / Copy code + String get inviteCopyLink; // 复制链接 / Copy link + String get inviteRewardsTitle; // 奖励规则 / Rewards + String get inviteRewardRegister; // 好友注册 · 双方各得 3 天 / Sign up · +3 days each + String get inviteRewardFirstBuy; // 好友首购 · 双方各得 15 天 / First purchase · +15 days each + String get inviteRewardJoinTg; // 加入 TG 频道 · 得 3 天 / Join TG · +3 days + String get inviteProgressTitle; // 我的奖励 / My rewards + // ── 通知(Spec ③占位) ── + String get notifTitle; // 通知 / Notifications + String get notifEmpty; // 暂无通知 / No notifications + String get notifTypeNews; // 新闻 / News + String get notifTypeFeature; // 功能 / Feature + String get notifTypeImportant; // 重要 / Important + // ── 设置分组 ── + String get settingsGroupConn; // 连接 / Connection + String get settingsGroupAppearance;// 外观 / Appearance + String get settingsGroupAbout; // 关于 / About + // ── 订单过期态(Task 0 后端产出) ── + String get orderStatusExpired; // 已过期 / Expired +``` + +- [ ] **Step 2: 6 份 strings 各实现(带 @override)** + +每份 `strings_.dart` 在 `qrNotSupported` 之后加对应 20 个 `@override String get X => '<译文>';`。译文(zh 见注释;en 见注释;ja/ko/ru/es 按语义翻译,与已有条目风格一致)。 + +- [ ] **Step 3: 跑 l10n 闸** + +Run: `cd /Users/wangjia/code/pangolin/.claude/worktrees/pay-v2-integration && node tools/check-l1-sync.mjs` +Expected: `✓ l10n 完整 OK —— NNN 个 getter × 6 语言全实现`(getter 数 +20)。 + +- [ ] **Step 4: analyze** + +Run: `cd client && flutter analyze lib/l10n/` +Expected: No issues found(仅 annotate_overrides 若漏 @override 会 info,补齐)。 + +- [ ] **Step 5: Commit** + +```bash +git add client/lib/l10n/ +git commit -m "feat(l10n): 邀请/通知/设置分组文案 ×6 语言(Spec ②③占位)" +``` + +--- + +### Task 3: 联系渠道单源 + Redeem 删「去哪买」 + +**Files:** +- Create: `client/lib/services/contact_channels.dart` +- Modify: `client/lib/screens/contact_page.dart:22-26` +- Modify: `client/lib/widgets/account_screens.dart:377-381,420-425,461-465` + +**Interfaces:** +- Produces: `const List kContactChannels`(single source);`class ContactChannel { IconData icon; String name; String sub; bool accent; }`。 + +- [ ] **Step 1: 建单源常量** + +`contact_channels.dart`: +```dart +import 'package:flutter/widgets.dart'; +import '../widgets/pangolin_icons.dart'; + +class ContactChannel { + const ContactChannel({required this.icon, required this.name, required this.sub, this.accent = false}); + final IconData icon; + final String name; // 展示名(Telegram 用字面量;邮箱/官网用 l10n,在页面侧传入) + final String sub; // handle:@x / a@b / domain(launchChannel 据此推导 URL) + final bool accent; +} + +// name 用 key 占位,页面按 t 映射;Telegram 固定字面量 +const List<({IconData icon, String nameKey, String sub, bool accent})> kContactChannels = [ + (icon: PangolinIcons.send, nameKey: 'telegram', sub: '@pangolin_app', accent: true), + (icon: PangolinIcons.mail, nameKey: 'email', sub: 'support@yanmeiai.com', accent: false), + (icon: PangolinIcons.globe, nameKey: 'website', sub: 'pangolin.yanmeiai.com', accent: false), +]; +``` +> 说明:`nameKey` 在页面侧映射(`telegram`→字面量 'Telegram',`email`→`t.contactEmail`,`website`→`t.contactWebsite`),避免把 AppText 塞进常量。 + +- [ ] **Step 2: contact_page.dart 用单源** + +`contact_page.dart:22-26` 的三元素内联 list 换成 `kContactChannels.map(...)`,name 按 nameKey 映射。 + +- [ ] **Step 3: ContactScreen 用单源** + +`account_screens.dart:461-465`(ContactScreen 的 channels)同样换成 `kContactChannels`。 + +- [ ] **Step 4: RedeemScreen 删「去哪买」** + +`account_screens.dart`:删 `channels`(377-381)、删 build 里「去哪买」引流卡(`buyTitle`/`buySub` + channel 列表,420-425)、删 `_channel` 方法(430+)。RedeemScreen 只保留兑换码输入(386-419)。 + +- [ ] **Step 5: analyze + 手测** + +Run: `cd client && flutter analyze lib/screens/contact_page.dart lib/widgets/account_screens.dart lib/services/contact_channels.dart` +Expected: No issues found。 + +- [ ] **Step 6: Commit** + +```bash +git add client/lib/services/contact_channels.dart client/lib/screens/contact_page.dart client/lib/widgets/account_screens.dart +git commit -m "refactor(contact): 渠道抽单源常量 + RedeemScreen 删去哪买引流卡(仅留兑换码)" +``` + +--- + +### Task 4: 账户页重排 + 邀请入口 + 删偏好卡 + formFactor 门控 + +**Files:** +- Modify: `client/lib/screens/account_page.dart:52-141,381,423` +- Create: `client/lib/screens/invite_page.dart` +- Test: `client/test/widget/account_ia_test.dart` + +**Interfaces:** +- Consumes: `NavView.invite`(Task 1)、`t.inviteEntry/inviteEntrySub`(Task 2)、`open(NavView, Widget)`(现有)。 +- Produces: `class InviteScreen extends ConsumerWidget { InviteScreen({required AppText t, VoidCallback? onBack, bool embedded}); }`(占位)。 + +- [ ] **Step 1: 建 InviteScreen 占位屏** + +`invite_page.dart`:`_SubScaffold`(复用 account_screens 的骨架不便跨文件,简单自建 Scaffold/embedded)标题 `t.inviteTitle`,内容 = 邀请码卡(可复制,值写死 `PANGOLIN-DEMO`)+ 链接卡 + 奖励规则列表(register/firstBuy/joinTg 三条,用 `t.inviteReward*`)+ 「我的奖励」占位卡。全走 `context.pangolin`/`PangolinIcons.gift`。顶部 `// TODO(spec-2): 邀请码/链接由后端签发,奖励进度来自 /v1/invite`。 + +- [ ] **Step 2: 写账户页 IA 结构测试(失败)** + +`account_ia_test.dart`:desktop 态(`debugDefaultTargetPlatformOverride=windows` + 宽 surface + provider override,参考 `test/widget/account_desktop_nav_test.dart`)pump `AccountPage(isWide:true)`,断言: +```dart +// 邀请入口存在 +expect(find.text(t.inviteEntry), findsOneWidget); +// 语言/主题不在账户页(移到 Settings) +expect(find.text(t.language), findsNothing); +// 兑换在账户页(购买&订单&兑换块) +expect(find.text(t.redeemEntry), findsOneWidget); +``` + +- [ ] **Step 3: 跑测试确认失败** + +Run: `cd client && flutter test test/widget/account_ia_test.dart` +Expected: FAIL(邀请入口不存在 / 语言仍在)。 + +- [ ] **Step 4: 改 account_page.dart** + +- 购买&订单&兑换块(现 80-94):把兑换码行(现在设备卡里的 `t.redeemEntry`)并入,成为 购买套餐 / 我的订单 / 兑换码 三行。 +- 其后加**邀请卡**:`_NavRow(icon: PangolinIcons.gift, title: t.inviteEntry, sub: t.inviteEntrySub, onTap: () => open(NavView.invite, InviteScreen(t: t)))`。 +- 设备/设置/兑换/联系卡(现 97-105):兑换移走(见上);设备保持下钻行;设置/联系两行包 `if (context.formFactor == FormFactor.mobile)`(桌面侧栏已有,不重复)。 +- **删语言/主题/协议卡**(108-130)+ `_LangSwitch`(381)+ `_PangolinSwitch`(423)。 +- import `invite_page.dart` + `core/responsive/form_factor.dart`。 + +- [ ] **Step 5: 跑测试确认通过** + +Run: `cd client && flutter test test/widget/account_ia_test.dart` +Expected: PASS。 + +- [ ] **Step 6: analyze + golden 重录(账户页有 golden 则录)** + +Run: `cd client && flutter analyze lib/screens/account_page.dart lib/screens/invite_page.dart && flutter test --update-goldens 2>&1 | tail -3` + +- [ ] **Step 7: Commit** + +```bash +git add client/lib/screens/account_page.dart client/lib/screens/invite_page.dart client/test/widget/account_ia_test.dart client/test/golden/ +git commit -m "feat(account): IA 重排+邀请入口+删偏好卡+设置/联系 formFactor 门控" +``` + +--- + +### Task 5: 桌面侧栏加 Account + 顶栏铃铛 + invite 视图接线 + +**Files:** +- Modify: `client/lib/shell/desktop_shell.dart:34-40,42-55,59-88,92-96,104` +- Modify: `client/lib/shell/tablet_shell.dart:39-46` +- Create: `client/lib/widgets/notification_bell.dart` +- Modify: `client/lib/widgets/content_top_bar.dart:44-66` + +**Interfaces:** +- Consumes: `NavView.invite/notifications`(Task 1)、`InviteScreen`(Task 4)、`NotificationsScreen`(Task 6,先前向引用——本任务铃铛 onTap 先 `go(NavView.notifications)`,Task 6 补屏)。 +- Produces: `class NotificationBell extends ConsumerWidget { NotificationBell({required VoidCallback onTap, bool hasUnread}); }`。 + +- [ ] **Step 1: 建 NotificationBell widget** + +`notification_bell.dart`:`IconButton`(`PangolinIcons.bell`) + 右上未读红点(`Positioned` 小圆 `context.pangolin.danger`,`hasUnread` 才显)。`hasUnread` 本任务先写死 `true`(演示),`// TODO(spec-3): 未读态来自 notices provider`。 + +- [ ] **Step 2: 侧栏加 Account 一级项** + +`desktop_shell.dart:34-40` `items` 在 stats 后 settings 前插: +```dart +(icon: PangolinIcons.user, label: t.tabMe, view: NavView.account), +``` + +- [ ] **Step 3: 顶栏挂铃铛** + +`content_top_bar.dart` 的 trailing 区(主题切换旁)加 `NotificationBell(hasUnread: true, onTap: onBell)`;`ContentTopBar` 加 `VoidCallback? onBell` 入参。`desktop_shell.dart:104` 传 `onBell: () => go(NavView.notifications)`。 + +- [ ] **Step 4: content()/titles 补 invite + notifications** + +`desktop_shell.dart` `titles`(42-55)加 `NavView.invite: t.inviteTitle, NavView.notifications: t.notifTitle`;`content()`(59-88)加 `case NavView.invite: return InviteScreen(t: t, embedded: true);` 和 `case NavView.notifications: return NotificationsScreen(t: t, embedded: true);`(NotificationsScreen 由 Task 6 建,本步先 import 占位——若 Task 6 未完成,临时 `return const SizedBox()` 并 `// TODO`)。`backTarget()`(92-96)靠 `kAccountSubViews` 已含 invite → 自动回 account;notifications 不在集内,返回走顶栏无返回(全屏态)——给 notifications 也返回 account:在 `backTarget` 加 `if (view == NavView.notifications) return () => go(NavView.account);`。 + +- [ ] **Step 5: tablet_shell titles 补一条** + +`tablet_shell.dart:39-46` `titles` 加 `NavView.invite: t.inviteTitle, NavView.notifications: t.notifTitle`(内容走 default→AccountPage/push)。 + +- [ ] **Step 6: analyze(全绿——穷举 switch 现已覆盖)** + +Run: `cd client && flutter analyze lib/shell/ lib/widgets/notification_bell.dart lib/widgets/content_top_bar.dart` +Expected: No issues found。 + +- [ ] **Step 7: Commit** + +```bash +git add client/lib/shell/desktop_shell.dart client/lib/shell/tablet_shell.dart client/lib/widgets/notification_bell.dart client/lib/widgets/content_top_bar.dart +git commit -m "feat(shell): 桌面侧栏加 Account 一级项 + 顶栏通知铃铛 + invite/notifications 视图接线" +``` + +--- + +### Task 6: 通知占位屏 + 移动顶栏铃铛 + +**Files:** +- Create: `client/lib/screens/notifications_page.dart` +- Modify: `client/lib/widgets/app_top_bar.dart`(trailing 铃铛) +- Modify: `client/lib/screens/account_page.dart:146`(AppTopBar 传 trailing) +- Modify: `client/lib/shell/desktop_shell.dart`(把 Task 5 的占位 import 换成真 NotificationsScreen) + +**Interfaces:** +- Consumes: `t.notifTitle/notifEmpty/notifType*`(Task 2)、`NotificationBell`(Task 5)、`open(NavView.notifications, ...)`。 +- Produces: `class NotificationsScreen extends ConsumerWidget { NotificationsScreen({required AppText t, VoidCallback? onBack, bool embedded}); }`。 + +- [ ] **Step 1: 建 NotificationsScreen 占位** + +`notifications_page.dart`:标题 `t.notifTitle`,内容 = 3 条示例通知(类型 pill 用 `t.notifType*`,未读点)+ 空态 `t.notifEmpty`(数据为空时)。`// TODO(spec-3): 列表来自 GET /v1/notices,已读态本地持久化`。 + +- [ ] **Step 2: 桌面 shell 换真屏** + +`desktop_shell.dart` `content()` 的 notifications case 从占位 `SizedBox` 换成 `NotificationsScreen(t: t, embedded: true)` + import。 + +- [ ] **Step 3: 移动顶栏挂铃铛** + +`app_top_bar.dart` 加 `Widget? trailing` 入参并渲染在右侧;`account_page.dart:146` `AppTopBar(brand: t.brand, trailing: NotificationBell(hasUnread: true, onTap: () => open(NavView.notifications, NotificationsScreen(t: t))))`。 + +- [ ] **Step 4: analyze** + +Run: `cd client && flutter analyze lib/screens/notifications_page.dart lib/widgets/app_top_bar.dart lib/screens/account_page.dart lib/shell/desktop_shell.dart` +Expected: No issues found。 + +- [ ] **Step 5: Commit** + +```bash +git add client/lib/screens/notifications_page.dart client/lib/widgets/app_top_bar.dart client/lib/screens/account_page.dart client/lib/shell/desktop_shell.dart +git commit -m "feat(notif): 通知占位屏 + 移动顶栏铃铛(Spec ③接口占位)" +``` + +--- + +### Task 7: 设置页归三组 + Web 链接下沉 + +**Files:** +- Modify: `client/lib/screens/settings_page.dart:20-22,54-112` +- Test: `client/test/widget/settings_ia_test.dart` + +**Interfaces:** +- Consumes: `t.settingsGroupConn/Appearance/About`(Task 2)。 + +- [ ] **Step 1: 写设置分组测试(失败)** + +`settings_ia_test.dart`:pump `SettingsPage`(provider override),断言: +```dart +expect(find.text(t.settingsGroupConn), findsOneWidget); +expect(find.text(t.settingsGroupAppearance), findsOneWidget); +expect(find.text(t.settingsGroupAbout), findsOneWidget); +// 购买入口不在设置页(归 Account) +expect(find.text(t.purchaseTitle), findsNothing); +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd client && flutter test test/widget/settings_ia_test.dart` +Expected: FAIL。 + +- [ ] **Step 3: 改 settings_page.dart** + +- 删第一张「购买套餐 + 我的订单」卡(75-89)+ 顶部 `orders_page/payment_page/purchase_page` import(20-22)+ `open()` helper(61-67)。 +- 三张卡分组:① `_SectionLabel(t.settingsGroupConn)` + 开关组(autostart/autoConnect/smartRoute/killSwitch);② `_SectionLabel(t.settingsGroupAppearance)` + 语言 + 深色;③ `_SectionLabel(t.settingsGroupAbout)` + 协议 + 检查更新 + Version + **底部** webUserCenter 行(降到最后)。 + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd client && flutter test test/widget/settings_ia_test.dart` +Expected: PASS。 + +- [ ] **Step 5: analyze + golden** + +Run: `cd client && flutter analyze lib/screens/settings_page.dart && flutter test --update-goldens 2>&1 | tail -3` + +- [ ] **Step 6: Commit** + +```bash +git add client/lib/screens/settings_page.dart client/test/widget/settings_ia_test.dart client/test/golden/ +git commit -m "feat(settings): 删购买/订单卡 + 归连接/外观/关于三组 + Web 链接下沉底部" +``` + +--- + +### Task 7.5: 订单页处理 expired 态 + +**Files:** +- Modify: `client/lib/screens/orders_page.dart`(状态→pill 映射) + +**Interfaces:** +- Consumes: 后端 `status=="expired"`(Task 0)、`t.orderStatusExpired`(Task 2)。 + +- [ ] **Step 1: orders_page 状态映射加 expired** + +`orders_page.dart` 的 `_statusPill`(created→connecting / paid→connected / canceled→neutral)加一支: +```dart +'expired' => (status: PangolinStatus.neutral, label: t.orderStatusExpired), +``` +过期单动作沿用 created 的「继续支付」(台账仍可 retry;retry 若遇 pay 会话过期会 409→客户端已有重新下单兜底)。 + +- [ ] **Step 2: analyze** + +Run: `cd client && flutter analyze lib/screens/orders_page.dart` +Expected: No issues found。 + +- [ ] **Step 3: Commit** + +```bash +git add client/lib/screens/orders_page.dart +git commit -m "feat(client/orders): 订单列表/详情显示 expired 过期态" +``` + +--- + +### Task 8: 全量验收 + 收尾 + +**Files:** 无(验收) + +- [ ] **Step 1: 全量 analyze** + +Run: `cd client && flutter analyze` +Expected: 0 error(仅 pre-existing 3 条 info)。 + +- [ ] **Step 2: 全量测试** + +Run: `cd client && flutter test 2>&1 | tail -3` +Expected: All tests passed。 + +- [ ] **Step 3: DS 双闸** + +Run: `cd /Users/wangjia/code/pangolin/.claude/worktrees/pay-v2-integration && node tools/check-l1-sync.mjs && node design/prototype/tools/check-ds.mjs` +Expected: 图标同集 OK + l10n 完整 OK + check-ds 5 项过。 + +- [ ] **Step 4: 服务端重编 + 部署(Task 0 改了 server)** + +```bash +cd server && go test ./... && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -o /tmp/pgl-new ./cmd/server +scp /tmp/pgl-new pangolin1:/tmp/pgl-new +ssh pangolin1 'cp -a /usr/local/bin/pangolin-server /usr/local/bin/pangolin-server.bak-ia && mv /tmp/pgl-new /usr/local/bin/pangolin-server && chmod +x /usr/local/bin/pangolin-server && systemctl restart pangolin-server && sleep 2 && systemctl is-active pangolin-server' +``` +Expected: `active`。验证 `curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8080/v1/pay/orders` → 401(路由在)。 + +- [ ] **Step 5: 更新决策日志** + +`docs/pay-v2-dev-log.md` 追加「IA 重构落地 + 订单过期态」step。 + +- [ ] **Step 6: Commit** + +```bash +git add docs/pay-v2-dev-log.md +git commit -m "docs(pay-v2): IA 重构 + 订单过期态 落地验收记录" +``` + +--- + +## Self-Review + +**Spec coverage**(对照 `account-ia-reorg-design.html` §5 落地改动清单): +- navigation_provider 加 invite ✅ Task 1 +- desktop_shell 侧栏 Account + 顶栏铃铛 + content 分支 ✅ Task 5 +- account_page 重排/邀请/删偏好卡/formFactor 门控 ✅ Task 4 +- settings_page 归组 + Web 下沉 ✅ Task 7 +- account_screens RedeemScreen 删去哪买 ✅ Task 3 +- 顶栏铃铛槽(content_top_bar + app_top_bar)✅ Task 5/6 +- 邀请/通知占位屏 ✅ Task 4/6 +- l10n ✅ Task 2 + +**已就绪不做**(盘点确认):设备⋮菜单、登录/注册、SegSwitch、图标、支付/订单 l10n。 + +**留给后续 spec**:邀请奖励引擎/归因/防刷(Spec ②)、通知 model/provider/api/下发/已读持久化(Spec ③)——本计划仅占位屏 + `// TODO(spec-N)` 接口标注。 + +**依赖顺序**:1→2→(3,4 可并)→5→6→7→8。Task 5 前向引用 NotificationsScreen(Task 6),已注明临时占位处理。 diff --git a/docs/superpowers/plans/2026-07-12-invite-task-rewards.md b/docs/superpowers/plans/2026-07-12-invite-task-rewards.md new file mode 100644 index 0000000..5d84a6d --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-invite-task-rewards.md @@ -0,0 +1,1831 @@ +# 邀请奖励 + 奖励任务(加入 TG 频道)Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 用「送 Pro 会员天数」驱动增长——邀请两段式(注册双方各 +3、被邀请人首充双方各 +7)+ 奖励任务(加入 TG 频道 +3,Bot 真校验),全部复用现有 subscriptions 发天数链路。 + +**Architecture:** 新增 `server/internal/reward` 包承载发奖 + 防刷;发天数复用 `codes.Service`(新增 `GrantRewardTx`)。注册段在 `auth.Service.Register` 后best-effort触发(自有 tx);首充段挂 `pay/webhook.settle` 同事务内;TG 走 `POST /tg/webhook`(getChatMember 真校验)。客户端 invite_page 由占位转真实(邀请区 + 任务区),注册页加邀请码输入。 + +**Tech Stack:** Go(chi + 裸 SQL + golang-migrate 双 DB)、Redis(token)、Flutter/Riverpod、Telegram Bot API。 + +## Global Constraints + +- 发天数一律复用 `codes.Service`(Pro plan);**迁移须把 `subscriptions.source` CHECK/ENUM 从 `('trial','code','pay')` 扩到 `('trial','code','pay','invite','task')`**(sqlite 重建表 / mysql `MODIFY`)。 +- 奖励天数:注册段各 **3**、首充段各 **7**、TG 任务 **3**;注册段邀请人**月度封顶默认 10**(env `INVITE_REG_MONTHLY_CAP`,`getenvDefault` 缺省 "10")。 +- 审计一律走 `audit_log`(`store.WriteAuditLog(ctx, tx, actor, action, target, meta)`),**无 sub_events 表**。 +- 多 DB:`server/migrations/{mysql,sqlite}/` 两套文件**一一对应**;裸 SQL + `internal/db` dialect 层;时间一律 Go 端 `time.Now().UTC()` 传 `?`,**禁** `NOW()`/`UTC_TIMESTAMP()`。 +- 邀请码 = **8 位 base32 大写、去 `0/O/1/I`**;`users.invite_code` UNIQUE、惰性生成。 +- 绑定**仅注册时**、first-touch 永久不可改。防刷:自邀请拦截(`inviter_id≠invitee_id`)+ `referrals.invitee_id` UNIQUE + 设备去重(`devices.uuid == DeviceMeta.DeviceID` 已属别的用户则注册奖励不发、记 `rejected`)+ 注册段月度封顶 + `reward_claims` 双唯一 `(user_id,task_key)`/`(task_key,external_ref)`。 +- TG:`getChatMember` 返回 `member`/`administrator`/`creator` 视为已加入;bot 未配(`TG_REWARD_BOT_TOKEN` 空)则 `/tg/webhook` 返回 404 且 App 任务卡隐藏。webhook 校验 `X-Telegram-Bot-Api-Secret-Token == TG_WEBHOOK_SECRET`。 +- 客户端 l10n **无 codegen**:新 string 须 `app_text.dart` 加抽象 getter + 6 个 `strings_{zh,en,es,ja,ko,ru}.dart` 各加实现。 +- **deep-link 自动预填 = 可选后置任务(Task 13)**;MVP 走注册页手填邀请码,邀请链接指向网页落地页(`url_launcher` 已在)。 +- TG 新基建(reward bot / 设为频道管理员 / `setWebhook`)由用户在实现前于 Telegram 侧配好。 +- 命令:后端 `cd server && go test ./...`;客户端 `cd client && flutter analyze && flutter test`。 + +--- + +## File Structure + +- `server/migrations/{mysql,sqlite}/000024_invite_rewards.{up,down}.sql` — 新表 + source 扩容(**新建**)。 +- `server/internal/codes/paygrant.go` — 加 `GrantRewardTx`(**改**)。 +- `server/internal/reward/store.go` — referrals / reward_claims / invite_code / first_paid_at 的 SQL(**新建**)。 +- `server/internal/reward/service.go` — 发奖 + 防刷 + 邀请码生成/解析 + summary(**新建**)。 +- `server/internal/reward/telegram.go` — token 签发 + getChatMember 客户端 + TG 领取(**新建**)。 +- `server/internal/reward/handler.go` — `GET /v1/invite`、`GET /v1/tasks/telegram/start`、`POST /tg/webhook`(**新建**)。 +- `server/internal/auth/service.go` / `handler.go` — Register 加 inviteCode + setter(**改**)。 +- `server/internal/pay/webhook.go` — settle 首充钩子(**改**)。 +- `server/cmd/server/main.go` — 装配 reward svc + 注入 + 路由 + env(**改**)。 +- `client/lib/services/invite_api.dart` / `client/lib/state/invite_provider.dart` — api + provider(**新建**)。 +- `client/lib/screens/invite_page.dart` — 占位转真实(**改**)。 +- `client/lib/widgets/auth_screen.dart` + `client/lib/services/auth_api.dart` — 邀请码输入(**改**)。 +- `client/lib/l10n/app_text.dart` + `strings_*.dart` ×6 — 新文案(**改**)。 + +--- + +### Task 1: 迁移 000024 — 新表 + source 扩容 + +**Files:** +- Create: `server/migrations/sqlite/000024_invite_rewards.up.sql` / `.down.sql` +- Create: `server/migrations/mysql/000024_invite_rewards.up.sql` / `.down.sql` +- Test: `server/internal/store/migrate_sqlite_test.go`(现有 up/down 测试;新增断言) + +**Interfaces:** +- Produces: 表 `referrals`、`reward_claims`;`users.invite_code`、`users.first_paid_at`;`subscriptions.source` 允许 `'invite'`/`'task'`。 + +- [ ] **Step 1: 写 sqlite up 迁移** `server/migrations/sqlite/000024_invite_rewards.up.sql` + +```sql +-- users 加两列 +ALTER TABLE users ADD COLUMN invite_code TEXT; +ALTER TABLE users ADD COLUMN first_paid_at DATETIME; +CREATE UNIQUE INDEX ux_users_invite_code ON users(invite_code); + +-- 邀请关系(一对一绑定) +CREATE TABLE referrals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + inviter_id INTEGER NOT NULL, + invitee_id INTEGER NOT NULL UNIQUE, + device_uuid TEXT, + status TEXT NOT NULL DEFAULT 'bound' + CHECK (status IN ('bound','reg_rewarded','paid_rewarded','rejected')), + reg_rewarded_at DATETIME, + paid_rewarded_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (inviter_id) REFERENCES users(id), + FOREIGN KEY (invitee_id) REFERENCES users(id) +); +CREATE INDEX ix_referrals_inviter ON referrals(inviter_id, created_at); + +-- 通用一次性任务领取 +CREATE TABLE reward_claims ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + task_key TEXT NOT NULL, + external_ref TEXT NOT NULL DEFAULT '', + granted_days INTEGER NOT NULL, + granted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) +); +CREATE UNIQUE INDEX ux_claim_user_task ON reward_claims(user_id, task_key); +CREATE UNIQUE INDEX ux_claim_task_ref ON reward_claims(task_key, external_ref); + +-- subscriptions.source 扩容(SQLite 需重建表) +CREATE TABLE subscriptions_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + plan_id INTEGER NOT NULL, + expires_at DATETIME NOT NULL, + source TEXT NOT NULL CHECK (source IN ('trial','code','pay','invite','task')), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (plan_id) REFERENCES plans(id) +); +INSERT INTO subscriptions_new (id,user_id,plan_id,expires_at,source,created_at) + SELECT id,user_id,plan_id,expires_at,source,created_at FROM subscriptions; +DROP TABLE subscriptions; +ALTER TABLE subscriptions_new RENAME TO subscriptions; +``` + +- [ ] **Step 2: 写 sqlite down 迁移** `server/migrations/sqlite/000024_invite_rewards.down.sql` + +```sql +-- 还原 subscriptions.source CHECK +CREATE TABLE subscriptions_old ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + plan_id INTEGER NOT NULL, + expires_at DATETIME NOT NULL, + source TEXT NOT NULL CHECK (source IN ('trial','code','pay')), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (plan_id) REFERENCES plans(id) +); +INSERT INTO subscriptions_old SELECT id,user_id,plan_id,expires_at,source,created_at + FROM subscriptions WHERE source IN ('trial','code','pay'); +DROP TABLE subscriptions; +ALTER TABLE subscriptions_old RENAME TO subscriptions; + +DROP TABLE reward_claims; +DROP TABLE referrals; +DROP INDEX ux_users_invite_code; +ALTER TABLE users DROP COLUMN first_paid_at; +ALTER TABLE users DROP COLUMN invite_code; +``` + +- [ ] **Step 3: 写 mysql up 迁移** `server/migrations/mysql/000024_invite_rewards.up.sql` + +```sql +ALTER TABLE users + ADD COLUMN invite_code VARCHAR(16) NULL, + ADD COLUMN first_paid_at DATETIME(6) NULL, + ADD UNIQUE KEY ux_users_invite_code (invite_code); + +CREATE TABLE referrals ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + inviter_id BIGINT UNSIGNED NOT NULL, + invitee_id BIGINT UNSIGNED NOT NULL UNIQUE, + device_uuid VARCHAR(64) NULL, + status ENUM('bound','reg_rewarded','paid_rewarded','rejected') NOT NULL DEFAULT 'bound', + reg_rewarded_at DATETIME(6) NULL, + paid_rewarded_at DATETIME(6) NULL, + created_at DATETIME(6) NOT NULL, + INDEX ix_referrals_inviter (inviter_id, created_at), + FOREIGN KEY (inviter_id) REFERENCES users(id), + FOREIGN KEY (invitee_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE reward_claims ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + task_key VARCHAR(32) NOT NULL, + external_ref VARCHAR(64) NOT NULL DEFAULT '', + granted_days INT NOT NULL, + granted_at DATETIME(6) NOT NULL, + UNIQUE KEY ux_claim_user_task (user_id, task_key), + UNIQUE KEY ux_claim_task_ref (task_key, external_ref), + FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +ALTER TABLE subscriptions MODIFY source ENUM('trial','code','pay','invite','task') NOT NULL; +``` + +- [ ] **Step 4: 写 mysql down 迁移** `server/migrations/mysql/000024_invite_rewards.down.sql` + +```sql +ALTER TABLE subscriptions MODIFY source ENUM('trial','code','pay') NOT NULL; +DROP TABLE reward_claims; +DROP TABLE referrals; +ALTER TABLE users + DROP INDEX ux_users_invite_code, + DROP COLUMN first_paid_at, + DROP COLUMN invite_code; +``` + +- [ ] **Step 5: 跑迁移 up/down 测试** + +Run: `cd server && bash run_sqlite_test.sh` +Expected: PASS(现有 `TestSQLiteMigrateUpDown` 全量 up→down→up 无错;若该测试对表数量有断言,更新计数)。 + +- [ ] **Step 6: Commit** + +```bash +git add server/migrations/sqlite/000024_invite_rewards.* server/migrations/mysql/000024_invite_rewards.* +git commit -m "feat(server/migrate): 000024 邀请奖励表 + subscriptions.source 扩 invite/task" +``` + +--- + +### Task 2: `codes.Service.GrantRewardTx` — 发奖天数原语 + +**Files:** +- Modify: `server/internal/codes/paygrant.go` +- Test: `server/internal/codes/reward_grant_sqlite_test.go`(新建) + +**Interfaces:** +- Produces: `func (svc *Service) GrantRewardTx(ctx context.Context, tx *sql.Tx, userID int64, days int, source, auditAction, ref string) (subID int64, expiresAt time.Time, err error)` — 发 Pro `days` 天,source 落新订阅行(已有活跃 pro 则顺延),写 audit_log。 + +- [ ] **Step 1: 写失败测试** `server/internal/codes/reward_grant_sqlite_test.go` + +```go +package codes + +import ( + "context" + "database/sql" + "testing" + "time" +) + +func TestGrantRewardTx_FreshUserCreatesInviteSub(t *testing.T) { + db := openMigratedSQLiteCodes(t) // 复用本包已有 sqlite helper(见 service_sqlite_test.go) + seedCodesUser(t, db, 7, "uuid-7") // 建 user id=7,不给活跃 sub(过期/无) + svc := NewService(NewStore(db), nil, 5, time.Hour) + + tx, _ := db.BeginTx(context.Background(), &sql.TxOptions{}) + subID, exp, err := svc.GrantRewardTx(context.Background(), tx, 7, 3, "invite", "invite_reward", "ref-1") + if err != nil { t.Fatalf("grant: %v", err) } + if err := tx.Commit(); err != nil { t.Fatal(err) } + if subID == 0 || exp.Before(time.Now()) { t.Fatalf("bad sub %d exp %v", subID, exp) } + + var src string + db.QueryRow(`SELECT source FROM subscriptions WHERE id=?`, subID).Scan(&src) + if src != "invite" { t.Fatalf("source = %q, want invite", src) } + var n int + db.QueryRow(`SELECT COUNT(*) FROM audit_log WHERE action='invite_reward' AND target='ref-1'`).Scan(&n) + if n != 1 { t.Fatalf("audit rows = %d, want 1", n) } +} +``` + +> 注:若本包已有 `openMigratedSQLite`/`seedUser` 同名 helper,直接复用并删掉上面重名声明;否则照 `server/internal/pay/testutil_test.go` 的 `openMigratedSQLite` 抄一份(`store.Open` → `MigrateUp` → `ApplyCodesLibMigrations`),user 用 `INSERT INTO users (id,uuid,email,pw_hash,dp_uuid,status,created_at) VALUES (...,'active',?)`。 + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd server && go test ./internal/codes/ -run TestGrantRewardTx -v` +Expected: FAIL(`svc.GrantRewardTx undefined`)。 + +- [ ] **Step 3: 实现** —— 在 `server/internal/codes/paygrant.go` 末尾加: + +```go +// GrantRewardTx 发放奖励会员天数(Pro,source∈{invite,task}),与付费/兑换码同一条 +// applySubscription 延时逻辑(max(到期,now)+days;已有活跃 pro 则原地顺延)。审计走 audit_log。 +func (svc *Service) GrantRewardTx( + ctx context.Context, tx *sql.Tx, userID int64, days int, source, auditAction, ref string, +) (int64, time.Time, error) { + planID, err := svc.store.GetPlanIDTx(ctx, tx, PlanPro) + if err != nil { + return 0, time.Time{}, err + } + subID, expiresAt, err := svc.applySubscription(ctx, tx, userID, planID, days, source) + if err != nil { + return 0, time.Time{}, err + } + meta, _ := json.Marshal(map[string]any{"days": days, "source": source, "sub_id": subID}) + _ = svc.store.WriteAuditLog(ctx, tx, fmt.Sprintf("user:%d", userID), auditAction, ref, string(meta)) + return subID, expiresAt, nil +} +``` + +(`json`/`fmt`/`time`/`sql`/`context` 已在 paygrant.go import。) + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd server && go test ./internal/codes/ -run TestGrantRewardTx -v` +Expected: PASS。 + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/codes/paygrant.go server/internal/codes/reward_grant_sqlite_test.go +git commit -m "feat(server/codes): GrantRewardTx 复用 applySubscription 发奖励会员天数" +``` + +--- + +### Task 3: `reward.Store` — 数据访问层 + +**Files:** +- Create: `server/internal/reward/store.go` +- Test: `server/internal/reward/store_sqlite_test.go` + +**Interfaces:** +- Produces: + - `type Store struct{ db *sql.DB }` + `func NewStore(db *sql.DB) *Store` + - `EnsureInviteCode(ctx, userID int64, gen func() string) (string, error)` — 惰性生成并写 users.invite_code(已有则返回旧值)。 + - `ResolveInviteCode(ctx, code string) (inviterID int64, ok bool, err error)` + - `DeviceUsedByOther(ctx, deviceUUID string, exceptUserID int64) (bool, error)` — devices.uuid 已属别人。 + - `RegRewardCountThisMonth(ctx, inviterID int64, since time.Time) (int, error)` — referrals.status='reg_rewarded' 本月计数。 + - `InsertReferralTx(ctx, tx, inviterID, inviteeID int64, deviceUUID, status string, regRewardedAt *time.Time, now time.Time) error` + - `MarkFirstPaidTx(ctx, tx, userID int64, at time.Time) (isFirst bool, err error)` — `UPDATE users SET first_paid_at=? WHERE id=? AND first_paid_at IS NULL`,RowsAffected==1 → true。 + - `ReferralByInvitee(ctx, tx, inviteeID int64) (inviterID int64, status string, ok bool, err error)` + - `MarkPaidRewardedTx(ctx, tx, inviteeID int64, at time.Time) error` + - `InsertClaimTx(ctx, tx, userID int64, taskKey, externalRef string, days int, now time.Time) error` — 唯一冲突返回 `ErrClaimExists`。 + - `Summary(ctx, userID int64) (invited, converted, earnedDays int, err error)` + `TelegramClaimed(ctx, userID int64) (bool, error)` + +- [ ] **Step 1: 写失败测试** `server/internal/reward/store_sqlite_test.go` + +```go +package reward + +import ( + "context" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/config" + "github.com/wangjia/pangolin/server/internal/store" +) + +func openDB(t *testing.T) *sqlDB { // 见下 helper + db, err := store.Open(&config.Config{Driver: "sqlite", DSN: ":memory:"}) + if err != nil { t.Fatal(err) } + t.Cleanup(func() { _ = db.Close() }) + if err := store.MigrateUp(db, "sqlite"); err != nil { t.Fatal(err) } + store.ApplyCodesLibMigrations(context.Background(), db, "sqlite") + return db +} + +func seedU(t *testing.T, db *sqlDB, id int64, uuid string) { + _, err := db.Exec(`INSERT INTO users (id,uuid,email,pw_hash,dp_uuid,status,created_at) + VALUES (?,?,?, 'x','dp-'||?, 'active', ?)`, id, uuid, uuid+"@x", uuid, time.Now().UTC()) + if err != nil { t.Fatal(err) } +} + +func TestEnsureAndResolveInviteCode(t *testing.T) { + db := openDB(t); seedU(t, db, 1, "u1") + st := NewStore(db) + code, err := st.EnsureInviteCode(context.Background(), 1, func() string { return "ABCD2345" }) + if err != nil || code != "ABCD2345" { t.Fatalf("ensure: %q %v", code, err) } + again, _ := st.EnsureInviteCode(context.Background(), 1, func() string { return "ZZZZ9999" }) + if again != "ABCD2345" { t.Fatalf("second ensure changed code: %q", again) } + inviter, ok, _ := st.ResolveInviteCode(context.Background(), "ABCD2345") + if !ok || inviter != 1 { t.Fatalf("resolve: %d %v", inviter, ok) } +} + +func TestInsertClaimUniqueGuards(t *testing.T) { + db := openDB(t); seedU(t, db, 1, "u1"); seedU(t, db, 2, "u2") + st := NewStore(db) + tx, _ := db.Begin() + if err := st.InsertClaimTx(context.Background(), tx, 1, "telegram_join", "tg-100", 3, time.Now().UTC()); err != nil { + t.Fatal(err) + } + tx.Commit() + // 同 user 再领 → ErrClaimExists + tx2, _ := db.Begin() + if err := st.InsertClaimTx(context.Background(), tx2, 1, "telegram_join", "tg-999", 3, time.Now().UTC()); err != ErrClaimExists { + t.Fatalf("same user reclaim err = %v, want ErrClaimExists", err) + } + tx2.Rollback() + // 同 telegram_id 换 user → ErrClaimExists + tx3, _ := db.Begin() + if err := st.InsertClaimTx(context.Background(), tx3, 2, "telegram_join", "tg-100", 3, time.Now().UTC()); err != ErrClaimExists { + t.Fatalf("same tgid reclaim err = %v, want ErrClaimExists", err) + } + tx3.Rollback() +} +``` + +> `sqlDB` 是 `*sql.DB` 的别名以省 import;实际直接用 `*sql.DB` 即可(把测试里 `*sqlDB` 换成 `*sql.DB`,并 `import "database/sql"`)。 + +- [ ] **Step 2: 跑确认失败** + +Run: `cd server && go test ./internal/reward/ -v` +Expected: FAIL(包不存在 / 符号未定义)。 + +- [ ] **Step 3: 实现** `server/internal/reward/store.go` + +```go +// Package reward 承载邀请奖励与奖励任务的数据访问 + 发奖 + 防刷。 +package reward + +import ( + "context" + "database/sql" + "errors" + "strings" + "time" +) + +var ErrClaimExists = errors.New("reward: claim already exists") + +type Store struct{ db *sql.DB } + +func NewStore(db *sql.DB) *Store { return &Store{db: db} } + +// EnsureInviteCode 惰性生成邀请码;已有则返回旧值。gen 生成候选码(冲突时重试到成功)。 +func (s *Store) EnsureInviteCode(ctx context.Context, userID int64, gen func() string) (string, error) { + var existing sql.NullString + if err := s.db.QueryRowContext(ctx, `SELECT invite_code FROM users WHERE id=?`, userID).Scan(&existing); err != nil { + return "", err + } + if existing.Valid && existing.String != "" { + return existing.String, nil + } + for i := 0; i < 5; i++ { + code := gen() + _, err := s.db.ExecContext(ctx, `UPDATE users SET invite_code=? WHERE id=? AND invite_code IS NULL`, code, userID) + if err != nil { + if isDup(err) { + continue + } + return "", err + } + // 读回(并发下可能是别的并发写入的值) + var got sql.NullString + if err := s.db.QueryRowContext(ctx, `SELECT invite_code FROM users WHERE id=?`, userID).Scan(&got); err != nil { + return "", err + } + if got.Valid && got.String != "" { + return got.String, nil + } + } + return "", errors.New("reward: invite code generation exhausted") +} + +func (s *Store) ResolveInviteCode(ctx context.Context, code string) (int64, bool, error) { + var id int64 + err := s.db.QueryRowContext(ctx, `SELECT id FROM users WHERE invite_code=? AND status='active'`, code).Scan(&id) + if err == sql.ErrNoRows { + return 0, false, nil + } + return id, err == nil, err +} + +func (s *Store) DeviceUsedByOther(ctx context.Context, deviceUUID string, exceptUserID int64) (bool, error) { + if deviceUUID == "" { + return false, nil + } + var n int + err := s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM devices WHERE uuid=? AND user_id<>?`, deviceUUID, exceptUserID).Scan(&n) + return n > 0, err +} + +func (s *Store) RegRewardCountThisMonth(ctx context.Context, inviterID int64, since time.Time) (int, error) { + var n int + err := s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM referrals WHERE inviter_id=? AND status='reg_rewarded' AND reg_rewarded_at>=?`, + inviterID, since).Scan(&n) + return n, err +} + +func (s *Store) InsertReferralTx(ctx context.Context, tx *sql.Tx, inviterID, inviteeID int64, + deviceUUID, status string, regRewardedAt *time.Time, now time.Time) error { + _, err := tx.ExecContext(ctx, + `INSERT INTO referrals (inviter_id,invitee_id,device_uuid,status,reg_rewarded_at,created_at) + VALUES (?,?,?,?,?,?)`, inviterID, inviteeID, deviceUUID, status, regRewardedAt, now) + if isDup(err) { + return ErrClaimExists + } + return err +} + +func (s *Store) ReferralByInvitee(ctx context.Context, tx *sql.Tx, inviteeID int64) (int64, string, bool, error) { + var inviter int64 + var status string + err := tx.QueryRowContext(ctx, `SELECT inviter_id,status FROM referrals WHERE invitee_id=?`, inviteeID). + Scan(&inviter, &status) + if err == sql.ErrNoRows { + return 0, "", false, nil + } + return inviter, status, err == nil, err +} + +func (s *Store) MarkFirstPaidTx(ctx context.Context, tx *sql.Tx, userID int64, at time.Time) (bool, error) { + res, err := tx.ExecContext(ctx, + `UPDATE users SET first_paid_at=? WHERE id=? AND first_paid_at IS NULL`, at, userID) + if err != nil { + return false, err + } + n, _ := res.RowsAffected() + return n == 1, nil +} + +func (s *Store) MarkPaidRewardedTx(ctx context.Context, tx *sql.Tx, inviteeID int64, at time.Time) error { + _, err := tx.ExecContext(ctx, + `UPDATE referrals SET status='paid_rewarded', paid_rewarded_at=? WHERE invitee_id=?`, at, inviteeID) + return err +} + +func (s *Store) InsertClaimTx(ctx context.Context, tx *sql.Tx, userID int64, taskKey, externalRef string, + days int, now time.Time) error { + _, err := tx.ExecContext(ctx, + `INSERT INTO reward_claims (user_id,task_key,external_ref,granted_days,granted_at) + VALUES (?,?,?,?,?)`, userID, taskKey, externalRef, days, now) + if isDup(err) { + return ErrClaimExists + } + return err +} + +func (s *Store) TelegramClaimed(ctx context.Context, userID int64) (bool, error) { + var n int + err := s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM reward_claims WHERE user_id=? AND task_key='telegram_join'`, userID).Scan(&n) + return n > 0, err +} + +// Summary: invited=已绑定人数;converted=已首充奖励人数;earnedDays=本人从奖励得到的总天数(audit_log 累加)。 +func (s *Store) Summary(ctx context.Context, userID int64) (invited, converted, earnedDays int, err error) { + if err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM referrals WHERE inviter_id=?`, userID).Scan(&invited); err != nil { + return + } + if err = s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM referrals WHERE inviter_id=? AND status='paid_rewarded'`, userID).Scan(&converted); err != nil { + return + } + // earnedDays: audit_log 里 actor=user: 且 action∈奖励动作,meta.days 累加(简化:reward_claims + referrals 估算) + var tg, reg, paid int + s.db.QueryRowContext(ctx, `SELECT COALESCE(SUM(granted_days),0) FROM reward_claims WHERE user_id=?`, userID).Scan(&tg) + s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM referrals WHERE inviter_id=? AND status IN ('reg_rewarded','paid_rewarded')`, userID).Scan(®) + s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM referrals WHERE inviter_id=? AND status='paid_rewarded'`, userID).Scan(&paid) + // 邀请人:每 reg_rewarded +3、每 paid_rewarded 再 +7;被邀请人自身得的天数不计入其「邀请战绩」。 + earnedDays = tg + reg*3 + paid*7 + return +} + +func isDup(err error) bool { + if err == nil { + return false + } + m := strings.ToLower(err.Error()) + return strings.Contains(m, "unique") || strings.Contains(m, "duplicate") || strings.Contains(m, "constraint") +} +``` + +- [ ] **Step 4: 跑确认通过** + +Run: `cd server && go test ./internal/reward/ -v` +Expected: PASS。 + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/reward/store.go server/internal/reward/store_sqlite_test.go +git commit -m "feat(server/reward): Store — referrals/reward_claims/invite_code 数据访问 + 唯一守卫" +``` + +--- + +### Task 4: `reward.Service` — 邀请码生成、注册段发奖 + 防刷 + +**Files:** +- Create: `server/internal/reward/service.go` +- Test: `server/internal/reward/service_sqlite_test.go` + +**Interfaces:** +- Consumes: `Store`(Task 3);`Granter`(下面定义,由 `*codes.Service` 满足)。 +- Produces: + - `type Granter interface { GrantRewardTx(ctx, tx *sql.Tx, userID int64, days int, source, auditAction, ref string) (int64, time.Time, error) }` + - `type Config struct{ RegDays, PaidDays, TgDays, RegMonthlyCap int }` + - `func NewService(db *sql.DB, st *Store, g Granter, cfg Config, now func() time.Time) *Service` + - `func GenInviteCode() string` — 8 位 base32(去 0/O/1/I),crypto/rand。 + - `func (s *Service) EnsureCode(ctx, userID) (string, error)` + - `func (s *Service) OnRegister(ctx, inviteeID int64, inviteCode, deviceUUID string)` — best-effort:解析码→防刷→建 referrals→注册段发双方 3 天。无返回(自吞错、内部 log)。 + +- [ ] **Step 1: 写失败测试** `server/internal/reward/service_sqlite_test.go` + +```go +package reward + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/codes" +) + +func newSvc(t *testing.T, db *sql.DB) *Service { + g := codes.NewService(codes.NewStore(db), nil, 5, time.Hour) + return NewService(db, NewStore(db), g, Config{RegDays: 3, PaidDays: 7, TgDays: 3, RegMonthlyCap: 10}, time.Now) +} + +func proDays(t *testing.T, db *sql.DB, userID int64) int { // 该用户 pro 剩余天数(粗算) + var exp sql.NullTime + db.QueryRow(`SELECT MAX(expires_at) FROM subscriptions s JOIN plans p ON p.id=s.plan_id + WHERE s.user_id=? AND p.code='pro'`, userID).Scan(&exp) + if !exp.Valid { return 0 } + return int(time.Until(exp.Time).Hours()/24) + 1 +} + +func TestOnRegister_BothGetThreeDays(t *testing.T) { + db := openDB(t); seedU(t, db, 1, "inviter"); seedU(t, db, 2, "invitee") + s := newSvc(t, db) + code, _ := s.EnsureCode(context.Background(), 1) + s.OnRegister(context.Background(), 2, code, "dev-2") + + var status string + db.QueryRow(`SELECT status FROM referrals WHERE invitee_id=2`).Scan(&status) + if status != "reg_rewarded" { t.Fatalf("status=%q", status) } + if d := proDays(t, db, 1); d < 3 { t.Fatalf("inviter pro days=%d, want>=3", d) } + if d := proDays(t, db, 2); d < 3 { t.Fatalf("invitee pro days=%d, want>=3", d) } +} + +func TestOnRegister_SelfInviteRejected(t *testing.T) { + db := openDB(t); seedU(t, db, 1, "u1") + s := newSvc(t, db) + code, _ := s.EnsureCode(context.Background(), 1) + s.OnRegister(context.Background(), 1, code, "dev-1") // 自邀请 + var n int + db.QueryRow(`SELECT COUNT(*) FROM referrals`).Scan(&n) + if n != 0 { t.Fatalf("self-invite created %d referrals, want 0", n) } +} + +func TestOnRegister_DeviceReusedRejectsReward(t *testing.T) { + db := openDB(t); seedU(t, db, 1, "inv"); seedU(t, db, 2, "old"); seedU(t, db, 3, "new") + // old(2) 的设备 dev-X 已在 devices + db.Exec(`INSERT INTO devices (uuid,user_id,name,platform,last_seen,created_at) + VALUES ('dev-X',2,'n','android',?,?)`, time.Now().UTC(), time.Now().UTC()) + s := newSvc(t, db) + code, _ := s.EnsureCode(context.Background(), 1) + s.OnRegister(context.Background(), 3, code, "dev-X") // 新用户复用老设备 + var status string + db.QueryRow(`SELECT status FROM referrals WHERE invitee_id=3`).Scan(&status) + if status != "rejected" { t.Fatalf("status=%q, want rejected", status) } + if d := proDays(t, db, 1); d >= 3 { t.Fatalf("inviter got reward despite device reuse: %d", d) } +} +``` + +- [ ] **Step 2: 跑确认失败** + +Run: `cd server && go test ./internal/reward/ -run TestOnRegister -v` +Expected: FAIL(`NewService`/`EnsureCode`/`OnRegister` 未定义)。 + +- [ ] **Step 3: 实现** `server/internal/reward/service.go` + +```go +package reward + +import ( + "context" + "crypto/rand" + "database/sql" + "log/slog" + "time" +) + +type Granter interface { + GrantRewardTx(ctx context.Context, tx *sql.Tx, userID int64, days int, source, auditAction, ref string) (int64, time.Time, error) +} + +type Config struct{ RegDays, PaidDays, TgDays, RegMonthlyCap int } + +type Service struct { + db *sql.DB + st *Store + g Granter + cfg Config + now func() time.Time +} + +func NewService(db *sql.DB, st *Store, g Granter, cfg Config, now func() time.Time) *Service { + if now == nil { + now = func() time.Time { return time.Now().UTC() } + } + return &Service{db: db, st: st, g: g, cfg: cfg, now: now} +} + +const inviteAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" // 去 0/O/1/I,32 字符 + +// GenInviteCode 返回 8 位随机码。crypto/rand 失败时用 now 派生兜底(避免 panic)。 +func GenInviteCode() string { + b := make([]byte, 8) + if _, err := rand.Read(b); err != nil { + t := time.Now().UnixNano() + for i := range b { + b[i] = byte(t >> (8 * i)) + } + } + out := make([]byte, 8) + for i, x := range b { + out[i] = inviteAlphabet[int(x)%len(inviteAlphabet)] + } + return string(out) +} + +func (s *Service) EnsureCode(ctx context.Context, userID int64) (string, error) { + return s.st.EnsureInviteCode(ctx, userID, GenInviteCode) +} + +// OnRegister best-effort:与建号解耦(自有事务,失败只 log,不回滚注册)。 +func (s *Service) OnRegister(ctx context.Context, inviteeID int64, inviteCode, deviceUUID string) { + if inviteCode == "" { + return + } + inviterID, ok, err := s.st.ResolveInviteCode(ctx, inviteCode) + if err != nil || !ok { + if err != nil { + slog.Warn("reward: resolve invite code failed", "err", err) + } + return // 无效码:静默,不报错(注册已成功) + } + if inviterID == inviteeID { + return // 自邀请:不建关系 + } + now := s.now() + + // 防刷判定:设备复用 or 月度封顶 → 记关系但 status=rejected、不发注册奖。 + rejected := false + if used, e := s.st.DeviceUsedByOther(ctx, deviceUUID, inviteeID); e == nil && used { + rejected = true + } + if !rejected { + monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) + if cnt, e := s.st.RegRewardCountThisMonth(ctx, inviterID, monthStart); e == nil && cnt >= s.cfg.RegMonthlyCap { + rejected = true + } + } + + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) + if err != nil { + slog.Warn("reward: begin tx failed", "err", err) + return + } + defer func() { _ = tx.Rollback() }() + + status := "reg_rewarded" + var regAt *time.Time + if rejected { + status = "rejected" + } else { + regAt = &now + } + if err := s.st.InsertReferralTx(ctx, tx, inviterID, inviteeID, deviceUUID, status, regAt, now); err != nil { + if err != ErrClaimExists { + slog.Warn("reward: insert referral failed", "err", err) + } + return // invitee 已绑定过(唯一约束)→ 放弃 + } + if !rejected { + if _, _, err := s.g.GrantRewardTx(ctx, tx, inviterID, s.cfg.RegDays, "invite", "invite_reg_inviter", "ref-inviter"); err != nil { + slog.Warn("reward: grant inviter reg failed", "err", err) + return + } + if _, _, err := s.g.GrantRewardTx(ctx, tx, inviteeID, s.cfg.RegDays, "invite", "invite_reg_invitee", "ref-invitee"); err != nil { + slog.Warn("reward: grant invitee reg failed", "err", err) + return + } + } + if err := tx.Commit(); err != nil { + slog.Warn("reward: commit failed", "err", err) + } +} +``` + +- [ ] **Step 4: 跑确认通过** + +Run: `cd server && go test ./internal/reward/ -run TestOnRegister -v` +Expected: PASS(3 个用例)。 + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/reward/service.go server/internal/reward/service_sqlite_test.go +git commit -m "feat(server/reward): OnRegister 绑定 + 注册段双方发奖 + 自邀请/设备/月封顶防刷" +``` + +--- + +### Task 5: 接入注册 — auth.Register 加 inviteCode + +**Files:** +- Modify: `server/internal/auth/service.go`(Register 签名 + setter) +- Modify: `server/internal/auth/handler.go`(RegisterRequest 加字段 + 传参) +- Test: `server/internal/auth/register_invite_test.go` + +**Interfaces:** +- Consumes: `reward.Service.OnRegister`(Task 4)—— 经接口 `ReferralHook interface{ OnRegister(ctx, inviteeID int64, inviteCode, deviceUUID string) }`。 +- Produces: `Register(ctx, rawEmail, code, password, ip string, device DeviceMeta, inviteCode string)`;`Service.SetReferralHook(h ReferralHook)`。 + +- [ ] **Step 1: 写失败测试** `server/internal/auth/register_invite_test.go` + +```go +package auth + +import ( + "context" + "testing" +) + +type fakeHook struct{ gotInvitee int64; gotCode, gotDev string } +func (f *fakeHook) OnRegister(_ context.Context, inviteeID int64, code, dev string) { + f.gotInvitee, f.gotCode, f.gotDev = inviteeID, code, dev +} + +func TestRegister_CallsReferralHookWithInviteCode(t *testing.T) { + s := newTestService(t) // 复用本包已有的 service 测试装配(见 service_test.go) + h := &fakeHook{} + s.SetReferralHook(h) + // 走通注册(需本包既有测试里的 seed 验证码/邮件 mock 流程);断言 hook 收到 code。 + _, apiErr := s.Register(context.Background(), "new@x.com", validCode(t, s, "new@x.com"), + "password123", "1.2.3.4", DeviceMeta{DeviceID: "dev-1"}, "INVCODE9") + if apiErr != nil { t.Fatalf("register: %v", apiErr) } + if h.gotCode != "INVCODE9" || h.gotDev != "dev-1" || h.gotInvitee == 0 { + t.Fatalf("hook got invitee=%d code=%q dev=%q", h.gotInvitee, h.gotCode, h.gotDev) + } +} +``` + +> `newTestService`/`validCode` 若本包无同名 helper,照 `server/internal/auth/service_test.go` 现有注册测试的装配复制(含 redis mock / verifyCode 放行)。若现有注册测试用别的 seed 手法,沿用之。 + +- [ ] **Step 2: 跑确认失败** + +Run: `cd server && go test ./internal/auth/ -run TestRegister_CallsReferralHook -v` +Expected: FAIL(`SetReferralHook` 未定义 / Register 参数不匹配)。 + +- [ ] **Step 3: 实现** —— `server/internal/auth/service.go`: + +在 Service struct 加字段与 setter(紧挨已有 `SetDeviceRegistrar`): + +```go +// ReferralHook 在注册成功后被调用(best-effort;实现见 reward.Service)。 +type ReferralHook interface { + OnRegister(ctx context.Context, inviteeID int64, inviteCode, deviceUUID string) +} + +func (s *Service) SetReferralHook(h ReferralHook) { s.referral = h } +``` + +Service struct 增 `referral ReferralHook`。改 `Register` 签名与尾部: + +```go +func (s *Service) Register(ctx context.Context, rawEmail, code, password, ip string, device DeviceMeta, inviteCode string) (*TokenPair, *apierr.Error) { + // ... 原有校验 / verifyCode / CreateUserWithTrial / IssueWithJTI / recordLogin 不变 ... + s.recordLogin(ctx, user.ID, jti, ip, device) + if s.referral != nil { + s.referral.OnRegister(ctx, user.ID, inviteCode, device.DeviceID) // best-effort + } + return pair, nil +} +``` + +`server/internal/auth/handler.go` 的 register 请求体加字段并传参: + +```go +type registerRequest struct { + Email string `json:"email"` + Code string `json:"code"` + Password string `json:"password"` + Device DeviceMeta `json:"device"` + InviteCode string `json:"invite_code"` // 选填 +} +// Register handler 内: +pair, apiErr := h.svc.Register(r.Context(), req.Email, req.Code, req.Password, clientIP(r), req.Device, req.InviteCode) +``` + +> 若 handler 里 DeviceMeta 是从别的字段拼的,保持原样,仅追加 `req.InviteCode` 末参。**全库搜 `.Register(` 更新所有调用点**(测试也算):`grep -rn '\.Register(' server/`。 + +- [ ] **Step 4: 跑确认通过 + 全包回归** + +Run: `cd server && go test ./internal/auth/ -v` +Expected: PASS(新用例 + 原有注册测试因签名更新后仍绿)。 + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/auth/service.go server/internal/auth/handler.go server/internal/auth/register_invite_test.go +git commit -m "feat(server/auth): Register 加 inviteCode + ReferralHook(注册后 best-effort 归因)" +``` + +--- + +### Task 6: 首充钩子 — pay webhook 接首充段发奖 + +**Files:** +- Modify: `server/internal/pay/webhook.go`(settle 内插首充奖励 + Rewarder 接口/setter) +- Test: `server/internal/pay/webhook_referral_sqlite_test.go` + +**Interfaces:** +- Consumes: `reward.Service`——经接口 `Rewarder interface{ OnFirstPaidTx(ctx, tx *sql.Tx, inviteeID int64, now time.Time) error }`。 +- Produces: `WebhookHandler.SetRewarder(r Rewarder)`;settle 在 grant 之后、commit 之前调 `OnFirstPaidTx`。 + +- [ ] **Step 1: 在 reward 包加 `OnFirstPaidTx`(同事务)** —— `server/internal/reward/service.go` 追加: + +```go +// OnFirstPaidTx 在 pay webhook 的同一事务内调用:标记 first_paid_at,若确为首充且该用户 +// 是被邀请人(referral 命中且未发过首充奖)→ 发首充段双方各 PaidDays 天。幂等。 +func (s *Service) OnFirstPaidTx(ctx context.Context, tx *sql.Tx, inviteeID int64, now time.Time) error { + first, err := s.st.MarkFirstPaidTx(ctx, tx, inviteeID, now) + if err != nil || !first { + return err // 非首充 → 不发 + } + inviterID, status, ok, err := s.st.ReferralByInvitee(ctx, tx, inviteeID) + if err != nil || !ok || status == "paid_rewarded" || status == "rejected" { + return err // 无邀请关系 / 已发过 / 被拒 → 不发 + } + if _, _, err := s.g.GrantRewardTx(ctx, tx, inviterID, s.cfg.PaidDays, "invite", "invite_paid_inviter", "ref-paid-inviter"); err != nil { + return err + } + if _, _, err := s.g.GrantRewardTx(ctx, tx, inviteeID, s.cfg.PaidDays, "invite", "invite_paid_invitee", "ref-paid-invitee"); err != nil { + return err + } + return s.st.MarkPaidRewardedTx(ctx, tx, inviteeID, now) +} +``` + +- [ ] **Step 2: 写失败测试** `server/internal/pay/webhook_referral_sqlite_test.go` + +```go +package pay + +import ( + "context" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/codes" + "github.com/wangjia/pangolin/server/internal/reward" +) + +func TestSettle_FirstPaidGrantsReferralBonus(t *testing.T) { + h, db, _ := newWebhookRig(t) // 复用本包 rig;rig 里 seedUser(1) + seedUser(t, db, 2, "invitee-uuid") // 被邀请人 = 付费者 + // 建 referral: inviter=1, invitee=2, 已 reg_rewarded + now := time.Now().UTC() + db.Exec(`INSERT INTO referrals (inviter_id,invitee_id,status,reg_rewarded_at,created_at) VALUES (1,2,'reg_rewarded',?,?)`, now, now) + // 装 rewarder + g := codes.NewService(codes.NewStore(db), nil, 5, time.Hour) + rw := reward.NewService(db, reward.NewStore(db), g, reward.Config{RegDays: 3, PaidDays: 7, TgDays: 3, RegMonthlyCap: 10}, time.Now) + h.SetRewarder(rw) + + // 触发 settle(照本包已有 webhook 测试构造 ev:invitee=2 的一笔 paid;biz_ref=其 uuid) + err := h.settleForTest(context.Background(), &webhookEvent{ + ProductBizCode: "pro_month", OutTradeNo: "ot-1", BizRef: "invitee-uuid", + Channel: "nezha", AmountMinor: 600, Currency: "CNY", PaidAt: now.Format(time.RFC3339), + }) + if err != nil { t.Fatalf("settle: %v", err) } + + var status string + db.QueryRow(`SELECT status FROM referrals WHERE invitee_id=2`).Scan(&status) + if status != "paid_rewarded" { t.Fatalf("status=%q, want paid_rewarded", status) } + var fp int + db.QueryRow(`SELECT COUNT(*) FROM users WHERE id=2 AND first_paid_at IS NOT NULL`).Scan(&fp) + if fp != 1 { t.Fatalf("first_paid_at not set") } +} +``` + +> `settleForTest` 若无,加一个包内导出小助手 `func (h *WebhookHandler) settleForTest(ctx, ev *webhookEvent) error { return h.settle(ctx, ev) }`(仅测试文件用 `//go:build` 不必;直接同包调用私有 `settle` 即可,删掉 wrapper,直接 `h.settle(...)`)。`webhookEvent` 字段名以本包实际定义为准(见 webhook.go)。 + +- [ ] **Step 3: 跑确认失败** + +Run: `cd server && go test ./internal/pay/ -run TestSettle_FirstPaid -v` +Expected: FAIL(`SetRewarder` 未定义)。 + +- [ ] **Step 4: 实现** —— `server/internal/pay/webhook.go`: + +```go +// Rewarder(可选)在首充同事务内发放邀请首充奖励;nil 则跳过。 +type Rewarder interface { + OnFirstPaidTx(ctx context.Context, tx *sql.Tx, inviteeID int64, now time.Time) error +} +func (h *WebhookHandler) SetRewarder(r Rewarder) { h.rewarder = r } +``` + +WebhookHandler struct 增 `rewarder Rewarder`。在 settle 里 `GrantPaidSubscriptionTx` 成功之后、`MarkPaidTx` 之后、`tx.Commit()` 之前插入: + +```go + if h.rewarder != nil { + if err := h.rewarder.OnFirstPaidTx(ctx, tx, userID, h.now().UTC()); err != nil { + return err // 同事务:发奖失败则整笔回滚,webhook 重试 + } + } + return tx.Commit() +``` + +- [ ] **Step 5: 跑确认通过** + +Run: `cd server && go test ./internal/pay/ -v` +Expected: PASS。 + +- [ ] **Step 6: Commit** + +```bash +git add server/internal/reward/service.go server/internal/pay/webhook.go server/internal/pay/webhook_referral_sqlite_test.go +git commit -m "feat(server/pay): 首充钩子 — settle 内接邀请首充段发奖(first_paid_at 幂等)" +``` + +--- + +### Task 7: `GET /v1/invite` 端点 + +**Files:** +- Create: `server/internal/reward/handler.go` +- Test: `server/internal/reward/handler_invite_test.go` + +**Interfaces:** +- Consumes: `Service`(EnsureCode + st.Summary + st.TelegramClaimed)。 +- Produces: `type Handler struct{...}` + `func NewHandler(s *Service, st *Store, tgEnabled bool, channel string) *Handler`;`func (h *Handler) GetInvite(w, r)` 返回 `{invite_code, invite_link, invited, converted, earned_days, telegram:{enabled, joined, channel}}`。 + +- [ ] **Step 1: 写失败测试** `server/internal/reward/handler_invite_test.go` + +```go +package reward + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/codes" +) + +func TestGetInvite_ReturnsCodeAndSummary(t *testing.T) { + db := openDB(t); seedU(t, db, 1, "u1") + st := NewStore(db) + g := codes.NewService(codes.NewStore(db), nil, 5, time.Hour) + svc := NewService(db, st, g, Config{RegDays: 3, PaidDays: 7, TgDays: 3, RegMonthlyCap: 10}, time.Now) + h := NewHandler(svc, st, true, "@pangolin_app") + + r := httptest.NewRequest(http.MethodGet, "/v1/invite", nil) + r = r.WithContext(context.WithValue(r.Context(), codes.CtxKeyUserID, int64(1))) + w := httptest.NewRecorder() + h.GetInvite(w, r) // 直接调 handler,不经 router + + if w.Code != 200 { t.Fatalf("code=%d body=%s", w.Code, w.Body) } + var got map[string]any + json.Unmarshal(w.Body.Bytes(), &got) + if got["invite_code"] == "" || got["invite_code"] == nil { t.Fatalf("no invite_code: %v", got) } + tg, _ := got["telegram"].(map[string]any) + if tg == nil || tg["enabled"] != true { t.Fatalf("telegram block wrong: %v", got["telegram"]) } +} +``` + +> 取 uid 用 `codes.CtxKeyUserID`(与 `auth.UserIDFromContext` 同键,见锚点 D11)。若 import auth 造成环,直接用 `auth.UserIDFromContext(ctx)`——它内部读同键。测试里注入用 `codes.CtxKeyUserID`。 + +- [ ] **Step 2: 跑确认失败** + +Run: `cd server && go test ./internal/reward/ -run TestGetInvite -v` +Expected: FAIL(`NewHandler`/`GetInvite` 未定义)。 + +- [ ] **Step 3: 实现** `server/internal/reward/handler.go`(GetInvite 部分) + +```go +package reward + +import ( + "encoding/json" + "net/http" + + "github.com/wangjia/pangolin/server/internal/apierr" + "github.com/wangjia/pangolin/server/internal/auth" +) + +const inviteLinkBase = "https://pangolin.yanmeiai.com/i/" + +type Handler struct { + svc *Service + st *Store + tgEnabled bool + channel string +} + +func NewHandler(s *Service, st *Store, tgEnabled bool, channel string) *Handler { + return &Handler{svc: s, st: st, tgEnabled: tgEnabled, channel: channel} +} + +func (h *Handler) GetInvite(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + uid, ok := auth.UserIDFromContext(ctx) + if !ok { + apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) + return + } + code, err := h.svc.EnsureCode(ctx, uid) + if err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + invited, converted, earned, err := h.st.Summary(ctx, uid) + if err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + joined, _ := h.st.TelegramClaimed(ctx, uid) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{ + "invite_code": code, + "invite_link": inviteLinkBase + code, + "invited": invited, + "converted": converted, + "earned_days": earned, + "telegram": map[string]any{ + "enabled": h.tgEnabled, + "joined": joined, + "channel": h.channel, + }, + }) +} +``` + +- [ ] **Step 4: 跑确认通过** + +Run: `cd server && go test ./internal/reward/ -run TestGetInvite -v` +Expected: PASS。 + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/reward/handler.go server/internal/reward/handler_invite_test.go +git commit -m "feat(server/reward): GET /v1/invite — 邀请码/链接/战绩/TG 任务态" +``` + +--- + +### Task 8: TG 绑定 token — `GET /v1/tasks/telegram/start` + +**Files:** +- Create: `server/internal/reward/telegram.go`(token 部分) +- Modify: `server/internal/reward/handler.go`(加 TelegramStart handler) +- Test: `server/internal/reward/telegram_token_test.go` + +**Interfaces:** +- Produces: + - `func (s *Service) IssueTelegramToken(ctx, userID int64) (string, error)` — 生成随机 token,Redis `SETEX tg_bind: 600 `;Redis 为 nil 时用内存 map 兜底(测试)。 + - `func (s *Service) ConsumeTelegramToken(ctx, token string) (userID int64, ok bool, err error)` — GETDEL。 + - `Handler.TelegramStart(w, r)` → `{deep_link: "https://t.me/?start="}`。Service 需知道 bot username(`SetTelegram(botUser, channel, botToken, webhookSecret)`)。 + +- [ ] **Step 1: 写失败测试** `server/internal/reward/telegram_token_test.go` + +```go +package reward + +import ( + "context" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/codes" +) + +func TestTelegramToken_IssueThenConsumeOnce(t *testing.T) { + db := openDB(t); seedU(t, db, 5, "u5") + g := codes.NewService(codes.NewStore(db), nil, 5, time.Hour) + s := NewService(db, NewStore(db), g, Config{TgDays: 3}, time.Now) + s.SetTelegram("pangolin_reward_bot", "@pangolin_app", "bot-token", "wh-secret") // 内存兜底(rdb nil) + + tok, err := s.IssueTelegramToken(context.Background(), 5) + if err != nil || tok == "" { t.Fatalf("issue: %q %v", tok, err) } + uid, ok, _ := s.ConsumeTelegramToken(context.Background(), tok) + if !ok || uid != 5 { t.Fatalf("consume: %d %v", uid, ok) } + _, ok2, _ := s.ConsumeTelegramToken(context.Background(), tok) + if ok2 { t.Fatalf("token consumable twice") } +} +``` + +- [ ] **Step 2: 跑确认失败** + +Run: `cd server && go test ./internal/reward/ -run TestTelegramToken -v` +Expected: FAIL。 + +- [ ] **Step 3: 实现** `server/internal/reward/telegram.go` + +```go +package reward + +import ( + "context" + "crypto/rand" + "encoding/hex" + "strconv" + "sync" + "time" + + "github.com/redis/go-redis/v9" +) + +type tgConfig struct { + botUser, channel, botToken, webhookSecret string +} + +// SetTelegram 注入 TG 配置 + redis(经 Service.rdb)。rdb 为 nil 时用内存 token 兜底(测试/未配 redis)。 +func (s *Service) SetTelegram(botUser, channel, botToken, webhookSecret string) { + s.tg = tgConfig{botUser: botUser, channel: channel, botToken: botToken, webhookSecret: webhookSecret} +} +func (s *Service) SetRedis(rdb *redis.Client) { s.rdb = rdb } + +func randToken() string { b := make([]byte, 16); _, _ = rand.Read(b); return hex.EncodeToString(b) } + +func (s *Service) IssueTelegramToken(ctx context.Context, userID int64) (string, error) { + tok := randToken() + if s.rdb != nil { + return tok, s.rdb.Set(ctx, "tg_bind:"+tok, userID, 10*time.Minute).Err() + } + s.memMu.Lock() + if s.memTok == nil { + s.memTok = map[string]int64{} + } + s.memTok[tok] = userID + s.memMu.Unlock() + return tok, nil +} + +func (s *Service) ConsumeTelegramToken(ctx context.Context, token string) (int64, bool, error) { + if s.rdb != nil { + v, err := s.rdb.GetDel(ctx, "tg_bind:"+token).Result() + if err == redis.Nil { + return 0, false, nil + } + if err != nil { + return 0, false, err + } + id, _ := strconv.ParseInt(v, 10, 64) + return id, true, nil + } + s.memMu.Lock() + defer s.memMu.Unlock() + id, ok := s.memTok[token] + if ok { + delete(s.memTok, token) + } + return id, ok, nil +} + +var _ = sync.Mutex{} // 保 sync import(memMu 在 Service struct) +``` + +在 `service.go` 的 Service struct 增字段:`rdb *redis.Client`、`tg tgConfig`、`memMu sync.Mutex`、`memTok map[string]int64`(补 `import "sync"` 与 redis)。 + +`handler.go` 加: + +```go +func (h *Handler) TelegramStart(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + uid, ok := auth.UserIDFromContext(ctx) + if !ok { apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized); return } + if !h.tgEnabled { apierr.WriteJSON(w, http.StatusNotFound, apierr.ErrNotFound); return } + tok, err := h.svc.IssueTelegramToken(ctx, uid) + if err != nil { apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal); return } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{"deep_link": h.svc.TelegramDeepLink(tok)}) +} +``` + +`service.go` 加 `func (s *Service) TelegramDeepLink(tok string) string { return "https://t.me/" + s.tg.botUser + "?start=" + tok }`。 + +- [ ] **Step 4: 跑确认通过** + +Run: `cd server && go test ./internal/reward/ -run TestTelegramToken -v` +Expected: PASS。 + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/reward/telegram.go server/internal/reward/handler.go server/internal/reward/service.go server/internal/reward/telegram_token_test.go +git commit -m "feat(server/reward): TG 绑定 token 签发/消费 + GET /v1/tasks/telegram/start" +``` + +--- + +### Task 9: TG webhook — getChatMember 真校验 + 发奖 + +**Files:** +- Modify: `server/internal/reward/telegram.go`(getChatMember 客户端 + ClaimTelegram) +- Modify: `server/internal/reward/handler.go`(TelegramWebhook handler) +- Test: `server/internal/reward/telegram_webhook_test.go` + +**Interfaces:** +- Produces: + - `type ChatMemberChecker interface{ IsMember(ctx, channel, telegramID string) (bool, error) }`(默认实现打 Telegram Bot API;测试注入 fake)。`Service.SetMemberChecker(c ChatMemberChecker)`。 + - `func (s *Service) ClaimTelegram(ctx, userID int64, telegramID string) (granted bool, err error)` — getChatMember 真是成员 → InsertClaimTx(唯一守卫)+ GrantRewardTx(source='task')。 + - `Handler.TelegramWebhook(w, r)` — 校验 secret 头 → 解析 `/start ` → ConsumeToken → ClaimTelegram → sendMessage。 + +- [ ] **Step 1: 写失败测试** `server/internal/reward/telegram_webhook_test.go` + +```go +package reward + +import ( + "context" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/codes" +) + +type fakeChecker struct{ member bool } +func (f fakeChecker) IsMember(_ context.Context, _, _ string) (bool, error) { return f.member, nil } + +func newTgSvc(t *testing.T, db *sql.DB, member bool) *Service { + g := codes.NewService(codes.NewStore(db), nil, 5, time.Hour) + s := NewService(db, NewStore(db), g, Config{TgDays: 3}, time.Now) + s.SetTelegram("bot", "@ch", "tok", "sec") + s.SetMemberChecker(fakeChecker{member: member}) + return s +} + +func TestClaimTelegram_MemberGrantsOnce(t *testing.T) { + db := openDB(t); seedU(t, db, 1, "u1") + s := newTgSvc(t, db, true) + ok, err := s.ClaimTelegram(context.Background(), 1, "tg-777") + if err != nil || !ok { t.Fatalf("claim1: %v %v", ok, err) } + if d := proDays(t, db, 1); d < 3 { t.Fatalf("pro days=%d", d) } + // 再领 → 不再发(唯一守卫) + ok2, _ := s.ClaimTelegram(context.Background(), 1, "tg-777") + if ok2 { t.Fatalf("claimed twice") } +} + +func TestClaimTelegram_NonMemberNoGrant(t *testing.T) { + db := openDB(t); seedU(t, db, 1, "u1") + s := newTgSvc(t, db, false) + ok, _ := s.ClaimTelegram(context.Background(), 1, "tg-1") + if ok { t.Fatalf("non-member granted") } + if d := proDays(t, db, 1); d >= 3 { t.Fatalf("granted days to non-member: %d", d) } +} +``` + +(文件顶部 `import "database/sql"`。) + +- [ ] **Step 2: 跑确认失败** + +Run: `cd server && go test ./internal/reward/ -run TestClaimTelegram -v` +Expected: FAIL。 + +- [ ] **Step 3: 实现** —— `telegram.go` 追加: + +```go +type ChatMemberChecker interface { + IsMember(ctx context.Context, channel, telegramID string) (bool, error) +} +func (s *Service) SetMemberChecker(c ChatMemberChecker) { s.checker = c } + +// ClaimTelegram: 真成员则一次性发 TgDays 天(source='task')。 +func (s *Service) ClaimTelegram(ctx context.Context, userID int64, telegramID string) (bool, error) { + member, err := s.checker.IsMember(ctx, s.tg.channel, telegramID) + if err != nil || !member { + return false, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) + if err != nil { + return false, err + } + defer func() { _ = tx.Rollback() }() + now := s.now() + if err := s.st.InsertClaimTx(ctx, tx, userID, "telegram_join", telegramID, s.cfg.TgDays, now); err != nil { + if err == ErrClaimExists { + return false, nil // 已领过(本账户 or 该 tgid) + } + return false, err + } + if _, _, err := s.g.GrantRewardTx(ctx, tx, userID, s.cfg.TgDays, "task", "task_telegram_join", "tg:"+telegramID); err != nil { + return false, err + } + if err := tx.Commit(); err != nil { + return false, err + } + return true, nil +} +``` + +(`sql` 已 import;Service struct 增 `checker ChatMemberChecker`。) + +默认 `IsMember` 打 Telegram API(生产用),同文件加: + +```go +type apiChecker struct{ botToken string; hc *http.Client } +func (c apiChecker) IsMember(ctx context.Context, channel, telegramID string) (bool, error) { + u := "https://api.telegram.org/bot" + c.botToken + "/getChatMember" + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + q := req.URL.Query(); q.Set("chat_id", channel); q.Set("user_id", telegramID); req.URL.RawQuery = q.Encode() + resp, err := c.hc.Do(req) + if err != nil { return false, err } + defer resp.Body.Close() + var out struct{ OK bool `json:"ok"`; Result struct{ Status string `json:"status"` } `json:"result"` } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return false, err } + s := out.Result.Status + return out.OK && (s == "member" || s == "administrator" || s == "creator"), nil +} +``` + +(`telegram.go` 顶部补 `import ("net/http"; "encoding/json"; "time")`;`SetTelegram` 里把默认 checker 设成 `apiChecker{botToken, &http.Client{Timeout: 8*time.Second}}`,但**若已 SetMemberChecker 注入则不覆盖**——用 `if s.checker == nil { s.checker = apiChecker{...} }`。) + +`handler.go` 加 webhook: + +```go +func (h *Handler) TelegramWebhook(w http.ResponseWriter, r *http.Request) { + if !h.tgEnabled || r.Header.Get("X-Telegram-Bot-Api-Secret-Token") != h.svc.WebhookSecret() { + w.WriteHeader(http.StatusNotFound) + return + } + var upd struct { + Message struct { + Text string `json:"text"` + From struct{ ID int64 `json:"id"` } `json:"from"` + } `json:"message"` + } + _ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&upd) + w.WriteHeader(http.StatusOK) // Telegram 只要 200;业务异步在下方 + text, fromID := upd.Message.Text, upd.Message.From.ID + if !strings.HasPrefix(text, "/start ") || fromID == 0 { + return + } + token := strings.TrimSpace(strings.TrimPrefix(text, "/start ")) + ctx := r.Context() + uid, ok, _ := h.svc.ConsumeTelegramToken(ctx, token) + if !ok { + h.svc.SendTelegram(ctx, fromID, "链接已失效,请回 App 重新点「验证领取」。") + return + } + granted, err := h.svc.ClaimTelegram(ctx, uid, strconv.FormatInt(fromID, 10)) + switch { + case err != nil: + h.svc.SendTelegram(ctx, fromID, "验证出错,请稍后重试。") + case granted: + h.svc.SendTelegram(ctx, fromID, "✅ 已到账 +3 天 Pro,感谢加入!") + default: + h.svc.SendTelegram(ctx, fromID, "请先加入频道 "+h.svc.Channel()+" 再点验证;若已加入且领取过则无需重复。") + } +} +``` + +`service.go` / `telegram.go` 补 `WebhookSecret()`/`Channel()`/`SendTelegram(ctx, chatID int64, text string)`(SendTelegram 打 Bot API sendMessage,失败只 log)。`handler.go` 顶部补 `import ("strconv"; "strings")`。 + +- [ ] **Step 4: 跑确认通过** + +Run: `cd server && go test ./internal/reward/ -v` +Expected: PASS(全包)。 + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/reward/telegram.go server/internal/reward/handler.go server/internal/reward/service.go server/internal/reward/telegram_webhook_test.go +git commit -m "feat(server/reward): TG getChatMember 真校验 + ClaimTelegram + POST /tg/webhook" +``` + +--- + +### Task 10: 装配 main.go — 构造 reward svc + 注入 + 路由 + env + +**Files:** +- Modify: `server/cmd/server/main.go` +- Test: 手动 `go build` + `go vet`(装配无独立单测;逻辑单测已在各包)。 + +**Interfaces:** +- Consumes: 前序全部。 +- Produces: 挂载 `GET /v1/invite`、`GET /v1/tasks/telegram/start`(protected)、`POST /tg/webhook`(public);把 reward svc 注入 auth(SetReferralHook)+ pay webhook(SetRewarder)。 + +- [ ] **Step 1: 加 env + 构造(在 codesSvc 之后、authHandler 装配之前调整顺序)** + +在 `codesSvc := codes.NewService(...)`(main.go:317)之后加: + +```go + // ── Reward(邀请奖励 + 奖励任务)────────────────────────────────────────── + regCap := 10 + if v := os.Getenv("INVITE_REG_MONTHLY_CAP"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + regCap = n + } + } + rewardStore := reward.NewStore(sqlDB) + rewardSvc := reward.NewService(sqlDB, rewardStore, codesSvc, + reward.Config{RegDays: 3, PaidDays: 7, TgDays: 3, RegMonthlyCap: regCap}, nil) + rewardSvc.SetRedis(rdb) + tgBotToken := os.Getenv("TG_REWARD_BOT_TOKEN") + tgEnabled := tgBotToken != "" + if tgEnabled { + rewardSvc.SetTelegram( + getenvDefault("TG_REWARD_BOT_USER", "pangolin_reward_bot"), + getenvDefault("TG_REWARD_CHANNEL", "@pangolin_app"), + tgBotToken, os.Getenv("TG_WEBHOOK_SECRET")) + } + rewardHandler := reward.NewHandler(rewardSvc, rewardStore, tgEnabled, + getenvDefault("TG_REWARD_CHANNEL", "@pangolin_app")) +``` + +(`strconv` 需 import;`reward` 包 import `github.com/wangjia/pangolin/server/internal/reward`。) + +- [ ] **Step 2: 注入 auth + pay webhook** + +auth 装配处(main.go:297-300)后加(authSvc 在作用域内即可;若 authSvc 是 if 内局部,把注入放进同一 if): + +```go + authSvc.SetReferralHook(rewardSvc) +``` + +pay webhook 装配处(main.go:334)后加: + +```go + payWebhook.SetRewarder(rewardSvc) +``` + +> 注意作用域:`authSvc`/`payWebhook` 在各自 if 块内构造。把上面两行放进对应 if 块末尾(webhook 仅 PAY_BASE_URL 配置时存在)。 + +- [ ] **Step 3: 挂路由** + +protected group(main.go:427 内,与 pay 段并列)加: + +```go + protected.Get("/invite", rewardHandler.GetInvite) + protected.Get("/tasks/telegram/start", rewardHandler.TelegramStart) +``` + +public v1(与 `/webhook/pay` 并列,main.go:420 附近)加: + +```go + v1.Post("/tg/webhook", rewardHandler.TelegramWebhook) +``` + +- [ ] **Step 4: 编译 + vet** + +Run: `cd server && go build ./... && go vet ./cmd/server/` +Expected: 无错。 + +- [ ] **Step 5: 全量测试** + +Run: `cd server && go test ./...` +Expected: PASS。 + +- [ ] **Step 6: Commit** + +```bash +git add server/cmd/server/main.go +git commit -m "feat(server): 装配 reward 服务 — 注入 auth/webhook + 挂 /v1/invite、/tasks/telegram/start、/tg/webhook" +``` + +--- + +### Task 11: 客户端 invite api + provider + +**Files:** +- Create: `client/lib/services/invite_api.dart` +- Create: `client/lib/state/invite_provider.dart` +- Modify: `client/lib/services/auth_api.dart`(register 加 inviteCode) +- Test: `client/test/unit/invite_api_test.dart` + +**Interfaces:** +- Consumes: `apiClientProvider`(`client/lib/state/account_providers.dart`)。 +- Produces: + - `class InviteInfo { code, link, invited, converted, earnedDays; bool tgEnabled, tgJoined; String channel; fromJson }` + - `class InviteApi { InviteApi(this._c); Future fetch(); Future telegramStartLink(); }` + - `inviteApiProvider`、`inviteProvider`(AsyncNotifierProvider)。 + +- [ ] **Step 1: 写失败测试** `client/test/unit/invite_api_test.dart` + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:pangolin_vpn/services/api_client.dart'; +import 'package:pangolin_vpn/services/invite_api.dart'; + +ApiClient _c(MockClient m) => ApiClient(baseUrl: 'http://x', getToken: () => 't', refresh: () async => false, client: m); + +void main() { + test('fetch 解析邀请信息 + TG 任务态', () async { + final api = InviteApi(_c(MockClient((req) async { + expect(req.url.path, '/v1/invite'); + return http.Response('{"invite_code":"ABCD2345","invite_link":"https://x/i/ABCD2345",' + '"invited":3,"converted":1,"earned_days":16,' + '"telegram":{"enabled":true,"joined":false,"channel":"@pangolin_app"}}', 200); + }))); + final info = await api.fetch(); + expect(info.code, 'ABCD2345'); + expect(info.invited, 3); + expect(info.earnedDays, 16); + expect(info.tgEnabled, true); + expect(info.tgJoined, false); + }); +} +``` + +- [ ] **Step 2: 跑确认失败** + +Run: `cd client && flutter test test/unit/invite_api_test.dart` +Expected: FAIL(`invite_api.dart` 不存在)。 + +- [ ] **Step 3: 实现** `client/lib/services/invite_api.dart` + +```dart +// invite_api.dart — 邀请/奖励任务代理端点封装(JWT 经 ApiClient 自动注入)。 +import 'api_client.dart'; + +class InviteInfo { + const InviteInfo({ + required this.code, required this.link, required this.invited, + required this.converted, required this.earnedDays, + required this.tgEnabled, required this.tgJoined, required this.channel, + }); + final String code, link, channel; + final int invited, converted, earnedDays; + final bool tgEnabled, tgJoined; + + factory InviteInfo.fromJson(Map j) { + final tg = (j['telegram'] as Map?) ?? const {}; + return InviteInfo( + code: j['invite_code'] as String? ?? '', + link: j['invite_link'] as String? ?? '', + invited: (j['invited'] as num?)?.toInt() ?? 0, + converted: (j['converted'] as num?)?.toInt() ?? 0, + earnedDays: (j['earned_days'] as num?)?.toInt() ?? 0, + tgEnabled: tg['enabled'] as bool? ?? false, + tgJoined: tg['joined'] as bool? ?? false, + channel: tg['channel'] as String? ?? '', + ); + } +} + +class InviteApi { + InviteApi(this._c); + final ApiClient _c; + Future fetch() async => InviteInfo.fromJson(await _c.getJson('/v1/invite')); + Future telegramStartLink() async => + (await _c.getJson('/v1/tasks/telegram/start'))['deep_link'] as String? ?? ''; +} +``` + +> 顶部那行 `import ... if (dart.library.io)` 是误写,删掉,只保留 `import 'api_client.dart';`。 + +`client/lib/state/invite_provider.dart`(照 account_providers.dart 装配): + +```dart +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../services/invite_api.dart'; +import 'account_providers.dart'; + +final inviteApiProvider = Provider((ref) => InviteApi(ref.watch(apiClientProvider))); + +class InviteNotifier extends AsyncNotifier { + @override + Future build() async { + // 未登录返回 null(不打网络);已登录拉取。 + final token = ref.watch(authProvider).accessToken; + if (token == null || token.isEmpty) return null; + return ref.read(inviteApiProvider).fetch(); + } + + Future refresh() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(() => ref.read(inviteApiProvider).fetch()); + } +} + +final inviteProvider = AsyncNotifierProvider(InviteNotifier.new); +``` + +> `authProvider` import 来自 account_providers.dart 已 re-export 或直接 `import '../state/auth_provider.dart'` 取 `authProvider`;以本项目实际路径为准(account_providers.dart 里 `ref.read(authProvider)` 已能用,照抄其 import)。 + +`client/lib/services/auth_api.dart` 的 register 加 `String? inviteCode` 并入 body: + +```dart +Future register({required String email, required String code, required String password, + Map? device, String? inviteCode}) async { + final body = { + 'email': email, 'code': code, 'password': password, + if (device != null) 'device': device, + if (inviteCode != null && inviteCode.isNotEmpty) 'invite_code': inviteCode, + }; + // ... 其余 POST /v1/auth/register 逻辑不变 ... +} +``` + +- [ ] **Step 4: 跑确认通过** + +Run: `cd client && flutter test test/unit/invite_api_test.dart` +Expected: PASS。 + +- [ ] **Step 5: Commit** + +```bash +git add client/lib/services/invite_api.dart client/lib/state/invite_provider.dart client/lib/services/auth_api.dart client/test/unit/invite_api_test.dart +git commit -m "feat(client): invite api + provider + auth_api.register 加 inviteCode" +``` + +--- + +### Task 12: 客户端 invite_page 真实化 + l10n + +**Files:** +- Modify: `client/lib/screens/invite_page.dart` +- Modify: `client/lib/l10n/app_text.dart` + `client/lib/l10n/strings_{zh,en,es,ja,ko,ru}.dart` +- Test: `client/test/widget/invite_page_test.dart` + +**Interfaces:** +- Consumes: `inviteProvider`、`InviteInfo`(Task 11)。 + +- [ ] **Step 1: 加 l10n string(先加,供页面引用)** —— `app_text.dart` 加抽象 getter: + +```dart +String get inviteTasksTitle; // 更多得会员 / More ways to earn +String get inviteJoinTgTitle; // 加入 Telegram 频道 +3 天 +String get inviteJoinTgBtn; // 加入频道 +String get inviteVerifyBtn; // 验证领取 +String get inviteTaskDone; // 已领 +3 天 +String get inviteStatInvited; // 已邀请 +String get inviteStatConverted; // 已转化 +String get inviteStatDays; // 累计获赠(天) +String get inviteCodeFieldLabel; // 邀请码(选填) +``` + +在 6 个 `strings_*.dart` 各加实现(zh 示例): + +```dart +@override String get inviteTasksTitle => '更多得会员'; +@override String get inviteJoinTgTitle => '加入 Telegram 频道'; +@override String get inviteJoinTgBtn => '加入频道'; +@override String get inviteVerifyBtn => '验证领取'; +@override String get inviteTaskDone => '已领 +3 天'; +@override String get inviteStatInvited => '已邀请'; +@override String get inviteStatConverted => '已转化'; +@override String get inviteStatDays => '累计获赠(天)'; +@override String get inviteCodeFieldLabel => '邀请码(选填)'; +``` + +en:`'More ways to earn' / 'Join Telegram channel' / 'Join channel' / 'Verify & claim' / 'Claimed +3 days' / 'Invited' / 'Converted' / 'Days earned' / 'Invite code (optional)'`。es/ja/ko/ru 同法译(照该文件既有条目语气)。 + +- [ ] **Step 2: 写失败 widget 测试** `client/test/widget/invite_page_test.dart` + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pangolin_vpn/l10n/strings_en.dart'; +import 'package:pangolin_vpn/screens/invite_page.dart'; +import 'package:pangolin_vpn/services/invite_api.dart'; +import 'package:pangolin_vpn/state/invite_provider.dart'; + +void main() { + testWidgets('邀请页显示真实码 + 战绩 + TG 任务卡', (tester) async { + const info = InviteInfo(code: 'ABCD2345', link: 'https://x/i/ABCD2345', + invited: 3, converted: 1, earnedDays: 16, tgEnabled: true, tgJoined: false, channel: '@pangolin_app'); + await tester.pumpWidget(ProviderScope( + overrides: [inviteProvider.overrideWith(() => _FakeNotifier(info))], + child: MaterialApp(home: Scaffold(body: InviteScreen(t: StringsEn()))), + )); + await tester.pumpAndSettle(); + expect(find.text('ABCD2345'), findsOneWidget); + expect(find.text('Join channel'), findsOneWidget); // TG 任务卡 + }); +} + +class _FakeNotifier extends InviteNotifier { + _FakeNotifier(this._v); + final InviteInfo _v; + @override + Future build() async => _v; +} +``` + +- [ ] **Step 3: 实现 invite_page.dart** —— 删占位常量,`build` 里 `ref.watch(inviteProvider)`,`.when(...)`:code/link 用真实值填 `_CopyCard`;下方战绩三格(invited/converted/earnedDays)用 `t.inviteStatInvited/Converted/Days`;`info.tgEnabled` 为真时渲染 TG 任务卡:未 join 显「加入频道」(url_launcher 打开 `https://t.me/`)+「验证领取」(调 `inviteApiProvider.telegramStartLink()` → url_launcher 打开 deep_link),已 join 显 `t.inviteTaskDone` 置灰。loading→CircularProgressIndicator,error/null→回退占位文案。保留现有 `_CopyCard`/`_copy` 组件。 + +(完整代码依 §7 UI 规格 + 现有 `_CopyCard` 组件填充;颜色一律 `context.pangolin` token,禁硬编码。) + +- [ ] **Step 4: 跑测试 + analyze** + +Run: `cd client && flutter test test/widget/invite_page_test.dart && flutter analyze lib/screens/invite_page.dart lib/l10n/` +Expected: PASS + No issues。 + +- [ ] **Step 5: Commit** + +```bash +git add client/lib/screens/invite_page.dart client/lib/l10n/ client/test/widget/invite_page_test.dart +git commit -m "feat(client/invite): 邀请页真实化(邀请区+战绩+TG 任务卡)+ 六语 l10n" +``` + +--- + +### Task 13: 注册页加邀请码输入 + deep-link 预填(可选后置) + +**Files:** +- Modify: `client/lib/widgets/auth_screen.dart` +- (可选)Add dep: `app_links` + 4 端原生配置 +- Test: `client/test/widget/auth_invite_field_test.dart` + +**Interfaces:** +- Consumes: `auth_api.register(..., inviteCode)`(Task 11)。 + +- [ ] **Step 1: 写失败测试** `client/test/widget/auth_invite_field_test.dart` —— 断言注册表单 step1(设密码步)存在「邀请码(选填)」输入框(`find.widgetWithText(TextField, ...)` 或按 key `find.byKey(const Key('invite-code-field'))`)。 + +- [ ] **Step 2: 跑确认失败** + +Run: `cd client && flutter test test/widget/auth_invite_field_test.dart` +Expected: FAIL。 + +- [ ] **Step 3: 实现** —— `auth_screen.dart`:加 `final _invite = TextEditingController();`(随 `_email/_pw` 一起 dispose);在注册 step1 表单(`_register`,:413)密码框下方加一个可选 `TextField`(key `Key('invite-code-field')`,label `t.inviteCodeFieldLabel`);`_doRegister`(:102)把 `inviteCode: _invite.text.trim()` 传入 `_api.register(...)`。登录路径不加。 + +- [ ] **Step 4: 跑测试 + analyze** + +Run: `cd client && flutter test test/widget/auth_invite_field_test.dart && flutter analyze lib/widgets/auth_screen.dart` +Expected: PASS + No issues。 + +- [ ] **Step 5: Commit** + +```bash +git add client/lib/widgets/auth_screen.dart client/test/widget/auth_invite_field_test.dart +git commit -m "feat(client/auth): 注册页加邀请码(选填)输入,提交时传 invite_code" +``` + +- [ ] **Step 6(可选,deep-link 预填):** 加 `app_links` 依赖 + Android intent-filter / iOS associated domains / macOS URL scheme,监听 `pangolin.yanmeiai.com/i/` 或自定义 scheme,启动时把 code 预填进注册页并锁定。**此步涉四端原生配置,单列为独立后续任务;MVP 不含**。 + +--- + +## Task 14: 计划 HTML 阅读版 + 索引登记 + +**Files:** +- Create: `docs/invite-task-rewards-plan.html`(本 md 的 HTML 阅读版,抄 design 家族样式) +- Modify: `docs/index.html`(「实现计划 / Plans」区加一条) + +- [ ] **Step 1:** 按项目规范(设计/计划双产物)生成 HTML 阅读版,登记进 `docs/index.html` 的实现计划分类,与设计文档互链。 +- [ ] **Step 2: Commit** + +```bash +git add docs/invite-task-rewards-plan.html docs/index.html +git commit -m "docs: 邀请奖励实现计划 HTML 阅读版 + 索引登记" +``` + +--- + +## 验收(端到端) + +- 后端:`cd server && go test ./...` 全绿(含 reward 包 + auth/pay 回归)。 +- 客户端:`cd client && flutter analyze && flutter test` 全绿。 +- 真机联调(需 TG 新基建就绪):A 注册拿邀请码 → B 用 A 的码注册 → A/B 各 +3;B 用人民币下一单付成 → A/B 各再 +7;B 在 App 点「加入频道」+「验证领取」→ bot 校验成员 → +3;重复领被拒。 +- 防刷:自邀请无关系;同设备第二账号注册奖励被拒(status=rejected);同一 telegram_id 换账户领被拒。 + +## 不在本轮(YAGNI) + +- 现金/提现、多级分销、退群回收、注册后补填邀请码。 +- deep-link 自动预填(Task 13 Step 6 单列后续)。 +- 通知集成(奖励事件接缝留给 Spec ③)。 diff --git a/docs/superpowers/plans/2026-07-13-notifications.md b/docs/superpowers/plans/2026-07-13-notifications.md new file mode 100644 index 0000000..4cd3818 --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-notifications.md @@ -0,0 +1,592 @@ +# 系统通知机制(Spec ③)Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** App 内统一通知收件箱(铃铛红点 + 六类通知列表),含个人到账通知(同事务零孤儿)、nodectl 发布、发版联动与 important 邮件兜底。 + +**Architecture:** 单 `notices` 表(`user_id` NULL=广播/非空=定向)+ `users.notices_read_at` 已读水位;新 `server/internal/notices` 包(store/service/handler)替换现有 `GET /v1/notices` 空壳;生产三路(nodectl 手工、reward/pay 事务内钩子、发版脚本);客户端 noticesProvider 驱动铃铛与列表,**原型先行**统一两端通知视图。 + +**Tech Stack:** Go(chi+裸 SQL 双方言)、SMTP(复用 auth mailer 配置)、Flutter/Riverpod、shared 设计原型。 + +## Global Constraints + +- 类型集**六值恒定**:`important|feature|news|reward|version|promo`(DB CHECK/ENUM、openapi、客户端 pill、原型登记四处同集)。 +- 内容仅 **zh/en** 双语字段;客户端 ja/ko/ru/es 界面取 en 内容。 +- 已读=**水位**:`published_at > users.notices_read_at` 即未读;**不做逐条已读**。 +- 事件到账通知必须与发放**同事务**(传入 tx,不自开;失败随事务回滚)。 +- 邮件**仅** `important` 且显式 `--email`;`notices.email_sent_at` 幂等;发送失败只记日志不阻塞发布。 +- 多 DB:迁移 `server/migrations/{mysql,sqlite}/` 两套一一对应,**下一编号 000026**;裸 SQL;时间 Go 端 `time.Now().UTC()` 传 `?`,禁 NOW()/UTC_TIMESTAMP()。 +- `GET /v1/notices` 响应契约(客户端按此解析):`{"notices":[{id,type,title_zh,title_en,body_zh,body_en,link,published_at,unread}],"unread_count":N}`;列表=广播∪我的定向,过滤 `revoked_at IS NOT NULL`/`expires_at ?) +// ORDER BY published_at DESC LIMIT ? +// 水位: SELECT notices_read_at FROM users WHERE id=? (NULL→零值时间,全未读) +// unreadCount = len(items 中 published_at > 水位);Unread 同判据。 +// MarkRead: UPDATE users SET notices_read_at=? WHERE id=? +// InsertNoticeTx / InsertBroadcast: 普通 INSERT(broadcast user_id 传 NULL); +// Revoke: UPDATE notices SET revoked_at=? WHERE id=? AND revoked_at IS NULL;RowsAffected==0 → sql.ErrNoRows +// ListAdmin: 全字段倒序,includeInactive=false 时同 ListForUser 的 revoked/expired 过滤 +// MarkEmailSent: UPDATE notices SET email_sent_at=? WHERE id=? AND email_sent_at IS NULL +``` + +- [ ] **Step 4: 跑确认通过** — `go test ./internal/notices/ -v -count=1` PASS。 + +- [ ] **Step 5: Commit** `feat(server/notices): Store — 广播∪定向合并查询/已读水位/事务内插通知` + +--- + +### Task 3: notices HTTP handler + openapi 扩展 + 替换空壳 + +**Files:** +- Create: `server/internal/notices/handler.go`、`server/internal/notices/handler_test.go` +- Modify: `server/internal/httpapi/account.go`(删 ListNotices 空壳与其路由引用注释)、`server/api/openapi.yaml` +- (main.go 挂载在 Task 7 统一装配;本任务 handler 自测即可) + +**Interfaces:** +- Produces: `func NewHandler(st *Store) *Handler`;`(h *Handler) List(w,r)`;`(h *Handler) MarkRead(w,r)`。响应契约见 Global Constraints。 + +- [ ] **Step 1: 失败测试** `handler_test.go` + +```go +package notices + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/codes" +) + +func TestListAndMarkRead(t *testing.T) { + db := openDB(t); seedU(t, db, 1, "u1") + st := NewStore(db) + _, _ = st.InsertBroadcast(context.Background(), "news", "hello", "hello", "", "", "", time.Now().UTC(), nil) + h := NewHandler(st) + + req := httptest.NewRequest(http.MethodGet, "/v1/notices", nil) + req = req.WithContext(context.WithValue(req.Context(), codes.CtxKeyUserID, int64(1))) + w := httptest.NewRecorder() + h.List(w, req) + if w.Code != 200 { t.Fatalf("code=%d body=%s", w.Code, w.Body) } + var got struct { + Notices []map[string]any `json:"notices"` + UnreadCount int `json:"unread_count"` + } + _ = json.Unmarshal(w.Body.Bytes(), &got) + if len(got.Notices) != 1 || got.UnreadCount != 1 { t.Fatalf("got %+v", got) } + if got.Notices[0]["type"] != "news" || got.Notices[0]["unread"] != true { t.Fatalf("字段契约: %+v", got.Notices[0]) } + + // read → 再查 unread 清零 + req2 := httptest.NewRequest(http.MethodPost, "/v1/notices/read", nil) + req2 = req2.WithContext(context.WithValue(req2.Context(), codes.CtxKeyUserID, int64(1))) + w2 := httptest.NewRecorder() + h.MarkRead(w2, req2) + if w2.Code != 200 { t.Fatalf("read code=%d", w2.Code) } + w3 := httptest.NewRecorder() + h.List(w3, req) + _ = json.Unmarshal(w3.Body.Bytes(), &got) + if got.UnreadCount != 0 { t.Fatalf("read 后 unread_count=%d", got.UnreadCount) } +} + +func TestListUnauthorized(t *testing.T) { + db := openDB(t) + h := NewHandler(NewStore(db)) + w := httptest.NewRecorder() + h.List(w, httptest.NewRequest(http.MethodGet, "/v1/notices", nil)) + if w.Code != http.StatusUnauthorized { t.Fatalf("code=%d want 401", w.Code) } +} +``` + +- [ ] **Step 2: 跑确认失败**。 + +- [ ] **Step 3: 实现 handler.go**(uid 取 `auth.UserIDFromContext`,未登录 401 `apierr.ErrUnauthorized`;出错 500 `apierr.ErrInternal`;成功 `json.NewEncoder` 直接编码——照 reward/handler.go 惯例;limit 固定 50;MarkRead 成功回 `{"ok":true}`)。 + +- [ ] **Step 4: 删空壳**:`server/internal/httpapi/account.go` 删 `ListNotices` 方法(main.go 引用在 Task 7 一并换成 notices handler,本步后 `go build ./...` 若 main 编译失败,可临时保留空壳到 Task 7 再删——**实现者按编译通过为准选择删除时机,报告注明**)。 + +- [ ] **Step 5: openapi.yaml**:`Notice` schema 增 `type`(enum 六值)、`link`、`unread`(boolean);`/notices` 响应对象增 `unread_count`(required);新增 `/notices/read` POST(200 `{ok:true}`)。跑仓库 openapi 校验(CI 用的同款:`python3 -m openapi_spec_validator server/api/openapi.yaml` 或项目脚本,grep ci.yml 确认命令)。 + +- [ ] **Step 6: 跑绿** — `go test ./internal/notices/ -count=1` + `go build ./...`。 + +- [ ] **Step 7: Commit** `feat(server/notices): GET /v1/notices(合并+unread_count)+ POST /read + openapi 扩展` + +--- + +### Task 4: 事件钩子 — 四处到账通知(同事务) + +**Files:** +- Modify: `server/internal/reward/service.go`(OnRegister×2、OnFirstPaidTx×2、ClaimTelegram) +- Modify: `server/internal/pay/webhook.go`(settle 购买开通) +- Test: `server/internal/reward/notices_hook_sqlite_test.go`(新)+ `server/internal/pay/webhook_notice_sqlite_test.go`(新) + +**Interfaces:** +- Consumes: `notices.Store.InsertNoticeTx`(Task 2)。 +- Produces: 消费方各自定义小接口(照 Granter/Rewarder 惯例,避免 import cycle): + +```go +// reward/service.go 与 pay/webhook.go 各自声明: +type Noticer interface { + InsertNoticeTx(ctx context.Context, tx *sql.Tx, userID int64, typ, titleZH, titleEN, bodyZH, bodyEN, link string, now time.Time) error +} +// reward.Service 加字段 noticer Noticer + SetNoticer(n Noticer) +// pay.WebhookHandler 加字段 noticer Noticer + SetNoticer(n Noticer) +``` + +- [ ] **Step 1: 双语文案模板**(reward/service.go 包级私有函数;硬编码双语,非 l10n——服务端数据): + +```go +// noticeTitle 按事件渲染双语标题(reward 类通知无正文,一行标题即可)。 +func rewardNoticeTitles(kind string, days int) (zh, en string) { + switch kind { + case "invite_reg": + return fmt.Sprintf("邀请奖励 +%d 天已到账", days), fmt.Sprintf("Invite reward +%d days credited", days) + case "invite_paid": + return fmt.Sprintf("好友首购奖励 +%d 天已到账", days), fmt.Sprintf("Friend's first purchase: +%d days credited", days) + case "task_tg": + return fmt.Sprintf("任务奖励 +%d 天已到账", days), fmt.Sprintf("Task reward +%d days credited", days) + } + return "", "" +} +``` + +- [ ] **Step 2: 失败测试(reward 侧)** `notices_hook_sqlite_test.go` —— 复用本包 `openDB/seedU/newSvc` 既有 helper: + +```go +func TestOnRegister_InsertsRewardNotices(t *testing.T) { + db := openDB(t); seedU(t, db, 1, "inviter"); seedU(t, db, 2, "invitee") + s := newSvc(t, db) + s.SetNoticer(noticesStoreAdapter(t, db)) // 直接用 notices.NewStore(db)(import cycle 无:reward→notices 单向;若成环则测试内定义适配器) + code, _ := s.EnsureCode(context.Background(), 1) + s.OnRegister(context.Background(), 2, code, "dev-2") + var n int + db.QueryRow(`SELECT COUNT(*) FROM notices WHERE type='reward' AND user_id IN (1,2)`).Scan(&n) + if n != 2 { t.Fatalf("注册段应双方各一条到账通知, got %d", n) } +} + +func TestClaimTelegram_NoticeRollsBackWithGrantFailure(t *testing.T) { + // 用 failing granter(注入一个 Grant 返回错误的 fake)驱动 ClaimTelegram 失败, + // 断言 notices 零行——同事务回滚验证。fake granter 照本包 fakeChecker 模式写。 +} +``` + +(pay 侧同法:`TestSettle_InsertsPurchaseNotice` —— 复用 `newWebhookRig`,settle 成功后断言 `notices` 有一条 `type='reward'` 定向 user 的「已开通」通知;标题模板放 pay 包:`fmt.Sprintf("已开通 Pro · %d 天", days)` / `fmt.Sprintf("Pro activated · %d days", days)`。) + +- [ ] **Step 3: 跑确认失败**(SetNoticer 未定义)。 + +- [ ] **Step 4: 实现**:reward 三处、pay 一处,均在**既有事务内、Grant 成功之后**追加 `if s.noticer != nil { _ = 判断 }`——注意:**到账通知插入失败应视为该事务失败**(return err,与设计「同 COMMIT」一致),不是吞错。四处 kind/days:OnRegister→invite_reg×RegDays(双方);OnFirstPaidTx→invite_paid×PaidDays(双方);ClaimTelegram→task_tg×TgDays;settle→购买模板×item.Days。noticer 为 nil 时跳过(装配前兼容)。 + +- [ ] **Step 5: 跑绿** — `go test ./internal/reward/ ./internal/pay/ ./internal/notices/ -count=1`。 + +- [ ] **Step 6: Commit** `feat(server): 到账通知四接缝(邀请注册/首充/TG/购买开通)——与发放同事务零孤儿` + +--- + +### Task 5: notices.Service — 发布/撤回 + 邮件兜底 + +**Files:** +- Create: `server/internal/notices/service.go`、`server/internal/notices/service_test.go` + +**Interfaces:** +- Produces: + +```go +type EmailSender interface { Send(ctx context.Context, to, subject, body string) error } + +type PublishInput struct { + Type, TitleZH, TitleEN, BodyZH, BodyEN, Link string + ExpiresAt *time.Time + Email bool // 仅 important 允许 true +} +func NewService(st *Store, mailer EmailSender, audit AuditWriter) *Service +// AuditWriter interface { WriteAuditLog(ctx, tx *sql.Tx, actor, action, target, meta string) error } — +// 若 codes.Store 的审计方法非事务版不匹配,则 Service 直接持 *sql.DB 自写 INSERT INTO audit_log(actor,action,target,meta,at); +// 以实现最简为准,报告注明选择。 +func (s *Service) Publish(ctx context.Context, in PublishInput) (int64, error) +// 校验:type∈六值;TitleZH/EN 非空;Email==true 时 type 必须 important(否则报错)。 +// InsertBroadcast → audit(notice_publish) → Email 时:ListActiveUserEmails 逐发(失败仅 log), +// 全部尝试后 MarkEmailSent。 +func (s *Service) RevokeByID(ctx context.Context, id int64) error // Revoke + audit(notice_revoke) +``` + +- [ ] **Step 1: 失败测试**(fake EmailSender 记录收件人;audit 用真 audit_log 表断言):校验用例(type 非法/标题缺/email 配非 important 均报错);Publish 成功落库+audit 行;Email=true → fake 收到全部 active 用户邮箱、email_sent_at 置位;**再次 Publish 是新公告**(幂等的是单公告不重发,不是全局);Revoke 后 ListForUser 不可见 + audit 行。 + +- [ ] **Step 2-4: 红→实现→绿**(照签名;邮件主题 `【穿山甲】 / `,正文 zh+en 合排 + 「在 App 内查看详情」)。 + +- [ ] **Step 5: Commit** `feat(server/notices): Service — 发布校验/审计/important 邮件兜底(幂等)` + +--- + +### Task 6: nodectl notice 子命令 + +**Files:** +- Create: `server/cmd/nodectl/notice.go` +- Modify: `server/cmd/nodectl/main.go`(switch 加 `case "notice"` + usage) +- Test: `server/cmd/nodectl/notice_test.go`(参数解析/校验层单测;DB 交互经 Service 已测) + +**Interfaces:** +- Consumes: `notices.NewStore/NewService`(Task 2/5);nodectl 既有 `openDeps`(DB_DSN 直连)模式。 +- Produces: CLI: + +``` +nodectl notice add --type <六值> --title-zh … --title-en … [--body-zh …] [--body-en …] \ + [--link …] [--expires 2026-08-01] [--email] +nodectl notice list [--all] [--limit 20] +nodectl notice revoke +``` + +- [ ] **Step 1: 失败测试**:`parseNoticeAddFlags`(抽成可测纯函数:flag 集→PublishInput+错误)——type 非法/缺标题/--email 非 important/--expires 格式(YYYY-MM-DD→当日 23:59:59 UTC)各红。 +- [ ] **Step 2-4: 红→实现→绿**:`notice.go` 内 `cmdNotice(args)` 分发 add/list/revoke;add 组 PublishInput 调 Service.Publish(mailer:从 SMTP_* env 构造——**从 auth.SMTPMailer 复用配置结构**,若其不导出通用 Send,则 notices 包内实现极简 smtp 发送器(net/smtp,同 env:SMTP_HOST/SMTP_PORT/SMTP_USER/SMTP_PASS/SMTP_FROM——先 grep main.go auth mailer 装配处核对确切 env 名);无 SMTP 配置且 --email → 明确报错拒发)。list 输出表格(id/type/标题zh/发布时间/状态[active|revoked|expired]);revoke 调 RevokeByID。main.go switch 与 usage 更新。 +- [ ] **Step 5:** `go build ./... && go test ./cmd/nodectl/ -count=1` 绿。 +- [ ] **Step 6: Commit** `feat(nodectl): notice add/list/revoke 子命令(校验+审计+可选邮件)` + +--- + +### Task 7: main.go 装配 + 发版联动 + +**Files:** +- Modify: `server/cmd/server/main.go`、`server/internal/httpapi/account.go`(空壳删除若 Task 3 未删)、`scripts/ci/release-client.sh` + +- [ ] **Step 1: 装配**(codesSvc/rewardSvc 段之后): + +```go + noticesStore := notices.NewStore(sqlDB) + noticesHandler := notices.NewHandler(noticesStore) + rewardSvc.SetNoticer(noticesStore) + // payWebhook 存在时(PAY_BASE_URL 块内): + payWebhook.SetNoticer(noticesStore) +``` + +路由(protected 组):`protected.Get("/notices", noticesHandler.List)`(**替换** `accountAPI.ListNotices`)+ `protected.Post("/notices/read", noticesHandler.MarkRead)`;删 account.go 空壳。 + +- [ ] **Step 2: 发版联动** `scripts/ci/release-client.sh`:在 version.yaml 推送成功后追加(失败不阻断发版,`|| echo warn`): + +```bash +$SSH "root@${DEPLOY_HOST}" "DB_DSN=/var/lib/pangolin/pangolin.db DB_DRIVER=sqlite \ + /usr/local/bin/pangolin-nodectl notice add --type version \ + --title-zh \"v${VERSION} 已发布\" --title-en \"v${VERSION} released\" \ + --body-zh \"新版本已可更新,打开设置检查更新。\" --body-en \"A new version is available — check for updates in Settings.\"" \ + || echo "==> release-client: version notice 插入失败(不阻断发版)" +``` + +(nodectl 二进制部署名以 deploy-server.sh 实际安装名为准——现装的是 `pangolin-migrate` 等,`pangolin-nodectl` 若未部署,补进 compile-backend.sh/deploy-server.sh 的产物清单;实现者核对后调整,报告注明。) + +- [ ] **Step 3:** `go build ./... && go vet ./cmd/server/ && go test ./... -count=1` 全绿;shellcheck release-client.sh(CI 有 shellcheck 闸)。 +- [ ] **Step 4: Commit** `feat(server): 装配 notices(路由替换空壳+reward/pay 注入)+ 发版联动插 version 公告` + +--- + +### Task 8: 原型先行 — 两端通知视图统一 + 新类型示例 + +**Files:** +- Modify: `design/prototype/screens/ui-desktop.html`、`design/prototype/screens/ui-mobile.html` + +- [ ] **Step 1:** 统一形态为桌面式「类型 pill + 标题 + 相对时间 + 未读点」:**ui-mobile 的通知子屏改造**(现为 icon-box 行)对齐桌面 `.notif-row` 结构(可在 mobile 页内加等价 CSS,复用 token;放弃 icon 区分)。 +- [ ] **Step 2:** 两端各补三类新示例行:`reward`(「邀请奖励 +3 天已到账」·个人)、`version`(「v1.0.73 已发布」+「去更新」次要动作)、`promo`(「¥6 月付优惠上线」);i18n 字典补 `nt.tReward/tVersion/tPromo` 与示例文案(zh/en);两端占位文案**统一为同一组**(现状两端内容不同步,一并对齐)。 +- [ ] **Step 3:** 点击行展开正文的简单交互示意(桌面:`.notif-row.is-open .notif-body-full{display:block}` 级别即可,一条示例带正文)。 +- [ ] **Step 4:** `node design/prototype/serve.mjs 5185` 起→两文件 200→杀;新增行无裸 hex。 +- [ ] **Step 5: Commit** `design(prototype): 通知视图两端统一(pill式)+ reward/version/promo 示例与展开态` + +--- + +### Task 9: 客户端 notices api + provider + +**Files:** +- Create: `client/lib/services/notices_api.dart`、`client/lib/state/notices_provider.dart` +- Test: `client/test/unit/notices_api_test.dart` + +**Interfaces:** +- Produces: + +```dart +class NoticeItem { + final int id; final String type, titleZh, titleEn, bodyZh, bodyEn, link; + final DateTime? publishedAt; final bool unread; + // fromJson 安全默认(缺字段不 crash);title(AppLang) => lang==zh ? titleZh : titleEn(en 兜底) +} +class NoticesData { final List items; final int unreadCount; } +class NoticesApi { + NoticesApi(this._c); + Future fetch(); // GET /v1/notices + Future markRead(); // POST /v1/notices/read +} +final noticesApiProvider = Provider((ref) => NoticesApi(ref.watch(apiClientProvider))); +final noticesProvider = AsyncNotifierProvider(NoticesNotifier.new); +// NoticesNotifier.build: 未登录 null;已登录延迟 2s 首拉(照 update_provider 的 Timer 模式); +// refresh();markAllRead()(调 api.markRead 后本地把 unreadCount 置 0、items unread 置 false)。 +// 回前台重拉:WidgetsBindingObserver 在页面/壳层触发 refresh —— 由 Task 10 的消费方调,provider 只暴露 refresh。 +``` + +- [ ] **Step 1: 失败测试**(MockClient 走 ApiClient,照 invite_api_test 模式):fetch 解析(2 条、unread_count、类型/双语字段、缺字段安全);markRead 打 POST 路径。 +- [ ] **Step 2-4: 红→实现→绿**;`flutter analyze` 新文件 0 issue。 +- [ ] **Step 5: Commit** `feat(client): notices api + provider(未读计数/markAllRead/延迟首拉)` + +--- + +### Task 10: 通知页真实化 + 铃铛红点接 provider + l10n + +**Files:** +- Modify: `client/lib/screens/notifications_page.dart`、`client/lib/widgets/content_top_bar.dart`、`client/lib/screens/account_page.dart`、`client/lib/l10n/app_text.dart` + `strings_{zh,en,ja,ko,ru,es}.dart` +- Test: `client/test/widget/notifications_page_test.dart`(新) + +- [ ] **Step 1: l10n**:`app_text.dart` 加 `notifTypeReward/notifTypeVersion/notifTypePromo` 抽象 getter,6 个 strings 文件各加实现(zh:到账/版本/活动;en:Credited/Update/Promo;ja/ko/ru/es 真实翻译,照现有三标签语气)。 +- [ ] **Step 2: 失败 widget 测试**:override noticesProvider 注入固定 NoticesData(3 条含一条 unread reward),断言:类型 pill 文案在、未读点在、进入页面触发 markAllRead(fake notifier 记录调用)。 +- [ ] **Step 3: notifications_page 真实化**:删 `_Notice` record 与静态数据;`ref.watch(noticesProvider)` 三态(loading 圈/空→notifEmpty/error→loadFailedRetry);行=pill(type→六标签映射)+ title(按 t.lang 取 zh/en)+ `relativeTime(now-publishedAt)` + 未读点;点击行展开 body(纯文本渲染,空 body 则不展开);version 类行尾「去更新」按钮→触发现有更新流程(`update_provider` 的检查/或打开设置更新入口——以现有 update 消费点最小接法为准);`initState`/首帧后调 `markAllRead()`。 +- [ ] **Step 4: 红点接真值**:`content_top_bar.dart` 的 `NotificationBell(hasUnread: true)` 与 `account_page.dart` 同款处 → `hasUnread: (ref.watch(noticesProvider).valueOrNull?.unreadCount ?? 0) > 0`(两处都是 Consumer 上下文,确认 ref 可达;content_top_bar 若非 Consumer 则由调用方壳传入)。回前台刷新:壳层(desktop/mobile/tablet 任一公共点,首选 `main.dart` 既有 lifecycle 监听处若有,否则 notifications 页 didChangeAppLifecycleState)调 `noticesProvider.notifier.refresh()`——取最小侵入点,报告注明落点。 +- [ ] **Step 5:** `flutter analyze` 0 新增;`flutter test`(新 widget 测试 + 全量非 golden);通知页 golden 若有(desktop/tablet 套件含 notifications 屏则重录+抽查),SubScaffold/PageBody 复用不新造。 +- [ ] **Step 6: Commit** `feat(client/notices): 通知页真实化+铃铛红点接 provider+进页清读+三类型标签六语` + +--- + +### Task 11: 计划 HTML 阅读版 + 索引登记 + 设计文档互链 + +**Files:** +- Create: `docs/notifications-plan.html`(家族样式,11 任务卡分后端/客户端/文档组) +- Modify: `docs/index.html`(实现计划分类加条目)、`docs/notifications-design.html`(顶部 sub 的「配套实现计划」改为直链 notifications-plan.html) + +- [ ] **Step 1:** 生成阅读版+登记+互链;**Step 2: Commit** `docs: 通知机制实现计划 HTML 阅读版 + 索引登记 + 设计互链` + +--- + +## 验收(端到端) + +- 后端:`cd server && go test ./...` 全绿;`nodectl notice add --type news …` 后 `GET /v1/notices`(带 JWT)可见、红点计数对;`revoke` 后消失。 +- 事件:TG 领奖/邀请注册/首充/购买各触发一次 → 对应用户列表出现 reward 通知,与权益同事务。 +- 邮件:important + --email → active 用户收信一轮,email_sent_at 置位,重复 add 是新公告不误判。 +- 客户端:铃铛红点=unread_count 驱动;进通知页红点清零(另一设备登录同账号也清);version 行可跳更新;六语标签;空态正常。 +- 部署:迁移 000026 + server + nodectl 上 pangolin1;发一条真实 news 公告真机验证。 + +## 不在本轮(YAGNI) + +APNs/FCM 推送 · 逐条已读 · 通知偏好开关 · 管理后台页 · 六语内容 · 静态镜像 JSON 副本 · 邮件队列。 diff --git a/scripts/ci/compile-backend.sh b/scripts/ci/compile-backend.sh index 2a922ea..9ad48d3 100644 --- a/scripts/ci/compile-backend.sh +++ b/scripts/ci/compile-backend.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash # compile-backend.sh — cross-compile the pangolin Go control-plane binaries -# (server / agent / migrate) for the pangolin1 deploy target. CGO disabled: -# modernc.org/sqlite is pure Go, no cgo toolchain needed on the runner. -# Output: server/out/{pangolin-server,pangolin-agent,pangolin-migrate}. +# (server / agent / migrate / nodectl) for the pangolin1 deploy target. CGO +# disabled: modernc.org/sqlite is pure Go, no cgo toolchain needed on the +# runner. +# Output: server/out/{pangolin-server,pangolin-agent,pangolin-migrate,pangolin-nodectl}. # # Run inside a golang:1.25 container by .gitea/workflows/deploy-server.yml; # this script itself just runs `go build` and assumes it is invoked from the @@ -24,5 +25,8 @@ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o out/pangolin-agent ./cmd/agent echo "==> compile-backend: building pangolin-migrate" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o out/pangolin-migrate ./cmd/migrate +echo "==> compile-backend: building pangolin-nodectl" +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o out/pangolin-nodectl ./cmd/nodectl + echo "==> compile-backend: done — out/ contents:" ls -lh out/ diff --git a/scripts/ci/deploy-server.sh b/scripts/ci/deploy-server.sh index 4d56a1b..91b3414 100644 --- a/scripts/ci/deploy-server.sh +++ b/scripts/ci/deploy-server.sh @@ -1,10 +1,11 @@ #!/usr/bin/env bash # deploy-server.sh — deploy the pangolin control-plane binaries -# (pangolin-server / pangolin-agent / pangolin-migrate) to pangolin1, in the -# exact manual sequence used for F3/F4: stop → wal checkpoint → backup db → -# migrate (rollback db + restart old binary on failure) → swap binaries → -# start → healthcheck. Assumes compile-backend.sh has already produced -# server/out/{pangolin-server,pangolin-agent,pangolin-migrate}. +# (pangolin-server / pangolin-agent / pangolin-migrate / pangolin-nodectl) to +# pangolin1, in the exact manual sequence used for F3/F4: stop → wal +# checkpoint → backup db → migrate (rollback db + restart old binary on +# failure) → swap binaries → start → healthcheck. Assumes compile-backend.sh +# has already produced +# server/out/{pangolin-server,pangolin-agent,pangolin-migrate,pangolin-nodectl}. # # Usage: scripts/ci/deploy-server.sh (e.g. server-v1.2.3) # Requires env: DEPLOY_SSH_KEY (see lib-ssh.sh). @@ -36,7 +37,7 @@ SCP="scp -i ${SSH_KEY_FILE} -P ${DEPLOY_PORT} -o StrictHostKeyChecking=accept-ne echo "==> deploy-server: tag=${TAG} host=${DEPLOY_HOST}" echo "==> deploy-server: uploading binaries to ${DEPLOY_HOST}:/tmp/" -$SCP server/out/pangolin-server server/out/pangolin-agent server/out/pangolin-migrate "root@${DEPLOY_HOST}:/tmp/" +$SCP server/out/pangolin-server server/out/pangolin-agent server/out/pangolin-migrate server/out/pangolin-nodectl "root@${DEPLOY_HOST}:/tmp/" $SSH "root@${DEPLOY_HOST}" "bash -s" < release-client: version.yaml deployed to pangolin1 (/etc/pangolin/version.yaml)" + + # 站内通知:插一条 version 类型公告,提示用户有新版可更新。失败不阻断发版 + # (公告只是锦上添花,manifest 已经推送成功才是自动更新真正生效的部分)。 + echo "==> release-client: inserting version notice via pangolin-nodectl" + $SSH "root@${DEPLOY_HOST}" "DB_DSN=/var/lib/pangolin/pangolin.db DB_DRIVER=sqlite \ + /usr/local/bin/pangolin-nodectl notice add --type version \ + --title-zh \"v${VER} 已发布\" --title-en \"v${VER} released\" \ + --body-zh \"新版本已可更新,打开设置检查更新。\" --body-en \"A new version is available — check for updates in Settings.\"" \ + || echo "==> release-client: version notice 插入失败(不阻断发版)" else echo "==> release-client: DEPLOY_SSH_KEY not set — skipping live manifest push (dist/version.yaml still staged + uploaded as a release asset)" fi diff --git a/scripts/local_test.sh b/scripts/local_test.sh index 78e3d2e..7015770 100755 --- a/scripts/local_test.sh +++ b/scripts/local_test.sh @@ -16,7 +16,7 @@ set -euo pipefail # ─────────── 配置(按需改)─────────── -API_URL="http://103.119.13.48:8080" # 联调控制面;发版改这里或走默认 +API_URL="${API_URL:-http://103.119.13.48:8080}" # 联调控制面;发版走 env: API_URL=https://api.yanmeiai.com SIGN_ID="Developer ID Application: Yanmei (beijing) Technology Co., Ltd (BYL4KQHMTN)" APP_PROFILE_NAME="Pangolin App DevID" # 主 app 的 Developer ID 描述文件名 SE_PROFILE_NAME="Pangolin PacketTunnel DevID" # PacketTunnel 的描述文件名 diff --git a/server/api/openapi.yaml b/server/api/openapi.yaml index 1d494a3..6bec7fa 100644 --- a/server/api/openapi.yaml +++ b/server/api/openapi.yaml @@ -857,10 +857,10 @@ paths: /notices: get: operationId: listNotices - summary: 获取公告列表 + summary: 获取公告列表(含未读数) description: | - 返回当前有效公告。同一份内容亦以多镜像签名静态 JSON 发布,客户端优先拉静态副本, - 失败时回落到本端点。 + 返回当前用户可见的公告(全员广播 ∪ 定向),按发布时间倒序,并附带 unread_count。 + 同一份内容亦以多镜像签名静态 JSON 发布,客户端优先拉静态副本,失败时回落到本端点。 tags: [Notices] responses: "200": @@ -869,12 +869,39 @@ paths: application/json: schema: type: object - required: [notices] + required: [notices, unread_count] properties: notices: type: array items: $ref: "#/components/schemas/Notice" + unread_count: + type: integer + description: 未读公告数 + example: 2 + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/Internal" + + /notices/read: + post: + operationId: markNoticesRead + summary: 标记公告已读 + description: 将当前用户的公告已读水位推进到当前时间,清空 unread_count。 + tags: [Notices] + responses: + "200": + description: 标记成功 + content: + application/json: + schema: + type: object + required: [ok] + properties: + ok: + type: boolean + example: true "401": $ref: "#/components/responses/Unauthorized" "500": @@ -1319,12 +1346,17 @@ components: Notice: type: object - required: [id, title_zh, title_en, body_zh, body_en, published_at] + required: [id, type, title_zh, title_en, published_at, unread] properties: id: - type: string - format: uuid + type: integer + format: int64 description: 公告 UUID + type: + type: string + enum: [important, feature, news, reward, version, promo] + description: 公告类型,驱动客户端图标/分组展示 + example: news title_zh: type: string description: 公告中文标题 @@ -1339,10 +1371,17 @@ components: body_en: type: string description: 公告英文正文(Markdown) + link: + type: string + description: 公告关联链接(可选,跳转详情/活动页) published_at: type: string format: date-time description: 发布时间(UTC ISO-8601) + unread: + type: boolean + description: 相对当前用户已读水位是否未读 + example: true # ── Pay(pay v2 统一支付网关代理)────────────────────── diff --git a/server/cmd/nodectl/main.go b/server/cmd/nodectl/main.go index d1353b1..bf336ec 100644 --- a/server/cmd/nodectl/main.go +++ b/server/cmd/nodectl/main.go @@ -67,9 +67,16 @@ nodectl — elastic-node control plane CLI nodectl rotate-pool -pool=consumable|premium [-concurrency=1] nodectl providers [-pool=consumable|premium] nodectl bootstrap-token -node=UUID + nodectl notice add -type= + -title-zh=... -title-en=... [-body-zh=...] [-body-en=...] + [-link=...] [-expires=YYYY-MM-DD] [-email] + nodectl notice list [-all] [-limit=20] + nodectl notice revoke Config via env: DB_DSN, REDIS_ADDR, REDIS_PASSWORD, PROVISION_CONTROL_PLANE_URL, - PROVISION_CLOUD_INIT_TMPL, PROVISION__* credentials.`)) + PROVISION_CLOUD_INIT_TMPL, PROVISION__* credentials, + SMTP_HOST/SMTP_PORT/SMTP_USERNAME/SMTP_PASSWORD/SMTP_FROM + (notice add -email; unset = -email rejected with an error).`)) } func buildService(ctx context.Context) (*provision.Service, func(), error) { @@ -118,6 +125,8 @@ func run(ctx context.Context, cmd string, args []string) error { return cmdProviders(ctx, args) case "bootstrap-token": return cmdBootstrapToken(ctx, args) + case "notice": + return cmdNotice(ctx, args) case "-h", "--help", "help": usage() return nil diff --git a/server/cmd/nodectl/notice.go b/server/cmd/nodectl/notice.go new file mode 100644 index 0000000..935fe6c --- /dev/null +++ b/server/cmd/nodectl/notice.go @@ -0,0 +1,271 @@ +// notice.go implements `nodectl notice add/list/revoke` — the operator CLI +// front-end for the notices package (system notifications: broadcast +// announcements with an optional important-only email fallback). +// +// DB access reuses the same env-driven open pattern as buildService +// (db.Open resolves driver via DB_DRIVER, defaulting to mysql, and reads +// DB_DSN — see internal/db/db.go — so this also works against the sqlite +// single-node deploy without any extra env here). +package main + +import ( + "context" + "database/sql" + "flag" + "fmt" + "io" + "net/mail" + "net/smtp" + "os" + "strconv" + "strings" + "text/tabwriter" + "time" + + "github.com/wangjia/pangolin/server/internal/db" + "github.com/wangjia/pangolin/server/internal/notices" +) + +// noticeTypes mirrors notices.validNoticeTypes (unexported in that package, +// kept in sync manually — see migrations/sqlite/000026_notices.up.sql CHECK). +var noticeTypes = map[string]bool{ + "important": true, + "feature": true, + "news": true, + "reward": true, + "version": true, + "promo": true, +} + +func cmdNotice(ctx context.Context, args []string) error { + if len(args) == 0 { + return fmt.Errorf("notice: subcommand required (add|list|revoke)") + } + sub := args[0] + rest := args[1:] + switch sub { + case "add": + return cmdNoticeAdd(ctx, rest) + case "list": + return cmdNoticeList(ctx, rest) + case "revoke": + return cmdNoticeRevoke(ctx, rest) + default: + return fmt.Errorf("notice: unknown subcommand %q (want add|list|revoke)", sub) + } +} + +// parseNoticeAddFlags parses `nodectl notice add` flags into a +// notices.PublishInput. Kept separate from cmdNoticeAdd so validation is +// unit-testable without a DB: invalid type, missing titles, --email on a +// non-important type, and malformed --expires all fail here, before any +// Service/Store call. +func parseNoticeAddFlags(args []string) (notices.PublishInput, error) { + fs := flag.NewFlagSet("notice add", flag.ContinueOnError) + fs.SetOutput(io.Discard) + + typ := fs.String("type", "", "notice type: important|feature|news|reward|version|promo (required)") + titleZH := fs.String("title-zh", "", "Chinese title (required)") + titleEN := fs.String("title-en", "", "English title (required)") + bodyZH := fs.String("body-zh", "", "Chinese body (optional)") + bodyEN := fs.String("body-en", "", "English body (optional)") + link := fs.String("link", "", "deep link / URL (optional)") + expires := fs.String("expires", "", "expiry date YYYY-MM-DD (expires at 23:59:59 UTC that day; optional)") + email := fs.Bool("email", false, "also send an email fallback (type=important only)") + + if err := fs.Parse(args); err != nil { + return notices.PublishInput{}, fmt.Errorf("notice add: %w", err) + } + + if !noticeTypes[*typ] { + return notices.PublishInput{}, fmt.Errorf("notice add: invalid -type %q (want important|feature|news|reward|version|promo)", *typ) + } + if strings.TrimSpace(*titleZH) == "" { + return notices.PublishInput{}, fmt.Errorf("notice add: -title-zh is required") + } + if strings.TrimSpace(*titleEN) == "" { + return notices.PublishInput{}, fmt.Errorf("notice add: -title-en is required") + } + if *email && *typ != "important" { + return notices.PublishInput{}, fmt.Errorf("notice add: -email is only allowed for -type=important, got %q", *typ) + } + + in := notices.PublishInput{ + Type: *typ, + TitleZH: *titleZH, + TitleEN: *titleEN, + BodyZH: *bodyZH, + BodyEN: *bodyEN, + Link: *link, + Email: *email, + } + + if strings.TrimSpace(*expires) != "" { + day, err := time.Parse("2006-01-02", *expires) + if err != nil { + return notices.PublishInput{}, fmt.Errorf("notice add: invalid -expires %q (want YYYY-MM-DD): %w", *expires, err) + } + exp := time.Date(day.Year(), day.Month(), day.Day(), 23, 59, 59, 0, time.UTC) + in.ExpiresAt = &exp + } + + return in, nil +} + +func cmdNoticeAdd(ctx context.Context, args []string) error { + in, err := parseNoticeAddFlags(args) + if err != nil { + return err + } + + conn, closeFn, err := openNoticeDB() + if err != nil { + return err + } + defer closeFn() + + st := notices.NewStore(conn) + svc := notices.NewService(st, buildNoticeMailer(), conn) + + id, err := svc.Publish(ctx, in) + if err != nil { + return err + } + fmt.Printf("published notice id=%d type=%s\n", id, in.Type) + return nil +} + +func cmdNoticeList(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("notice list", flag.ExitOnError) + all := fs.Bool("all", false, "include revoked/expired notices") + limit := fs.Int("limit", 20, "max rows") + _ = fs.Parse(args) + + conn, closeFn, err := openNoticeDB() + if err != nil { + return err + } + defer closeFn() + + st := notices.NewStore(conn) + rows, err := st.ListAdmin(ctx, *all, *limit) + if err != nil { + return err + } + + w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) + fmt.Fprintln(w, "ID\tTYPE\tTITLE_ZH\tPUBLISHED_AT\tSTATUS") + for _, r := range rows { + fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", r.ID, r.Type, r.TitleZH, r.PublishedAt.Format(time.RFC3339), r.Status) + } + return w.Flush() +} + +func cmdNoticeRevoke(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("notice revoke", flag.ExitOnError) + _ = fs.Parse(args) + if fs.NArg() != 1 { + return fmt.Errorf("notice revoke: exactly one positional argument is required") + } + id, err := strconv.ParseInt(fs.Arg(0), 10, 64) + if err != nil { + return fmt.Errorf("notice revoke: invalid id %q: %w", fs.Arg(0), err) + } + + conn, closeFn, err := openNoticeDB() + if err != nil { + return err + } + defer closeFn() + + st := notices.NewStore(conn) + svc := notices.NewService(st, buildNoticeMailer(), conn) + if err := svc.RevokeByID(ctx, id); err != nil { + return err + } + fmt.Printf("revoked notice id=%d\n", id) + return nil +} + +// openNoticeDB opens the control-plane DB the same way buildService does: +// db.Open(dsn) resolves the driver internally via DB_DRIVER (defaulting to +// mysql; sqlite for single-node deploys) — see internal/db/db.go — so no +// extra driver plumbing is needed here. +func openNoticeDB() (*sql.DB, func(), error) { + dsn := os.Getenv("DB_DSN") + if dsn == "" { + return nil, nil, fmt.Errorf("DB_DSN is required") + } + conn, err := db.Open(dsn) + if err != nil { + return nil, nil, err + } + return conn, func() { conn.Close() }, nil +} + +// buildNoticeMailer constructs a notices.EmailSender from the same SMTP_* +// env vars cmd/server/main.go uses to wire auth.SMTPMailer (SMTP_HOST, +// SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD, SMTP_FROM). auth.SMTPMailer +// itself isn't reused because it only implements auth.Mailer's +// SendCode/SendAlreadyRegistered, not notices.EmailSender's generic +// Send(to, subject, body) — so this is a small parallel net/smtp sender. +// Returns a nil (untyped) notices.EmailSender when SMTP_HOST is unset; +// Service.Publish then explicitly rejects -email requests ("no mailer +// configured") rather than silently dropping them. +func buildNoticeMailer() notices.EmailSender { + host := os.Getenv("SMTP_HOST") + if host == "" { + return nil + } + port := 587 + if p := os.Getenv("SMTP_PORT"); p != "" { + if v, err := strconv.Atoi(p); err == nil { + port = v + } + } + from := os.Getenv("SMTP_FROM") + if from == "" { + from = "no-reply@pangolin.app" + } + return &smtpNoticeMailer{ + host: host, + port: port, + username: os.Getenv("SMTP_USERNAME"), + password: os.Getenv("SMTP_PASSWORD"), + from: from, + } +} + +// smtpNoticeMailer is a minimal notices.EmailSender over net/smtp, mirroring +// auth.SMTPMailer's STARTTLS/PlainAuth send path (internal/auth/mailer.go) +// but with a generic subject/body instead of fixed verification-code copy. +type smtpNoticeMailer struct { + host, username, password, from string + port int +} + +func (m *smtpNoticeMailer) envelopeFrom() string { + if a, err := mail.ParseAddress(m.from); err == nil { + return a.Address + } + return m.from +} + +func (m *smtpNoticeMailer) Send(_ context.Context, to, subject, body string) error { + msg := strings.Join([]string{ + "From: " + m.from, + "To: " + to, + "Subject: " + subject, + "MIME-Version: 1.0", + "Content-Type: text/plain; charset=UTF-8", + "", + body, + }, "\r\n") + + addr := fmt.Sprintf("%s:%d", m.host, m.port) + auth := smtp.PlainAuth("", m.username, m.password, m.host) + if err := smtp.SendMail(addr, auth, m.envelopeFrom(), []string{to}, []byte(msg)); err != nil { + return fmt.Errorf("nodectl: smtp send: %w", err) + } + return nil +} diff --git a/server/cmd/nodectl/notice_test.go b/server/cmd/nodectl/notice_test.go new file mode 100644 index 0000000..4ca1123 --- /dev/null +++ b/server/cmd/nodectl/notice_test.go @@ -0,0 +1,136 @@ +package main + +import ( + "testing" + "time" +) + +// TestParseNoticeAddFlags_Valid verifies the happy path parses all fields, +// including --expires → 23:59:59 UTC on the given day. +func TestParseNoticeAddFlags_Valid(t *testing.T) { + in, err := parseNoticeAddFlags([]string{ + "-type", "important", + "-title-zh", "维护通知", + "-title-en", "Maintenance notice", + "-body-zh", "今晚维护", + "-body-en", "Maintenance tonight", + "-link", "https://example.com/notice/1", + "-expires", "2026-08-01", + "-email", + }) + if err != nil { + t.Fatalf("parseNoticeAddFlags: unexpected error: %v", err) + } + if in.Type != "important" { + t.Errorf("Type = %q, want important", in.Type) + } + if in.TitleZH != "维护通知" || in.TitleEN != "Maintenance notice" { + t.Errorf("titles = %q/%q", in.TitleZH, in.TitleEN) + } + if !in.Email { + t.Errorf("Email = false, want true") + } + if in.ExpiresAt == nil { + t.Fatalf("ExpiresAt is nil, want set") + } + want := time.Date(2026, 8, 1, 23, 59, 59, 0, time.UTC) + if !in.ExpiresAt.Equal(want) { + t.Errorf("ExpiresAt = %v, want %v", in.ExpiresAt, want) + } +} + +// TestParseNoticeAddFlags_NoExpires verifies omitting --expires leaves it nil. +func TestParseNoticeAddFlags_NoExpires(t *testing.T) { + in, err := parseNoticeAddFlags([]string{ + "-type", "feature", + "-title-zh", "新功能", + "-title-en", "New feature", + }) + if err != nil { + t.Fatalf("parseNoticeAddFlags: unexpected error: %v", err) + } + if in.ExpiresAt != nil { + t.Errorf("ExpiresAt = %v, want nil", in.ExpiresAt) + } +} + +// TestParseNoticeAddFlags_InvalidType is red case 1: -type outside the six +// permitted values must fail before any Service/Store call. +func TestParseNoticeAddFlags_InvalidType(t *testing.T) { + _, err := parseNoticeAddFlags([]string{ + "-type", "bogus", + "-title-zh", "标题", + "-title-en", "Title", + }) + if err == nil { + t.Fatal("parseNoticeAddFlags: expected error for invalid -type, got nil") + } +} + +// TestParseNoticeAddFlags_MissingTitle is red case 2: missing -title-zh or +// -title-en must fail. +func TestParseNoticeAddFlags_MissingTitle(t *testing.T) { + cases := []struct { + name string + args []string + }{ + { + name: "missing title-zh", + args: []string{"-type", "important", "-title-en", "Title"}, + }, + { + name: "missing title-en", + args: []string{"-type", "important", "-title-zh", "标题"}, + }, + { + name: "missing both", + args: []string{"-type", "important"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := parseNoticeAddFlags(tc.args) + if err == nil { + t.Fatalf("parseNoticeAddFlags(%v): expected error for missing title, got nil", tc.args) + } + }) + } +} + +// TestParseNoticeAddFlags_EmailNonImportant is red case 3: -email is only +// allowed when -type=important. +func TestParseNoticeAddFlags_EmailNonImportant(t *testing.T) { + _, err := parseNoticeAddFlags([]string{ + "-type", "feature", + "-title-zh", "标题", + "-title-en", "Title", + "-email", + }) + if err == nil { + t.Fatal("parseNoticeAddFlags: expected error for -email on non-important type, got nil") + } +} + +// TestParseNoticeAddFlags_BadExpiresFormat is red case 4: -expires must be +// YYYY-MM-DD; other formats (slashes, timestamps, garbage) must fail. +func TestParseNoticeAddFlags_BadExpiresFormat(t *testing.T) { + cases := []string{ + "2026/08/01", + "08-01-2026", + "2026-08-01T00:00:00Z", + "not-a-date", + } + for _, expires := range cases { + t.Run(expires, func(t *testing.T) { + _, err := parseNoticeAddFlags([]string{ + "-type", "important", + "-title-zh", "标题", + "-title-en", "Title", + "-expires", expires, + }) + if err == nil { + t.Fatalf("parseNoticeAddFlags(-expires=%q): expected error, got nil", expires) + } + }) + } +} diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index d91f3cf..b7dccde 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -33,11 +33,13 @@ import ( "github.com/wangjia/pangolin/server/internal/httpapi" "github.com/wangjia/pangolin/server/internal/mtls" "github.com/wangjia/pangolin/server/internal/nodes" + "github.com/wangjia/pangolin/server/internal/notices" "github.com/wangjia/pangolin/server/internal/pay" agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1" "github.com/wangjia/pangolin/server/internal/provision" "github.com/wangjia/pangolin/server/internal/provision/providers" "github.com/wangjia/pangolin/server/internal/redisutil" + "github.com/wangjia/pangolin/server/internal/reward" "github.com/wangjia/pangolin/server/internal/scheduler" "github.com/wangjia/pangolin/server/internal/scheduler/probe" "github.com/wangjia/pangolin/server/internal/sessions" @@ -279,6 +281,8 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv // ── Auth ────────────────────────────────────────────────────────────────── var authHandler *auth.Handler + var authSvc *auth.Service + var authStore *auth.SQLStore if tm != nil { var mailer auth.Mailer if smtpHost := os.Getenv("SMTP_HOST"); smtpHost != "" { @@ -293,8 +297,8 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv mailer = auth.NewLogMailer(nil) // dev: code printed to log } rl := auth.NewRateLimiter(rdb, nil) - authStore := auth.NewSQLStore(sqlDB) - authSvc := auth.NewService(authStore, rdb, rl, tm, mailer, auth.ServiceConfig{}, nil) + authStore = auth.NewSQLStore(sqlDB) + authSvc = auth.NewService(authStore, rdb, rl, tm, mailer, auth.ServiceConfig{}, nil) authSvc.SetDeviceRegistrar(authDeviceRegistrar{svc: devicesSvc}) authSvc.SetSessionStore(sessionStore) authHandler = auth.NewHandler(authSvc) @@ -319,6 +323,43 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv webhookHandler := codes.NewWebhookHandler(codesStore, rdb, os.Getenv("WEBHOOK_SECRET"), 5*time.Minute, 15*time.Minute) + // ── Reward(邀请奖励 + 奖励任务)────────────────────────────────────────── + regCap := 10 + if v := os.Getenv("INVITE_REG_MONTHLY_CAP"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + regCap = n + } + } + rewardStore := reward.NewStore(sqlDB) + rewardSvc := reward.NewService(sqlDB, rewardStore, codesSvc, + reward.Config{RegDays: 3, PaidDays: 7, TgDays: 3, RegMonthlyCap: regCap}, nil) + rewardSvc.SetRedis(rdb) + tgBotToken := os.Getenv("TG_REWARD_BOT_TOKEN") + tgEnabled := tgBotToken != "" + if tgEnabled { + tgWebhookSecret := os.Getenv("TG_WEBHOOK_SECRET") + if tgWebhookSecret == "" { + log.Fatal("TG_REWARD_BOT_TOKEN 已设置但 TG_WEBHOOK_SECRET 为空,/v1/tg/webhook 将无鉴权,拒绝启动") + } + rewardSvc.SetTelegram( + getenvDefault("TG_REWARD_BOT_USER", "pangolin_reward_bot"), + getenvDefault("TG_REWARD_CHANNEL", "@pangolin_app"), + tgBotToken, tgWebhookSecret) + } + rewardHandler := reward.NewHandler(rewardSvc, rewardStore, tgEnabled, + getenvDefault("TG_REWARD_CHANNEL", "@pangolin_app")) + if authSvc != nil { + authSvc.SetReferralHook(rewardSvc) + } + + // ── Notices(站内通知;reward/pay 到账钩子注入)──────────────────────────── + noticesStore := notices.NewStore(sqlDB) + noticesHandler := notices.NewHandler(noticesStore) + rewardSvc.SetNoticer(noticesStore) + if authStore != nil { + authStore.SetNoticer(noticesStore) + } + // ── Pay(pay v2 统一支付网关;PAY_BASE_URL 未配则整组不挂载)────────────── var payHandler *pay.Handler var payWebhook *pay.WebhookHandler @@ -333,6 +374,8 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv payHandler = pay.NewHandler(payClient, payStore, sqlDB) payWebhook = pay.NewWebhookHandler(payStore, codesSvc, sqlDB, rdb, paySystem, paySecret, 5*time.Minute, 15*time.Minute) + payWebhook.SetRewarder(rewardSvc) + payWebhook.SetNoticer(noticesStore) } else { log.Printf("PAY_BASE_URL 未配置 — /v1/pay 支付端点不挂载") } @@ -421,6 +464,7 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv if payWebhook != nil { v1.Post("/webhook/pay", payWebhook.ServeHTTP) } + v1.Post("/tg/webhook", rewardHandler.TelegramWebhook) // Protected: all routes that require a valid Bearer JWT. if tm != nil { @@ -449,14 +493,18 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv protected.Get("/usage/devices", deviceUsageHandler.ServeHTTP) protected.Post("/ads/unlock", adsHandler.ServeHTTP) protected.Get("/plans", accountAPI.ListPlans) - protected.Get("/notices", accountAPI.ListNotices) + protected.Get("/notices", noticesHandler.List) + protected.Post("/notices/read", noticesHandler.MarkRead) if payHandler != nil { protected.Get("/pay/catalog", payHandler.Catalog) + protected.Get("/pay/orders", payHandler.List) protected.Post("/pay/orders", payHandler.CreateOrder) protected.Get("/pay/orders/{orderNo}", payHandler.GetOrder) protected.Post("/pay/orders/{orderNo}/retry", payHandler.Retry) protected.Post("/pay/orders/{orderNo}/cancel", payHandler.Cancel) } + protected.Get("/invite", rewardHandler.GetInvite) + protected.Get("/tasks/telegram/start", rewardHandler.TelegramStart) if nodeAPI != nil { protected.Get("/nodes", nodeAPI.ListNodes) diff --git a/server/internal/auth/device_register_test.go b/server/internal/auth/device_register_test.go index af37572..6c3924c 100644 --- a/server/internal/auth/device_register_test.go +++ b/server/internal/auth/device_register_test.go @@ -43,7 +43,7 @@ func TestService_RegisterDevice_OnRegisterAndLogin(t *testing.T) { t.Fatalf("SendCode: %v", err) } code := codeInRedis(t, svc, email) - if _, e := svc.Register(ctx, email, code, pw, "1.2.3.4", meta); e != nil { + if _, e := svc.Register(ctx, email, code, pw, "1.2.3.4", meta, ""); e != nil { t.Fatalf("Register: %v", e) } if len(reg.calls) != 1 || reg.calls[0].meta.DeviceID != "dev-uuid-1" || reg.calls[0].meta.Platform != "macos" { @@ -79,7 +79,7 @@ func TestService_Login_SurfacesDeviceLimit(t *testing.T) { t.Fatalf("SendCode: %v", err) } code := codeInRedis(t, svc, email) - if _, e := svc.Register(ctx, email, code, pw, "", meta); e != nil { + if _, e := svc.Register(ctx, email, code, pw, "", meta, ""); e != nil { t.Fatalf("Register: %v", e) } out, _, e := svc.Login(ctx, email, pw, "", meta) @@ -104,7 +104,7 @@ func TestService_RegisterDevice_BestEffort(t *testing.T) { t.Fatalf("SendCode: %v", err) } code := codeInRedis(t, svc, email) - if _, e := svc.Register(ctx, email, code, "supersecret", "", DeviceMeta{DeviceID: "x", Platform: "windows"}); e != nil { + if _, e := svc.Register(ctx, email, code, "supersecret", "", DeviceMeta{DeviceID: "x", Platform: "windows"}, ""); e != nil { t.Fatalf("Register must succeed despite registrar error: %v", e) } } @@ -120,7 +120,7 @@ func TestService_RegisterDevice_NoMeta(t *testing.T) { t.Fatalf("SendCode: %v", err) } code := codeInRedis(t, svc, email) - if _, e := svc.Register(ctx, email, code, "supersecret", "", DeviceMeta{}); e != nil { + if _, e := svc.Register(ctx, email, code, "supersecret", "", DeviceMeta{}, ""); e != nil { t.Fatalf("Register: %v", e) } if len(reg.calls) != 0 { diff --git a/server/internal/auth/handler.go b/server/internal/auth/handler.go index 24389e6..6152ae7 100644 --- a/server/internal/auth/handler.go +++ b/server/internal/auth/handler.go @@ -61,10 +61,11 @@ func (d deviceBody) toMeta() DeviceMeta { } type registerRequest struct { - Email string `json:"email"` - Code string `json:"code"` - Password string `json:"password"` - Device deviceBody `json:"device"` + Email string `json:"email"` + Code string `json:"code"` + Password string `json:"password"` + Device deviceBody `json:"device"` + InviteCode string `json:"invite_code"` // 选填:邀请码,注册后 best-effort 归因 } type loginRequest struct { @@ -104,7 +105,7 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { if !decodeJSON(w, r, &req) { return } - pair, apiErr := h.svc.Register(r.Context(), req.Email, req.Code, req.Password, clientIP(r), req.Device.toMeta()) + pair, apiErr := h.svc.Register(r.Context(), req.Email, req.Code, req.Password, clientIP(r), req.Device.toMeta(), req.InviteCode) if apiErr != nil { writeAPIErr(w, apiErr, 0) return diff --git a/server/internal/auth/integration_test.go b/server/internal/auth/integration_test.go index e3fb5bf..015aec7 100644 --- a/server/internal/auth/integration_test.go +++ b/server/internal/auth/integration_test.go @@ -140,7 +140,7 @@ func TestIntegration_FullChain(t *testing.T) { } // 2. Register → trial subscription must exist for 7 days. - pair, apiErr := svc.Register(ctx, email, code, pw, "203.0.113.10", DeviceMeta{}) + pair, apiErr := svc.Register(ctx, email, code, pw, "203.0.113.10", DeviceMeta{}, "") if apiErr != nil { t.Fatalf("Register: %v", apiErr) } @@ -170,7 +170,7 @@ func TestIntegration_FullChain(t *testing.T) { } // Force a fresh code regardless of rate limit. _ = rdb.Set(ctx, codeKey(email), code, 10*time.Minute).Err() - if _, e := svc.Register(ctx, email, code, pw, "203.0.113.10", DeviceMeta{}); e == nil || e.Code != ErrCodeInvalid.Code { + if _, e := svc.Register(ctx, email, code, pw, "203.0.113.10", DeviceMeta{}, ""); e == nil || e.Code != ErrCodeInvalid.Code { t.Fatalf("want code_invalid (anti-enumeration), got %v", e) } diff --git a/server/internal/auth/register_invite_test.go b/server/internal/auth/register_invite_test.go new file mode 100644 index 0000000..0cd4c78 --- /dev/null +++ b/server/internal/auth/register_invite_test.go @@ -0,0 +1,43 @@ +package auth + +import ( + "context" + "testing" +) + +// fakeHook captures the arguments passed to OnRegister so tests can assert on +// them without depending on the real reward.Service. +type fakeHook struct { + gotInvitee int64 + gotCode string + gotDev string +} + +func (f *fakeHook) OnRegister(_ context.Context, inviteeID int64, code, dev string) { + f.gotInvitee, f.gotCode, f.gotDev = inviteeID, code, dev +} + +// TestRegister_CallsReferralHookWithInviteCode verifies that a successful +// registration best-effort invokes the ReferralHook with the invite code and +// this registration's device UUID (not some stale/historical value). +func TestRegister_CallsReferralHookWithInviteCode(t *testing.T) { + svc, _, _ := newService(t, ServiceConfig{}) + ctx := context.Background() + const email = "new@x.com" + + if _, err := svc.SendCode(ctx, email, "1.2.3.4"); err != nil { + t.Fatalf("SendCode: %v", err) + } + code := codeInRedis(t, svc, email) + + h := &fakeHook{} + svc.SetReferralHook(h) + + _, apiErr := svc.Register(ctx, email, code, "password123", "1.2.3.4", DeviceMeta{DeviceID: "dev-1"}, "INVCODE9") + if apiErr != nil { + t.Fatalf("Register: %v", apiErr) + } + if h.gotCode != "INVCODE9" || h.gotDev != "dev-1" || h.gotInvitee == 0 { + t.Fatalf("hook got invitee=%d code=%q dev=%q", h.gotInvitee, h.gotCode, h.gotDev) + } +} diff --git a/server/internal/auth/service.go b/server/internal/auth/service.go index e99b78a..0b77e14 100644 --- a/server/internal/auth/service.go +++ b/server/internal/auth/service.go @@ -132,12 +132,22 @@ type Service struct { now func() time.Time devReg DeviceRegistrar // nil until wired; registration is best-effort sessions SessionStore // nil until wired; session recording is best-effort + referral ReferralHook // nil until wired; referral attribution is best-effort } -// SetDeviceRegistrar / SetSessionStore wire collaborators after construction -// (main keeps auth, devices and sessions decoupled). Call once during startup. +// ReferralHook is invoked after a successful registration to attribute the new +// account to the invite code it registered with (best-effort; implemented by +// reward.Service). Defined consumer-side to avoid an import cycle. +type ReferralHook interface { + OnRegister(ctx context.Context, inviteeID int64, inviteCode, deviceUUID string) +} + +// SetDeviceRegistrar / SetSessionStore / SetReferralHook wire collaborators +// after construction (main keeps auth, devices, sessions and reward decoupled). +// Call once during startup. func (s *Service) SetDeviceRegistrar(r DeviceRegistrar) { s.devReg = r } func (s *Service) SetSessionStore(st SessionStore) { s.sessions = st } +func (s *Service) SetReferralHook(h ReferralHook) { s.referral = h } // recordLogin registers the device and binds a session to the freshly-issued // refresh JTI. Best-effort: a registrar/session error (device cap, transient DB) @@ -267,7 +277,7 @@ func (s *Service) SendCode(ctx context.Context, rawEmail, ip string) (retryAfter // Register verifies the code (one-time), creates the account plus a 7-day PRO // trial in a single transaction, and returns a fresh token pair. -func (s *Service) Register(ctx context.Context, rawEmail, code, password, ip string, device DeviceMeta) (*TokenPair, *apierr.Error) { +func (s *Service) Register(ctx context.Context, rawEmail, code, password, ip string, device DeviceMeta, inviteCode string) (*TokenPair, *apierr.Error) { email := NormalizeEmail(rawEmail) if !ValidEmail(email) || len(password) < 8 || len(code) != 6 { return nil, ErrInvalidRequest @@ -301,6 +311,9 @@ func (s *Service) Register(ctx context.Context, rawEmail, code, password, ip str return nil, ErrInternal } s.recordLogin(ctx, user.ID, jti, ip, device) + if s.referral != nil { + s.referral.OnRegister(ctx, user.ID, inviteCode, device.DeviceID) // best-effort + } return pair, nil } diff --git a/server/internal/auth/service_test.go b/server/internal/auth/service_test.go index 33edc61..9f83d64 100644 --- a/server/internal/auth/service_test.go +++ b/server/internal/auth/service_test.go @@ -40,7 +40,7 @@ func TestService_RegisterFullFlow(t *testing.T) { } code := codeInRedis(t, svc, email) - pair, apiErr := svc.Register(ctx, email, code, "supersecret", "", DeviceMeta{}) + pair, apiErr := svc.Register(ctx, email, code, "supersecret", "", DeviceMeta{}, "") if apiErr != nil { t.Fatalf("Register: %v", apiErr) } @@ -84,7 +84,7 @@ func TestService_DuplicateEmailConflict(t *testing.T) { // First registration. _, _ = svc.SendCode(ctx, email, "") - if _, e := svc.Register(ctx, email, codeInRedis(t, svc, email), "password1", "", DeviceMeta{}); e != nil { + if _, e := svc.Register(ctx, email, codeInRedis(t, svc, email), "password1", "", DeviceMeta{}, ""); e != nil { t.Fatalf("first register: %v", e) } @@ -103,7 +103,7 @@ func TestService_DuplicateEmailConflict(t *testing.T) { if err := svc.rdb.Set(ctx, codeKey(email), "654321", 10*time.Minute).Err(); err != nil { t.Fatalf("force code: %v", err) } - _, apiErr := svc.Register(ctx, email, "654321", "password2", "", DeviceMeta{}) + _, apiErr := svc.Register(ctx, email, "654321", "password2", "", DeviceMeta{}, "") if apiErr == nil || apiErr.Code != ErrCodeInvalid.Code { t.Fatalf("want code_invalid (anti-enumeration), got %v", apiErr) } @@ -115,7 +115,7 @@ func TestService_CodeWrong(t *testing.T) { const email = "wrong@example.com" _, _ = svc.SendCode(ctx, email, "") - _, apiErr := svc.Register(ctx, email, "000000", "password1", "", DeviceMeta{}) + _, apiErr := svc.Register(ctx, email, "000000", "password1", "", DeviceMeta{}, "") if apiErr == nil || apiErr.Code != ErrCodeInvalid.Code { t.Fatalf("want code_invalid, got %v", apiErr) } @@ -131,7 +131,7 @@ func TestService_CodeExpired(t *testing.T) { // Expire the code key. svc.rdb.Del(ctx, codeKey(email)) - _, apiErr := svc.Register(ctx, email, code, "password1", "", DeviceMeta{}) + _, apiErr := svc.Register(ctx, email, code, "password1", "", DeviceMeta{}, "") if apiErr == nil || apiErr.Code != ErrCodeInvalid.Code { t.Fatalf("want code_invalid after expiry, got %v", apiErr) } @@ -144,11 +144,11 @@ func TestService_CodeReuseRejected(t *testing.T) { _, _ = svc.SendCode(ctx, email, "") code := codeInRedis(t, svc, email) - if _, e := svc.Register(ctx, email, code, "password1", "", DeviceMeta{}); e != nil { + if _, e := svc.Register(ctx, email, code, "password1", "", DeviceMeta{}, ""); e != nil { t.Fatalf("first register: %v", e) } // Re-using the consumed code must fail. - _, apiErr := svc.Register(ctx, "other@example.com", code, "password1", "", DeviceMeta{}) + _, apiErr := svc.Register(ctx, "other@example.com", code, "password1", "", DeviceMeta{}, "") if apiErr == nil || apiErr.Code != ErrCodeInvalid.Code { t.Fatalf("want code_invalid on reuse, got %v", apiErr) } @@ -163,12 +163,12 @@ func TestService_CodeBruteForceBurned(t *testing.T) { // 3 wrong attempts burn the code. for i := 0; i < 3; i++ { - if _, e := svc.Register(ctx, email, "999999", "password1", "", DeviceMeta{}); e == nil { + if _, e := svc.Register(ctx, email, "999999", "password1", "", DeviceMeta{}, ""); e == nil { t.Fatal("wrong code should fail") } } // Even the correct code no longer works. - if _, e := svc.Register(ctx, email, good, "password1", "", DeviceMeta{}); e == nil || e.Code != ErrCodeInvalid.Code { + if _, e := svc.Register(ctx, email, good, "password1", "", DeviceMeta{}, ""); e == nil || e.Code != ErrCodeInvalid.Code { t.Fatalf("burned code should reject correct value, got %v", e) } } @@ -205,7 +205,7 @@ func TestService_LoginAndLockout(t *testing.T) { const pw = "rightpassword" _, _ = svc.SendCode(ctx, email, "") - if _, e := svc.Register(ctx, email, codeInRedis(t, svc, email), pw, "", DeviceMeta{}); e != nil { + if _, e := svc.Register(ctx, email, codeInRedis(t, svc, email), pw, "", DeviceMeta{}, ""); e != nil { t.Fatalf("register: %v", e) } @@ -247,7 +247,7 @@ func TestService_BannedUserRejected(t *testing.T) { const pw = "password1" _, _ = svc.SendCode(ctx, email, "") - if _, e := svc.Register(ctx, email, codeInRedis(t, svc, email), pw, "", DeviceMeta{}); e != nil { + if _, e := svc.Register(ctx, email, codeInRedis(t, svc, email), pw, "", DeviceMeta{}, ""); e != nil { t.Fatalf("register: %v", e) } store.setStatus(email, "banned") @@ -264,7 +264,7 @@ func TestService_RefreshRotation(t *testing.T) { const email = "refresh@example.com" _, _ = svc.SendCode(ctx, email, "") - pair, e := svc.Register(ctx, email, codeInRedis(t, svc, email), "password1", "", DeviceMeta{}) + pair, e := svc.Register(ctx, email, codeInRedis(t, svc, email), "password1", "", DeviceMeta{}, "") if e != nil { t.Fatalf("register: %v", e) } diff --git a/server/internal/auth/store.go b/server/internal/auth/store.go index 24aff05..681d3ea 100644 --- a/server/internal/auth/store.go +++ b/server/internal/auth/store.go @@ -42,14 +42,25 @@ type UserStore interface { GetUserByEmail(ctx context.Context, email string) (*User, error) } +// Noticer 抽象 notices.Store 的事务内插入入口(测试可替身;生产传 notices.NewStore(db))。 +// auth → notices 单向依赖,不引入 import cycle,故本地声明接口、不直接 import notices +// (参照 reward 包 Noticer 的做法)。 +type Noticer interface { + InsertNoticeTx(ctx context.Context, tx *sql.Tx, userID int64, typ, titleZH, titleEN, bodyZH, bodyEN, link string, now time.Time) error +} + // SQLStore is the MySQL-backed UserStore. type SQLStore struct { - db *sql.DB + db *sql.DB + noticer Noticer } // NewSQLStore builds a SQLStore. func NewSQLStore(db *sql.DB) *SQLStore { return &SQLStore{db: db} } +// SetNoticer 注入注册欢迎通知钩子(notices.Store 满足此接口);为 nil 时跳过(装配前兼容)。 +func (s *SQLStore) SetNoticer(n Noticer) { s.noticer = n } + // CreateUserWithTrial implements UserStore. func (s *SQLStore) CreateUserWithTrial(ctx context.Context, email, pwHash string, trialDays int) (*User, error) { userUUID := uuid.NewString() @@ -95,6 +106,17 @@ func (s *SQLStore) CreateUserWithTrial(ctx context.Context, email, pwHash string return nil, fmt.Errorf("auth: insert trial subscription: %w", err) } + // 欢迎站内通知:同事务插入,注册回滚则通知不留(零孤儿),与 reward/pay 到账接缝同语义。 + if s.noticer != nil { + titleZH := "欢迎加入 Pangolin 🎉" + titleEN := "Welcome to Pangolin 🎉" + bodyZH := fmt.Sprintf("已为你开通 %d 天 PRO 会员,现在就选节点连接,畅享全球网络。", trialDays) + bodyEN := fmt.Sprintf("Your %d-day PRO trial is active. Pick a node and connect to enjoy unrestricted access.", trialDays) + if err := s.noticer.InsertNoticeTx(ctx, tx, userID, "reward", titleZH, titleEN, bodyZH, bodyEN, "", now); err != nil { + return nil, fmt.Errorf("auth: insert welcome notice: %w", err) + } + } + if err := tx.Commit(); err != nil { return nil, fmt.Errorf("auth: commit: %w", err) } @@ -125,11 +147,15 @@ func (s *SQLStore) GetUserByEmail(ctx context.Context, email string) (*User, err return &u, nil } -// isDuplicateKey reports whether err is a MySQL duplicate-key (1062) error. +// isDuplicateKey reports whether err is a duplicate-key/unique-constraint +// violation, dialect-neutral: MySQL ("Duplicate entry" / 1062) and sqlite +// (modernc.org/sqlite "UNIQUE constraint failed" / SQLITE_CONSTRAINT_UNIQUE +// 2067) both matched by message text. func isDuplicateKey(err error) bool { if err == nil { return false } msg := err.Error() - return strings.Contains(msg, "Duplicate entry") || strings.Contains(msg, "1062") + return strings.Contains(msg, "Duplicate entry") || strings.Contains(msg, "1062") || + strings.Contains(msg, "UNIQUE constraint failed") } diff --git a/server/internal/auth/store_notices_test.go b/server/internal/auth/store_notices_test.go new file mode 100644 index 0000000..18a7e04 --- /dev/null +++ b/server/internal/auth/store_notices_test.go @@ -0,0 +1,80 @@ +package auth_test + +import ( + "context" + "fmt" + "testing" + + "github.com/wangjia/pangolin/server/internal/auth" + "github.com/wangjia/pangolin/server/internal/notices" +) + +// TestCreateUserWithTrial_InsertsWelcomeNotice: 注册同事务应插入一条欢迎到账通知, +// 双语文案按 trialDays 参数化。用真实 notices.Store(而非 fake)验证落库结果; +// 放在外部测试包(package auth_test)是因为 notices 反向 import auth(handler.go), +// 若同包(package auth)测试文件再 import notices 会成环 —— 参照 store_sqlite_test.go +// 里 fakeNoticer 单测的注释。 +func TestCreateUserWithTrial_InsertsWelcomeNotice(t *testing.T) { + db := auth.OpenAuthDBForTest(t) + s := auth.NewSQLStore(db) + s.SetNoticer(notices.NewStore(db)) + + u, err := s.CreateUserWithTrial(context.Background(), "welcome@example.com", "hash", 7) + if err != nil { + t.Fatalf("CreateUserWithTrial: %v", err) + } + + var n int + if err := db.QueryRow(`SELECT COUNT(*) FROM notices WHERE type='reward' AND user_id=?`, u.ID).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("welcome notice count = %d, want 1", n) + } + + var typ, titleZH, titleEN, bodyZH, bodyEN string + row := db.QueryRow(`SELECT type, title_zh, title_en, body_zh, body_en FROM notices WHERE user_id=?`, u.ID) + if err := row.Scan(&typ, &titleZH, &titleEN, &bodyZH, &bodyEN); err != nil { + t.Fatal(err) + } + if typ != "reward" { + t.Fatalf("type = %q, want reward", typ) + } + if titleZH != "欢迎加入 Pangolin 🎉" { + t.Fatalf("titleZH = %q", titleZH) + } + if titleEN != "Welcome to Pangolin 🎉" { + t.Fatalf("titleEN = %q", titleEN) + } + wantBodyZH := fmt.Sprintf("已为你开通 %d 天 PRO 会员,现在就选节点连接,畅享全球网络。", 7) + if bodyZH != wantBodyZH { + t.Fatalf("bodyZH = %q, want %q", bodyZH, wantBodyZH) + } + wantBodyEN := fmt.Sprintf("Your %d-day PRO trial is active. Pick a node and connect to enjoy unrestricted access.", 7) + if bodyEN != wantBodyEN { + t.Fatalf("bodyEN = %q, want %q", bodyEN, wantBodyEN) + } +} + +// TestCreateUserWithTrial_DupEmail_NoNotice: 邮箱重复 → 用户插入失败(事务回滚), +// 不应留下任何通知(零孤儿)。 +func TestCreateUserWithTrial_DupEmail_NoNotice(t *testing.T) { + db := auth.OpenAuthDBForTest(t) + s := auth.NewSQLStore(db) + s.SetNoticer(notices.NewStore(db)) + + if _, err := s.CreateUserWithTrial(context.Background(), "dup@example.com", "hash", 7); err != nil { + t.Fatalf("first CreateUserWithTrial: %v", err) + } + if _, err := s.CreateUserWithTrial(context.Background(), "dup@example.com", "hash2", 7); err != auth.ErrEmailTaken { + t.Fatalf("second CreateUserWithTrial err = %v, want ErrEmailTaken", err) + } + + var n int + if err := db.QueryRow(`SELECT COUNT(*) FROM notices`).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("notices count = %d, want 1 (only the first registration's welcome notice)", n) + } +} diff --git a/server/internal/auth/store_sqlite_test.go b/server/internal/auth/store_sqlite_test.go new file mode 100644 index 0000000..9aa0506 --- /dev/null +++ b/server/internal/auth/store_sqlite_test.go @@ -0,0 +1,105 @@ +package auth + +import ( + "context" + "database/sql" + "fmt" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/config" + "github.com/wangjia/pangolin/server/internal/store" +) + +// openAuthDB spins up an in-memory sqlite DB with the full migration set +// (plans get seeded by 000007_seed.up.sql), mirroring reward package's +// openDB test harness. Exported (capitalized) so the external store_notices_test.go +// (package auth_test, needed to avoid the notices->auth import cycle) can reuse it. +func OpenAuthDBForTest(t *testing.T) *sql.DB { + t.Helper() + db, err := store.Open(&config.Config{Driver: "sqlite", DSN: ":memory:"}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := store.MigrateUp(db, "sqlite"); err != nil { + t.Fatal(err) + } + if err := store.ApplyCodesLibMigrations(context.Background(), db, "sqlite"); err != nil { + t.Fatal(err) + } + return db +} + +// fakeNoticer 记录调用,用于验证「未设置 noticer 时跳过」与「显式 fake 校验参数」两种路径。 +// 用 fake(而非真实 notices.Store)是因为 notices 包反向 import auth(handler.go 用 +// auth.UserIDFromContext),同包内部测试(package auth)若再 import notices 会成环; +// 真实 notices.Store 的集成断言放到外部测试包 store_notices_test.go(package auth_test)。 +type fakeNoticer struct { + calls int + last struct { + userID int64 + typ, titleZH, titleEN string + bodyZH, bodyEN, link string + } +} + +func (f *fakeNoticer) InsertNoticeTx(_ context.Context, _ *sql.Tx, userID int64, typ, titleZH, titleEN, bodyZH, bodyEN, link string, _ time.Time) error { + f.calls++ + f.last.userID = userID + f.last.typ = typ + f.last.titleZH = titleZH + f.last.titleEN = titleEN + f.last.bodyZH = bodyZH + f.last.bodyEN = bodyEN + f.last.link = link + return nil +} + +// TestCreateUserWithTrial_NilNoticer_Skips: 未注入 noticer(装配前/未调用 SetNoticer)时, +// 注册仍应成功且不 panic —— 保持既有装配可编译、零副作用。 +func TestCreateUserWithTrial_NilNoticer_Skips(t *testing.T) { + db := OpenAuthDBForTest(t) + s := NewSQLStore(db) // 不调用 SetNoticer + + if _, err := s.CreateUserWithTrial(context.Background(), "nonoticer@example.com", "hash", 7); err != nil { + t.Fatalf("CreateUserWithTrial: %v", err) + } + var n int + if err := db.QueryRow(`SELECT COUNT(*) FROM notices`).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 0 { + t.Fatalf("notices count = %d, want 0 when noticer not set", n) + } +} + +// TestCreateUserWithTrial_FakeNoticer_CalledOnceWithArgs: 用 fake 断言被调用一次、 +// 参数与实际 trialDays 一致。 +func TestCreateUserWithTrial_FakeNoticer_CalledOnceWithArgs(t *testing.T) { + db := OpenAuthDBForTest(t) + s := NewSQLStore(db) + fn := &fakeNoticer{} + s.SetNoticer(fn) + + u, err := s.CreateUserWithTrial(context.Background(), "fake@example.com", "hash", 14) + if err != nil { + t.Fatalf("CreateUserWithTrial: %v", err) + } + if fn.calls != 1 { + t.Fatalf("noticer calls = %d, want 1", fn.calls) + } + if fn.last.userID != u.ID { + t.Fatalf("noticer userID = %d, want %d", fn.last.userID, u.ID) + } + if fn.last.typ != "reward" { + t.Fatalf("noticer typ = %q, want reward", fn.last.typ) + } + wantBodyZH := fmt.Sprintf("已为你开通 %d 天 PRO 会员,现在就选节点连接,畅享全球网络。", 14) + if fn.last.bodyZH != wantBodyZH { + t.Fatalf("noticer bodyZH = %q, want %q", fn.last.bodyZH, wantBodyZH) + } + if fn.last.link != "" { + t.Fatalf("noticer link = %q, want empty", fn.last.link) + } +} diff --git a/server/internal/codes/paygrant.go b/server/internal/codes/paygrant.go index 64b3a54..d3185a4 100644 --- a/server/internal/codes/paygrant.go +++ b/server/internal/codes/paygrant.go @@ -29,3 +29,21 @@ func (svc *Service) GrantPaidSubscriptionTx( _ = svc.store.WriteAuditLog(ctx, tx, fmt.Sprintf("user:%d", userID), "pay_grant", ref, string(meta)) return subID, expiresAt, nil } + +// GrantRewardTx 发放奖励会员天数(Pro,source∈{invite,task}),与付费/兑换码同一条 +// applySubscription 延时逻辑(max(到期,now)+days;已有活跃 pro 则原地顺延)。审计走 audit_log。 +func (svc *Service) GrantRewardTx( + ctx context.Context, tx *sql.Tx, userID int64, days int, source, auditAction, ref string, +) (int64, time.Time, error) { + planID, err := svc.store.GetPlanIDTx(ctx, tx, PlanPro) + if err != nil { + return 0, time.Time{}, err + } + subID, expiresAt, err := svc.applySubscription(ctx, tx, userID, planID, days, source) + if err != nil { + return 0, time.Time{}, err + } + meta, _ := json.Marshal(map[string]any{"days": days, "source": source, "sub_id": subID}) + _ = svc.store.WriteAuditLog(ctx, tx, fmt.Sprintf("user:%d", userID), auditAction, ref, string(meta)) + return subID, expiresAt, nil +} diff --git a/server/internal/codes/reward_grant_sqlite_test.go b/server/internal/codes/reward_grant_sqlite_test.go new file mode 100644 index 0000000..6bbe93a --- /dev/null +++ b/server/internal/codes/reward_grant_sqlite_test.go @@ -0,0 +1,48 @@ +package codes_test + +import ( + "context" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/codes" +) + +// 无活跃 pro 的新用户:GrantRewardTx 新建一行 source='invite',并写一条 audit_log。 +func TestGrantRewardTx_FreshUserCreatesInviteSub(t *testing.T) { + db := openMigratedSQLite(t) + seedUser(t, db, 7) + store := codes.NewStore(db) + svc := codes.NewService(store, nil, 5, time.Hour) + ctx := context.Background() + + tx, err := store.BeginTx(ctx) + if err != nil { + t.Fatalf("BeginTx: %v", err) + } + subID, exp, err := svc.GrantRewardTx(ctx, tx, 7, 3, "invite", "invite_reward", "ref-1") + if err != nil { + t.Fatalf("grant: %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatal(err) + } + if subID == 0 || exp.Before(time.Now()) { + t.Fatalf("bad sub %d exp %v", subID, exp) + } + + var src string + if err := db.QueryRow(`SELECT source FROM subscriptions WHERE id=?`, subID).Scan(&src); err != nil { + t.Fatalf("query source: %v", err) + } + if src != "invite" { + t.Fatalf("source = %q, want invite", src) + } + var n int + if err := db.QueryRow(`SELECT COUNT(*) FROM audit_log WHERE action='invite_reward' AND target='ref-1'`).Scan(&n); err != nil { + t.Fatalf("query audit_log: %v", err) + } + if n != 1 { + t.Fatalf("audit rows = %d, want 1", n) + } +} diff --git a/server/internal/devices/device_limit_override_test.go b/server/internal/devices/device_limit_override_test.go new file mode 100644 index 0000000..5abd5dc --- /dev/null +++ b/server/internal/devices/device_limit_override_test.go @@ -0,0 +1,209 @@ +package devices + +import ( + "context" + "database/sql" + "fmt" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/config" + "github.com/wangjia/pangolin/server/internal/store" +) + +// -------------------------------------------------------------------------- +// SQLite fixture for migration 000025 (users.max_devices_override). No +// container needed — modernc.org/sqlite is pure Go — so these run in normal +// CI alongside run_sqlite_test.sh's data-layer tests. +// -------------------------------------------------------------------------- + +func openOverrideTestDB(t *testing.T) *sql.DB { + t.Helper() + cfg := &config.Config{Driver: "sqlite", DSN: ":memory:"} + db, err := store.Open(cfg) + if err != nil { + t.Fatalf("store.Open: %v", err) + } + t.Cleanup(func() { db.Close() }) + if err := store.MigrateUp(db, "sqlite"); err != nil { + t.Fatalf("MigrateUp: %v", err) + } + return db +} + +// insertOverrideTestUser inserts an active user with an optional +// max_devices_override (NULL when override.Valid is false). +func insertOverrideTestUser(t *testing.T, db *sql.DB, email string, override sql.NullInt64) int64 { + t.Helper() + var ( + res sql.Result + err error + ) + if override.Valid { + res, err = db.Exec( + `INSERT INTO users (uuid, email, pw_hash, dp_uuid, status, max_devices_override) + VALUES (?, ?, 'x', ?, 'active', ?)`, + "uuid-"+email, email, "dp-"+email, override.Int64) + } else { + res, err = db.Exec( + `INSERT INTO users (uuid, email, pw_hash, dp_uuid, status) + VALUES (?, ?, 'x', ?, 'active')`, + "uuid-"+email, email, "dp-"+email) + } + if err != nil { + t.Fatalf("insert user %s: %v", email, err) + } + id, err := res.LastInsertId() + if err != nil { + t.Fatalf("last insert id: %v", err) + } + return id +} + +// giveOverrideTestSubscription inserts an active subscription for userID on +// the given seeded plan code (free/pro/team, from migration 000007). +func giveOverrideTestSubscription(t *testing.T, db *sql.DB, userID int64, planCode, source string, expiresAt time.Time) { + t.Helper() + var planID int64 + if err := db.QueryRow(`SELECT id FROM plans WHERE code=?`, planCode).Scan(&planID); err != nil { + t.Fatalf("plan lookup %s: %v", planCode, err) + } + if _, err := db.Exec( + `INSERT INTO subscriptions (user_id, plan_id, expires_at, source) VALUES (?, ?, ?, ?)`, + userID, planID, expiresAt.UTC(), source); err != nil { + t.Fatalf("give subscription: %v", err) + } +} + +// insertOverrideTestDevice inserts an active (recently-seen) device row so it +// counts toward CountActiveDevices. +func insertOverrideTestDevice(t *testing.T, db *sql.DB, userID int64, uuidSuffix string) { + t.Helper() + if _, err := db.Exec( + `INSERT INTO devices (uuid, user_id, name, platform, last_seen) VALUES (?, ?, ?, ?, ?)`, + "dev-"+uuidSuffix, userID, "Device "+uuidSuffix, "ios", time.Now().UTC()); err != nil { + t.Fatalf("insert device: %v", err) + } +} + +// -------------------------------------------------------------------------- +// ResolvePlan: override applied at the single funnel (both the free fallback +// and the subscription path flow through it). +// -------------------------------------------------------------------------- + +// TestResolvePlan_FreeUserNoOverride: a free user with no override set gets +// the plan's default cap (free = 1, per migration 000007 seed). +func TestResolvePlan_FreeUserNoOverride(t *testing.T) { + db := openOverrideTestDB(t) + svc := NewService(NewStore(db), nil) + userID := insertOverrideTestUser(t, db, "free-none@example.com", sql.NullInt64{}) + + plan, apiErr := svc.ResolvePlan(context.Background(), userID) + if apiErr != nil { + t.Fatalf("ResolvePlan: %v", apiErr) + } + if plan.PlanCode != "free" { + t.Errorf("PlanCode = %q, want free", plan.PlanCode) + } + if plan.MaxDevices != 1 { + t.Errorf("MaxDevices = %d, want 1 (free plan default, no override)", plan.MaxDevices) + } +} + +// TestResolvePlan_FreeUserWithOverride: same free user, override=6 → the +// free-fallback path (GetFreePlan) must be overridden too. +func TestResolvePlan_FreeUserWithOverride(t *testing.T) { + db := openOverrideTestDB(t) + svc := NewService(NewStore(db), nil) + userID := insertOverrideTestUser(t, db, "free-override@example.com", sql.NullInt64{Int64: 6, Valid: true}) + + plan, apiErr := svc.ResolvePlan(context.Background(), userID) + if apiErr != nil { + t.Fatalf("ResolvePlan: %v", apiErr) + } + if plan.PlanCode != "free" { + t.Errorf("PlanCode = %q, want free (override must not change plan identity)", plan.PlanCode) + } + if plan.MaxDevices != 6 { + t.Errorf("MaxDevices = %d, want 6 (override)", plan.MaxDevices) + } +} + +// TestResolvePlan_ProSubscriptionOverrideWins: a pro-subscription user +// (plan default max_devices=3) with override=2 — the subscription path +// (resolveEffectivePlan's "best" branch) must also be overridden, even +// though the override is *smaller* than the plan's own cap. +func TestResolvePlan_ProSubscriptionOverrideWins(t *testing.T) { + db := openOverrideTestDB(t) + svc := NewService(NewStore(db), nil) + userID := insertOverrideTestUser(t, db, "pro-override@example.com", sql.NullInt64{Int64: 2, Valid: true}) + giveOverrideTestSubscription(t, db, userID, "pro", "trial", time.Now().UTC().Add(24*time.Hour)) + + plan, apiErr := svc.ResolvePlan(context.Background(), userID) + if apiErr != nil { + t.Fatalf("ResolvePlan: %v", apiErr) + } + if plan.PlanCode != "pro" { + t.Errorf("PlanCode = %q, want pro", plan.PlanCode) + } + if plan.MaxDevices != 2 { + t.Errorf("MaxDevices = %d, want 2 (override wins over pro plan's own cap of 3)", plan.MaxDevices) + } +} + +// TestResolvePlan_NullOrZeroOverrideDoesNotApply: NULL and 0 both mean +// "no override" — the plan-derived cap must stand. +func TestResolvePlan_NullOrZeroOverrideDoesNotApply(t *testing.T) { + db := openOverrideTestDB(t) + svc := NewService(NewStore(db), nil) + + nullUser := insertOverrideTestUser(t, db, "null-override@example.com", sql.NullInt64{}) + zeroUser := insertOverrideTestUser(t, db, "zero-override@example.com", sql.NullInt64{Int64: 0, Valid: true}) + + for _, tc := range []struct { + name string + userID int64 + }{ + {"NULL", nullUser}, + {"zero", zeroUser}, + } { + t.Run(tc.name, func(t *testing.T) { + plan, apiErr := svc.ResolvePlan(context.Background(), tc.userID) + if apiErr != nil { + t.Fatalf("ResolvePlan: %v", apiErr) + } + if plan.MaxDevices != 1 { + t.Errorf("MaxDevices = %d, want 1 (no override should apply)", plan.MaxDevices) + } + }) + } +} + +// -------------------------------------------------------------------------- +// CheckDeviceLimit: behavioral assertion using the existing rig (it calls +// ResolvePlan internally) — override raises the effective cap so a device +// count that would be rejected under the free default is no longer flagged. +// -------------------------------------------------------------------------- + +func TestCheckDeviceLimit_OverrideAllowsMoreDevicesThanFreeCap(t *testing.T) { + db := openOverrideTestDB(t) + svc := NewService(NewStore(db), nil) + userID := insertOverrideTestUser(t, db, "backstop@example.com", sql.NullInt64{Int64: 6, Valid: true}) + + // Free plan cap is 1; without the override 3 active devices would be + // rejected (Over=true). The override raises the cap to 6. + for i := 0; i < 3; i++ { + insertOverrideTestDevice(t, db, userID, fmt.Sprintf("%d", i)) + } + + status, apiErr := svc.CheckDeviceLimit(context.Background(), userID) + if apiErr != nil { + t.Fatalf("CheckDeviceLimit: %v", apiErr) + } + if status.MaxDevices != 6 { + t.Errorf("MaxDevices = %d, want 6", status.MaxDevices) + } + if status.Over { + t.Errorf("Over = true, want false (override=6 covers 3 active devices; free default of 1 would reject them)") + } +} diff --git a/server/internal/devices/service.go b/server/internal/devices/service.go index 4779fbd..03dc1a9 100644 --- a/server/internal/devices/service.go +++ b/server/internal/devices/service.go @@ -450,7 +450,21 @@ func (svc *Service) ResolvePlan(ctx context.Context, userID int64) (Plan, *apier if err != nil { return Plan{}, apierr.ErrInternal } - return resolveEffectivePlan(time.Now().UTC(), status, subs, free) + plan, apiErr := resolveEffectivePlan(time.Now().UTC(), status, subs, free) + if apiErr != nil { + return Plan{}, apiErr + } + // Per-user device-cap override (migration 000025, users.max_devices_override): + // NULL/0 = no override, keep the plan-derived cap resolved above. Applied + // once here — the single funnel both the subscription path and the free + // fallback flow through — so every consumer (subscription middleware, + // /v1/me, CheckDeviceLimit) sees it uniformly. + if override, ok, err := svc.store.GetMaxDevicesOverride(ctx, userID); err != nil { + return Plan{}, apierr.ErrInternal + } else if ok { + plan.MaxDevices = override + } + return plan, nil } // CheckDeviceLimit prunes stale devices (best-effort churn cleanup), resolves the diff --git a/server/internal/devices/store.go b/server/internal/devices/store.go index d3f4ddf..2db3ce1 100644 --- a/server/internal/devices/store.go +++ b/server/internal/devices/store.go @@ -299,6 +299,26 @@ func (s *Store) GetFreePlan(ctx context.Context) (Plan, error) { return p, nil } +// GetMaxDevicesOverride returns the per-user device-cap override set by +// migration 000025 (users.max_devices_override). NULL or <=0 means "no +// override" (ok=false) — the caller should fall back to the plan-derived cap. +// A positive value always wins, even if it is *smaller* than the plan's cap, +// so operators can also tighten a specific account. +func (s *Store) GetMaxDevicesOverride(ctx context.Context, userID int64) (int, bool, error) { + var override sql.NullInt64 + row := s.db.QueryRowContext(ctx, `SELECT max_devices_override FROM users WHERE id=?`, userID) + if err := row.Scan(&override); err != nil { + if err == sql.ErrNoRows { + return 0, false, nil + } + return 0, false, fmt.Errorf("store.GetMaxDevicesOverride: %w", err) + } + if !override.Valid || override.Int64 <= 0 { + return 0, false, nil + } + return int(override.Int64), true, nil +} + // -------------------------------------------------------------------------- // Audit log // -------------------------------------------------------------------------- diff --git a/server/internal/httpapi/account.go b/server/internal/httpapi/account.go index f8b57c0..21eb7e4 100644 --- a/server/internal/httpapi/account.go +++ b/server/internal/httpapi/account.go @@ -214,11 +214,3 @@ func (a *AccountAPI) ListPlans(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(map[string]any{"plans": plans}) } - -// ─── GET /v1/notices ───────────────────────────────────────────────────────── - -// ListNotices handles GET /v1/notices. Returns an empty list for the MVP. -func (a *AccountAPI) ListNotices(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - _ = json.NewEncoder(w).Encode(map[string]any{"notices": []any{}}) -} diff --git a/server/internal/nodes/entitlement_override_sqlite_test.go b/server/internal/nodes/entitlement_override_sqlite_test.go new file mode 100644 index 0000000..1fc8bc2 --- /dev/null +++ b/server/internal/nodes/entitlement_override_sqlite_test.go @@ -0,0 +1,177 @@ +package nodes_test + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/config" + "github.com/wangjia/pangolin/server/internal/nodes" + "github.com/wangjia/pangolin/server/internal/store" +) + +// -------------------------------------------------------------------------- +// C1 · EntitlementForUser is the *other* consumer of migration 000025's +// users.max_devices_override — the per-connection backstop in +// internal/httpapi/nodes.go (DEVICE_LIMIT_EXCEEDED). Before this fix it never +// read the override, so an operator-granted override (e.g. 6) would pass the +// login gate (internal/devices.ResolvePlan reads it) but still get the +// account's *next VPN connection* rejected under the un-overridden plan cap. +// +// No container needed — modernc.org/sqlite is pure Go — so these run in +// normal CI alongside run_sqlite_test.sh's data-layer tests, mirroring +// internal/devices/device_limit_override_test.go. +// -------------------------------------------------------------------------- + +func openEntitlementTestDB(t *testing.T) *sql.DB { + t.Helper() + cfg := &config.Config{Driver: "sqlite", DSN: ":memory:"} + db, err := store.Open(cfg) + if err != nil { + t.Fatalf("store.Open: %v", err) + } + t.Cleanup(func() { db.Close() }) + if err := store.MigrateUp(db, "sqlite"); err != nil { + t.Fatalf("MigrateUp: %v", err) + } + return db +} + +// insertEntitlementTestUser inserts an active user with an optional +// max_devices_override (NULL when override.Valid is false). +func insertEntitlementTestUser(t *testing.T, db *sql.DB, email string, override sql.NullInt64) int64 { + t.Helper() + var ( + res sql.Result + err error + ) + if override.Valid { + res, err = db.Exec( + `INSERT INTO users (uuid, email, pw_hash, dp_uuid, status, max_devices_override) + VALUES (?, ?, 'x', ?, 'active', ?)`, + "uuid-"+email, email, "dp-"+email, override.Int64) + } else { + res, err = db.Exec( + `INSERT INTO users (uuid, email, pw_hash, dp_uuid, status) + VALUES (?, ?, 'x', ?, 'active')`, + "uuid-"+email, email, "dp-"+email) + } + if err != nil { + t.Fatalf("insert user %s: %v", email, err) + } + id, err := res.LastInsertId() + if err != nil { + t.Fatalf("last insert id: %v", err) + } + return id +} + +// giveEntitlementTestSubscription inserts an active subscription for userID +// on the given seeded plan code (free/pro/team, from migration 000007). +func giveEntitlementTestSubscription(t *testing.T, db *sql.DB, userID int64, planCode, source string, expiresAt time.Time) { + t.Helper() + var planID int64 + if err := db.QueryRow(`SELECT id FROM plans WHERE code=?`, planCode).Scan(&planID); err != nil { + t.Fatalf("plan lookup %s: %v", planCode, err) + } + if _, err := db.Exec( + `INSERT INTO subscriptions (user_id, plan_id, expires_at, source) VALUES (?, ?, ?, ?)`, + userID, planID, expiresAt.UTC(), source); err != nil { + t.Fatalf("give subscription: %v", err) + } +} + +// TestEntitlementForUser_FreeUserNoOverride: a free user with no override set +// gets the plan's hard-coded free default (1) — unchanged baseline behavior. +func TestEntitlementForUser_FreeUserNoOverride(t *testing.T) { + db := openEntitlementTestDB(t) + st := nodes.NewSQLNodeStore(db) + userID := insertEntitlementTestUser(t, db, "free-none@example.com", sql.NullInt64{}) + + ent, err := st.EntitlementForUser(context.Background(), userID) + if err != nil { + t.Fatalf("EntitlementForUser: %v", err) + } + if ent == nil { + t.Fatal("ent = nil, want entitlement") + } + if ent.PlanCode != "free" { + t.Errorf("PlanCode = %q, want free", ent.PlanCode) + } + if ent.MaxDevices != 1 { + t.Errorf("MaxDevices = %d, want 1 (free plan default, no override)", ent.MaxDevices) + } +} + +// TestEntitlementForUser_FreeUserWithOverride: same free user, override=6 — +// the free-fallback branch (no active subscription row) must be overridden +// too, otherwise the connect backstop rejects the 2nd..6th device even +// though the login gate (devices.ResolvePlan) already allows them. +func TestEntitlementForUser_FreeUserWithOverride(t *testing.T) { + db := openEntitlementTestDB(t) + st := nodes.NewSQLNodeStore(db) + userID := insertEntitlementTestUser(t, db, "free-override@example.com", sql.NullInt64{Int64: 6, Valid: true}) + + ent, err := st.EntitlementForUser(context.Background(), userID) + if err != nil { + t.Fatalf("EntitlementForUser: %v", err) + } + if ent.PlanCode != "free" { + t.Errorf("PlanCode = %q, want free (override must not change plan identity)", ent.PlanCode) + } + if ent.MaxDevices != 6 { + t.Errorf("MaxDevices = %d, want 6 (override) — connect backstop must agree with login gate", ent.MaxDevices) + } +} + +// TestEntitlementForUser_ProSubscriptionOverrideWins: a pro-subscription user +// (plan default max_devices=3, migration 000019) with override=2 — the +// subscription branch must also be overridden, even though the override is +// *smaller* than the plan's own cap (operators can tighten a specific +// account too, per internal/devices/store.go:305-306's documented semantics). +func TestEntitlementForUser_ProSubscriptionOverrideWins(t *testing.T) { + db := openEntitlementTestDB(t) + st := nodes.NewSQLNodeStore(db) + userID := insertEntitlementTestUser(t, db, "pro-override@example.com", sql.NullInt64{Int64: 2, Valid: true}) + giveEntitlementTestSubscription(t, db, userID, "pro", "trial", time.Now().UTC().Add(24*time.Hour)) + + ent, err := st.EntitlementForUser(context.Background(), userID) + if err != nil { + t.Fatalf("EntitlementForUser: %v", err) + } + if ent.PlanCode != "pro" { + t.Errorf("PlanCode = %q, want pro", ent.PlanCode) + } + if ent.MaxDevices != 2 { + t.Errorf("MaxDevices = %d, want 2 (override wins over pro plan's own cap of 3)", ent.MaxDevices) + } +} + +// TestEntitlementForUser_NullOrZeroOverrideDoesNotApply: NULL and 0 both mean +// "no override" — the plan-derived cap must stand (free=1 here). +func TestEntitlementForUser_NullOrZeroOverrideDoesNotApply(t *testing.T) { + db := openEntitlementTestDB(t) + st := nodes.NewSQLNodeStore(db) + + nullUser := insertEntitlementTestUser(t, db, "null-override@example.com", sql.NullInt64{}) + zeroUser := insertEntitlementTestUser(t, db, "zero-override@example.com", sql.NullInt64{Int64: 0, Valid: true}) + + for _, tc := range []struct { + name string + userID int64 + }{ + {"NULL", nullUser}, + {"zero", zeroUser}, + } { + t.Run(tc.name, func(t *testing.T) { + ent, err := st.EntitlementForUser(context.Background(), tc.userID) + if err != nil { + t.Fatalf("EntitlementForUser: %v", err) + } + if ent.MaxDevices != 1 { + t.Errorf("MaxDevices = %d, want 1 (no override should apply)", ent.MaxDevices) + } + }) + } +} diff --git a/server/internal/nodes/store.go b/server/internal/nodes/store.go index b9291e4..6e2e7a2 100644 --- a/server/internal/nodes/store.go +++ b/server/internal/nodes/store.go @@ -227,18 +227,37 @@ func (s *SQLNodeStore) EntitlementForUser(ctx context.Context, userID int64) (*E err := s.db.QueryRowContext(ctx, q, userID, time.Now().UTC()).Scan( &e.PlanCode, &e.AdGate, &e.DailyMinutes, &e.DailyMB, &e.MaxDevices, &e.ExpiresAt, ) - if err == sql.ErrNoRows { + switch { + case err == sql.ErrNoRows: // No active subscription → free plan defaults (mirrors the free plan seed). e.PlanCode = "free" e.AdGate = true e.DailyMinutes = sql.NullInt64{Valid: true, Int64: 10} e.DailyMB = sql.NullInt64{Valid: true, Int64: 500} e.MaxDevices = 1 - return e, nil - } - if err != nil { + case err != nil: return nil, fmt.Errorf("nodes.SQLNodeStore.EntitlementForUser: plan: %w", err) } + + // Per-user device-cap override (migration 000025, users.max_devices_override): + // applies uniformly to BOTH branches above (subscription hit + free + // fallback) — mirrors internal/devices/service.go's ResolvePlan funnel, so + // the per-connection backstop (internal/httpapi/nodes.go's + // DEVICE_LIMIT_EXCEEDED check, which reads Entitlement.MaxDevices) agrees + // with the login gate / /v1/me / devices.CheckDeviceLimit. NULL or <=0 + // means "no override" (same semantics as devices/store.go's + // GetMaxDevicesOverride); a positive value always wins, even if smaller + // than the plan's own cap. + var override sql.NullInt64 + if err := s.db.QueryRowContext(ctx, + `SELECT max_devices_override FROM users WHERE id = ?`, userID, + ).Scan(&override); err != nil && err != sql.ErrNoRows { + return nil, fmt.Errorf("nodes.SQLNodeStore.EntitlementForUser: override: %w", err) + } + if override.Valid && override.Int64 > 0 { + e.MaxDevices = int(override.Int64) + } + return e, nil } diff --git a/server/internal/notices/handler.go b/server/internal/notices/handler.go new file mode 100644 index 0000000..dbcdcd0 --- /dev/null +++ b/server/internal/notices/handler.go @@ -0,0 +1,61 @@ +package notices + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/wangjia/pangolin/server/internal/apierr" + "github.com/wangjia/pangolin/server/internal/auth" +) + +// listLimit 是 GET /v1/notices 单次返回的最大条数(合并广播+定向后按 published_at 倒序截断)。 +const listLimit = 50 + +// Handler 承载 /v1/notices、/v1/notices/read 两个受保护端点。 +type Handler struct { + st *Store +} + +func NewHandler(st *Store) *Handler { + return &Handler{st: st} +} + +// List handles GET /v1/notices: 返回该用户可见的通知(广播 ∪ 定向)与未读数。 +func (h *Handler) List(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + uid, ok := auth.UserIDFromContext(ctx) + if !ok { + apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) + return + } + items, unreadCount, err := h.st.ListForUser(ctx, uid, time.Now().UTC(), listLimit) + if err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + if items == nil { + items = []Notice{} + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{ + "notices": items, + "unread_count": unreadCount, + }) +} + +// MarkRead handles POST /v1/notices/read: 把用户已读水位推进到当前时间。 +func (h *Handler) MarkRead(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + uid, ok := auth.UserIDFromContext(ctx) + if !ok { + apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) + return + } + if err := h.st.MarkRead(ctx, uid, time.Now().UTC()); err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) +} diff --git a/server/internal/notices/handler_test.go b/server/internal/notices/handler_test.go new file mode 100644 index 0000000..623a344 --- /dev/null +++ b/server/internal/notices/handler_test.go @@ -0,0 +1,64 @@ +package notices + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/codes" +) + +func TestListAndMarkRead(t *testing.T) { + db := openDB(t) + seedU(t, db, 1, "u1") + st := NewStore(db) + _, _ = st.InsertBroadcast(context.Background(), "news", "hello", "hello", "", "", "", time.Now().UTC(), nil) + h := NewHandler(st) + + req := httptest.NewRequest(http.MethodGet, "/v1/notices", nil) + req = req.WithContext(context.WithValue(req.Context(), codes.CtxKeyUserID, int64(1))) + w := httptest.NewRecorder() + h.List(w, req) + if w.Code != 200 { + t.Fatalf("code=%d body=%s", w.Code, w.Body) + } + var got struct { + Notices []map[string]any `json:"notices"` + UnreadCount int `json:"unread_count"` + } + _ = json.Unmarshal(w.Body.Bytes(), &got) + if len(got.Notices) != 1 || got.UnreadCount != 1 { + t.Fatalf("got %+v", got) + } + if got.Notices[0]["type"] != "news" || got.Notices[0]["unread"] != true { + t.Fatalf("字段契约: %+v", got.Notices[0]) + } + + // read → 再查 unread 清零 + req2 := httptest.NewRequest(http.MethodPost, "/v1/notices/read", nil) + req2 = req2.WithContext(context.WithValue(req2.Context(), codes.CtxKeyUserID, int64(1))) + w2 := httptest.NewRecorder() + h.MarkRead(w2, req2) + if w2.Code != 200 { + t.Fatalf("read code=%d", w2.Code) + } + w3 := httptest.NewRecorder() + h.List(w3, req) + _ = json.Unmarshal(w3.Body.Bytes(), &got) + if got.UnreadCount != 0 { + t.Fatalf("read 后 unread_count=%d", got.UnreadCount) + } +} + +func TestListUnauthorized(t *testing.T) { + db := openDB(t) + h := NewHandler(NewStore(db)) + w := httptest.NewRecorder() + h.List(w, httptest.NewRequest(http.MethodGet, "/v1/notices", nil)) + if w.Code != http.StatusUnauthorized { + t.Fatalf("code=%d want 401", w.Code) + } +} diff --git a/server/internal/notices/service.go b/server/internal/notices/service.go new file mode 100644 index 0000000..b005e7b --- /dev/null +++ b/server/internal/notices/service.go @@ -0,0 +1,160 @@ +package notices + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "log/slog" + "strings" + "time" +) + +// EmailSender 抽象邮件兜底通道(生产实现按 SMTP/第三方 API 注入,测试用 fake)。 +type EmailSender interface { + Send(ctx context.Context, to, subject, body string) error +} + +// validNoticeTypes 与 migrations/sqlite/000026_notices.up.sql 的 CHECK 约束一致, +// 六值全部放行(nodectl 手发场景约束更窄,交由调用层——如 nodectl 命令——自行收紧)。 +var validNoticeTypes = map[string]bool{ + "important": true, + "feature": true, + "news": true, + "reward": true, + "version": true, + "promo": true, +} + +// PublishInput 是 Service.Publish 的入参;Email=true 仅 important 类型允许。 +type PublishInput struct { + Type string + TitleZH string + TitleEN string + BodyZH string + BodyEN string + Link string + ExpiresAt *time.Time + Email bool // 仅 important 允许 true +} + +// Service 承载通知发布/撤回的业务规则:字段校验、审计留痕、important 邮件兜底。 +// +// 审计选型(简案,见 task-5-brief):Service 直接持 *sql.DB 自写 +// `INSERT INTO audit_log(actor,action,target,meta,at)`,不复用 codes.Store.WriteAuditLog—— +// 避免 notices 包为了一个方法反向依赖 codes 包,保持依赖方向干净。actor 固定为 "nodectl" +// (当前唯一调用方是后台管理命令);失败仅记录,不影响主流程(照仓库既有审计写入惯例)。 +type Service struct { + st *Store + mailer EmailSender + db *sql.DB +} + +// NewService 组装通知发布服务。mailer 可为 nil(仅当调用方保证不会传 Email=true 时安全)。 +func NewService(st *Store, mailer EmailSender, db *sql.DB) *Service { + return &Service{st: st, mailer: mailer, db: db} +} + +func (s *Service) writeAudit(ctx context.Context, action, target string, meta map[string]any) { + metaJSON := "null" + if meta != nil { + if b, err := json.Marshal(meta); err == nil { + metaJSON = string(b) + } + } + _, err := s.db.ExecContext(ctx, + `INSERT INTO audit_log (actor, action, target, meta, at) VALUES (?, ?, ?, ?, ?)`, + "nodectl", action, target, metaJSON, time.Now().UTC()) + if err != nil { + // 审计失败不影响主流程,照仓库惯例仅记录。 + slog.Warn("notices: write audit log failed", "action", action, "target", target, "err", err) + } +} + +// Publish 校验并落库一条广播通知;Email=true 时对全体 active 用户尽力发送兜底邮件 +// (单个失败仅记录日志、不中断,全部尝试后置位 email_sent_at)。 +func (s *Service) Publish(ctx context.Context, in PublishInput) (int64, error) { + if !validNoticeTypes[in.Type] { + return 0, fmt.Errorf("notices: invalid type %q", in.Type) + } + if strings.TrimSpace(in.TitleZH) == "" { + return 0, fmt.Errorf("notices: title_zh required") + } + if strings.TrimSpace(in.TitleEN) == "" { + return 0, fmt.Errorf("notices: title_en required") + } + if in.Email && in.Type != "important" { + return 0, fmt.Errorf("notices: email fallback only allowed for type=important, got %q", in.Type) + } + if in.Email && s.mailer == nil { + return 0, fmt.Errorf("notices: email requested but no mailer configured") + } + + now := time.Now().UTC() + id, err := s.st.InsertBroadcast(ctx, in.Type, in.TitleZH, in.TitleEN, in.BodyZH, in.BodyEN, in.Link, now, in.ExpiresAt) + if err != nil { + return 0, fmt.Errorf("notices: insert broadcast: %w", err) + } + + s.writeAudit(ctx, "notice_publish", fmt.Sprintf("notice:%d", id), map[string]any{ + "type": in.Type, + "title_zh": in.TitleZH, + }) + + if in.Email { + s.sendEmailFallback(ctx, id, in) + } + + return id, nil +} + +// sendEmailFallback 对全体 active 用户逐个发送兜底邮件;单个失败仅记录日志继续, +// 全部尝试后统一置位 email_sent_at(幂等:Store.MarkEmailSent 只在未发送过时置位)。 +func (s *Service) sendEmailFallback(ctx context.Context, id int64, in PublishInput) { + emails, err := s.st.ListActiveUserEmails(ctx) + if err != nil { + slog.Warn("notices: list active user emails failed", "notice_id", id, "err", err) + return + } + + subject := fmt.Sprintf("【穿山甲】%s / %s", in.TitleZH, in.TitleEN) + body := emailBody(in) + + for _, to := range emails { + if err := s.mailer.Send(ctx, to, subject, body); err != nil { + slog.Warn("notices: send email fallback failed", "notice_id", id, "to", to, "err", err) + continue + } + } + + if err := s.st.MarkEmailSent(ctx, id, time.Now().UTC()); err != nil { + slog.Warn("notices: mark email sent failed", "notice_id", id, "err", err) + } +} + +func emailBody(in PublishInput) string { + var b strings.Builder + b.WriteString(in.TitleZH) + b.WriteString(" / ") + b.WriteString(in.TitleEN) + b.WriteString("\n\n") + if in.BodyZH != "" { + b.WriteString(in.BodyZH) + b.WriteString("\n\n") + } + if in.BodyEN != "" { + b.WriteString(in.BodyEN) + b.WriteString("\n\n") + } + b.WriteString("在 App 内查看详情 / View details in the app") + return b.String() +} + +// RevokeByID 撤回一条通知并留痕审计;目标不存在或已撤回时返回错误。 +func (s *Service) RevokeByID(ctx context.Context, id int64) error { + if err := s.st.Revoke(ctx, id, time.Now().UTC()); err != nil { + return fmt.Errorf("notices: revoke %d: %w", id, err) + } + s.writeAudit(ctx, "notice_revoke", fmt.Sprintf("notice:%d", id), nil) + return nil +} diff --git a/server/internal/notices/service_test.go b/server/internal/notices/service_test.go new file mode 100644 index 0000000..1bcfdc3 --- /dev/null +++ b/server/internal/notices/service_test.go @@ -0,0 +1,246 @@ +package notices + +import ( + "context" + "database/sql" + "errors" + "sync" + "testing" + "time" +) + +// fakeMailer 记录每次 Send 调用,便于断言收件人集合与失败模拟。 +type fakeMailer struct { + mu sync.Mutex + sent []string // to + failTo map[string]bool +} + +func (f *fakeMailer) Send(ctx context.Context, to, subject, body string) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.failTo != nil && f.failTo[to] { + return errors.New("fake smtp send failure") + } + f.sent = append(f.sent, to) + return nil +} + +type auditRow struct { + Target string + Meta string +} + +func auditRows(t *testing.T, db *sql.DB, action string) []auditRow { + t.Helper() + rows, err := db.Query(`SELECT target, COALESCE(meta,'') FROM audit_log WHERE action=? ORDER BY id`, action) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var out []auditRow + for rows.Next() { + var r auditRow + if err := rows.Scan(&r.Target, &r.Meta); err != nil { + t.Fatal(err) + } + out = append(out, r) + } + return out +} + +func TestPublish_ValidationErrors(t *testing.T) { + db := openDB(t) + st := NewStore(db) + svc := NewService(st, nil, db) + ctx := context.Background() + + cases := []struct { + name string + in PublishInput + }{ + {"bad type", PublishInput{Type: "spam", TitleZH: "标题", TitleEN: "title"}}, + {"empty title zh", PublishInput{Type: "news", TitleZH: "", TitleEN: "title"}}, + {"empty title en", PublishInput{Type: "news", TitleZH: "标题", TitleEN: ""}}, + {"email non-important", PublishInput{Type: "news", TitleZH: "标题", TitleEN: "title", Email: true}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if _, err := svc.Publish(ctx, c.in); err == nil { + t.Fatalf("want error, got nil") + } + }) + } +} + +func TestPublish_SuccessNoEmail(t *testing.T) { + db := openDB(t) + seedU(t, db, 1, "u1") + st := NewStore(db) + svc := NewService(st, nil, db) + ctx := context.Background() + + id, err := svc.Publish(ctx, PublishInput{ + Type: "news", TitleZH: "标题", TitleEN: "title", BodyZH: "正文", BodyEN: "body", + }) + if err != nil { + t.Fatalf("Publish: %v", err) + } + if id == 0 { + t.Fatalf("want non-zero id") + } + + items, _, err := st.ListForUser(ctx, 1, time.Now().UTC(), 50) + if err != nil { + t.Fatal(err) + } + found := false + for _, it := range items { + if it.ID == id { + found = true + } + } + if !found { + t.Fatalf("published notice not visible via ListForUser: %+v", items) + } + + rows := auditRows(t, db, "notice_publish") + if len(rows) != 1 { + t.Fatalf("want 1 audit row, got %d: %+v", len(rows), rows) + } +} + +func TestPublish_EmailImportant_AllActiveUsersNotified(t *testing.T) { + db := openDB(t) + seedU(t, db, 1, "u1") + seedU(t, db, 2, "u2") + st := NewStore(db) + mailer := &fakeMailer{} + svc := NewService(st, mailer, db) + ctx := context.Background() + + id, err := svc.Publish(ctx, PublishInput{ + Type: "important", TitleZH: "重要通知", TitleEN: "Important notice", + BodyZH: "维护中", BodyEN: "under maintenance", Email: true, + }) + if err != nil { + t.Fatalf("Publish: %v", err) + } + + mailer.mu.Lock() + sentCount := len(mailer.sent) + mailer.mu.Unlock() + if sentCount != 2 { + t.Fatalf("want 2 emails sent (all active users), got %d: %+v", sentCount, mailer.sent) + } + + var emailSentAt sql.NullTime + if err := db.QueryRow(`SELECT email_sent_at FROM notices WHERE id=?`, id).Scan(&emailSentAt); err != nil { + t.Fatal(err) + } + if !emailSentAt.Valid { + t.Fatalf("email_sent_at not set after Publish with Email=true") + } + + // 再次 Publish 是新公告(幂等的是单公告不重发,不是全局):第二条也应各自发全量邮件。 + id2, err := svc.Publish(ctx, PublishInput{ + Type: "important", TitleZH: "第二条", TitleEN: "Second notice", Email: true, + }) + if err != nil { + t.Fatalf("Publish #2: %v", err) + } + if id2 == id { + t.Fatalf("want new notice id, got same as first") + } + mailer.mu.Lock() + sentCount2 := len(mailer.sent) + mailer.mu.Unlock() + if sentCount2 != 4 { + t.Fatalf("want 4 emails total after 2nd publish, got %d: %+v", sentCount2, mailer.sent) + } +} + +func TestPublish_EmailSendFailure_ContinuesAndStillMarksSent(t *testing.T) { + db := openDB(t) + seedU(t, db, 1, "u1") + seedU(t, db, 2, "u2") + st := NewStore(db) + mailer := &fakeMailer{failTo: map[string]bool{"u1@x": true}} + svc := NewService(st, mailer, db) + ctx := context.Background() + + id, err := svc.Publish(ctx, PublishInput{ + Type: "important", TitleZH: "重要", TitleEN: "Important", Email: true, + }) + if err != nil { + t.Fatalf("Publish should not fail even if one recipient's send fails: %v", err) + } + mailer.mu.Lock() + sent := append([]string(nil), mailer.sent...) + mailer.mu.Unlock() + if len(sent) != 1 || sent[0] != "u2@x" { + t.Fatalf("want only u2@x to have succeeded, got %+v", sent) + } + + var emailSentAt sql.NullTime + if err := db.QueryRow(`SELECT email_sent_at FROM notices WHERE id=?`, id).Scan(&emailSentAt); err != nil { + t.Fatal(err) + } + if !emailSentAt.Valid { + t.Fatalf("email_sent_at should still be set after best-effort send attempt") + } +} + +func TestPublish_EmailTrueWithNilMailer_ReturnsError(t *testing.T) { + db := openDB(t) + st := NewStore(db) + svc := NewService(st, nil, db) + ctx := context.Background() + + if _, err := svc.Publish(ctx, PublishInput{ + Type: "important", TitleZH: "标题", TitleEN: "title", Email: true, + }); err == nil { + t.Fatalf("want error when mailer is nil and Email=true") + } +} + +func TestRevokeByID_HidesFromListAndAudits(t *testing.T) { + db := openDB(t) + seedU(t, db, 1, "u1") + st := NewStore(db) + svc := NewService(st, nil, db) + ctx := context.Background() + + id, err := svc.Publish(ctx, PublishInput{Type: "news", TitleZH: "标题", TitleEN: "title"}) + if err != nil { + t.Fatal(err) + } + + items, _, _ := st.ListForUser(ctx, 1, time.Now().UTC(), 50) + if len(items) != 1 { + t.Fatalf("expected notice visible before revoke, got %d", len(items)) + } + + if err := svc.RevokeByID(ctx, id); err != nil { + t.Fatalf("RevokeByID: %v", err) + } + + items, _, _ = st.ListForUser(ctx, 1, time.Now().UTC(), 50) + if len(items) != 0 { + t.Fatalf("expected no notices visible after revoke, got %+v", items) + } + + rows := auditRows(t, db, "notice_revoke") + if len(rows) != 1 { + t.Fatalf("want 1 audit row for revoke, got %d: %+v", len(rows), rows) + } +} + +func TestRevokeByID_NotFound(t *testing.T) { + db := openDB(t) + st := NewStore(db) + svc := NewService(st, nil, db) + if err := svc.RevokeByID(context.Background(), 9999); err == nil { + t.Fatalf("want error for nonexistent notice id") + } +} diff --git a/server/internal/notices/store.go b/server/internal/notices/store.go new file mode 100644 index 0000000..8ba3bfa --- /dev/null +++ b/server/internal/notices/store.go @@ -0,0 +1,220 @@ +// Package notices 承载系统通知(广播 + 定向)的数据访问层:合并查询、已读水位、 +// 事务内插入(供其他领域事件钩子复用)、后台管理(nodectl)增删改查。 +package notices + +import ( + "context" + "database/sql" + "time" +) + +// Notice 是对外(API/nodectl)的通知视图。json tag 是 API 契约,勿改。 +type Notice struct { + ID int64 `json:"id"` + Type string `json:"type"` + UserID *int64 `json:"-"` + TitleZH string `json:"title_zh"` + TitleEN string `json:"title_en"` + BodyZH string `json:"body_zh,omitempty"` + BodyEN string `json:"body_en,omitempty"` + Link string `json:"link,omitempty"` + PublishedAt time.Time `json:"published_at"` + Unread bool `json:"unread"` +} + +// AdminRow 是 nodectl list 消费的后台视图:不含正文,多一个 status 摘要字段。 +type AdminRow struct { + ID int64 + Type string + TitleZH string + PublishedAt time.Time + Status string // active|revoked|expired +} + +type Store struct{ db *sql.DB } + +func NewStore(db *sql.DB) *Store { return &Store{db: db} } + +// ListForUser 返回广播(user_id IS NULL)∪ 定向(user_id=uid)的未撤回、未过期通知, +// 按 published_at 倒序,并根据 users.notices_read_at 水位计算每条 Unread 与 unreadCount。 +// 水位为 NULL 时按零值时间处理,即全部未读。 +func (s *Store) ListForUser(ctx context.Context, uid int64, now time.Time, limit int) ([]Notice, int, error) { + var readAt sql.NullTime + if err := s.db.QueryRowContext(ctx, `SELECT notices_read_at FROM users WHERE id=?`, uid).Scan(&readAt); err != nil { + return nil, 0, err + } + watermark := time.Time{} + if readAt.Valid { + watermark = readAt.Time + } + + rows, err := s.db.QueryContext(ctx, ` + SELECT id, type, user_id, title_zh, title_en, COALESCE(body_zh,''), COALESCE(body_en,''), + COALESCE(link,''), published_at + FROM notices + WHERE (user_id IS NULL OR user_id = ?) + AND revoked_at IS NULL + AND (expires_at IS NULL OR expires_at > ?) + ORDER BY published_at DESC + LIMIT ? + `, uid, now, limit) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var items []Notice + unreadCount := 0 + for rows.Next() { + var n Notice + var userID sql.NullInt64 + if err := rows.Scan(&n.ID, &n.Type, &userID, &n.TitleZH, &n.TitleEN, &n.BodyZH, &n.BodyEN, &n.Link, &n.PublishedAt); err != nil { + return nil, 0, err + } + if userID.Valid { + id := userID.Int64 + n.UserID = &id + } + n.Unread = n.PublishedAt.After(watermark) + if n.Unread { + unreadCount++ + } + items = append(items, n) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + return items, unreadCount, nil +} + +// MarkRead 把用户的通知已读水位推进到 at。 +func (s *Store) MarkRead(ctx context.Context, uid int64, at time.Time) error { + _, err := s.db.ExecContext(ctx, `UPDATE users SET notices_read_at=? WHERE id=?`, at, uid) + return err +} + +// InsertNoticeTx 在调用方已开的事务内插入一条定向通知(不自开/自提交事务), +// 供其他领域的事件钩子(如奖励发放)在同一事务里原子写入。 +func (s *Store) InsertNoticeTx(ctx context.Context, tx *sql.Tx, userID int64, typ, titleZH, titleEN, bodyZH, bodyEN, link string, now time.Time) error { + _, err := tx.ExecContext(ctx, ` + INSERT INTO notices (type, user_id, title_zh, title_en, body_zh, body_en, link, published_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, typ, userID, titleZH, titleEN, nullIfEmpty(bodyZH), nullIfEmpty(bodyEN), nullIfEmpty(link), now) + return err +} + +// InsertBroadcast 插入一条全员广播通知(user_id=NULL),自管连接,返回新 id。供 +// nodectl / 发版流程使用。expiresAt 可为 nil(永不过期)。 +func (s *Store) InsertBroadcast(ctx context.Context, typ, titleZH, titleEN, bodyZH, bodyEN, link string, publishedAt time.Time, expiresAt *time.Time) (int64, error) { + res, err := s.db.ExecContext(ctx, ` + INSERT INTO notices (type, user_id, title_zh, title_en, body_zh, body_en, link, published_at, expires_at) + VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?) + `, typ, titleZH, titleEN, nullIfEmpty(bodyZH), nullIfEmpty(bodyEN), nullIfEmpty(link), publishedAt, nullTime(expiresAt)) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// Revoke 撤回一条通知(置 revoked_at)。目标不存在或已撤回时返回 sql.ErrNoRows。 +func (s *Store) Revoke(ctx context.Context, id int64, at time.Time) error { + res, err := s.db.ExecContext(ctx, `UPDATE notices SET revoked_at=? WHERE id=? AND revoked_at IS NULL`, at, id) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return sql.ErrNoRows + } + return nil +} + +// ListAdmin 供 nodectl list 使用:全字段倒序;includeInactive=false 时只保留 +// 未撤回、未过期(与 ListForUser 相同判据)的记录。 +func (s *Store) ListAdmin(ctx context.Context, includeInactive bool, limit int) ([]AdminRow, error) { + now := time.Now().UTC() + query := ` + SELECT id, type, title_zh, published_at, revoked_at, expires_at + FROM notices + ` + args := []any{} + if !includeInactive { + query += ` WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)` + args = append(args, now) + } + query += ` ORDER BY published_at DESC LIMIT ?` + args = append(args, limit) + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []AdminRow + for rows.Next() { + var r AdminRow + var revokedAt, expiresAt sql.NullTime + if err := rows.Scan(&r.ID, &r.Type, &r.TitleZH, &r.PublishedAt, &revokedAt, &expiresAt); err != nil { + return nil, err + } + switch { + case revokedAt.Valid: + r.Status = "revoked" + case expiresAt.Valid && !expiresAt.Time.After(now): + r.Status = "expired" + default: + r.Status = "active" + } + out = append(out, r) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +// MarkEmailSent 标记该通知的邮件兜底已发送(幂等:只在未发送过时置位)。 +func (s *Store) MarkEmailSent(ctx context.Context, id int64, at time.Time) error { + _, err := s.db.ExecContext(ctx, `UPDATE notices SET email_sent_at=? WHERE id=? AND email_sent_at IS NULL`, at, id) + return err +} + +// ListActiveUserEmails 返回所有 active 用户的邮箱,供邮件兜底批量发送。 +func (s *Store) ListActiveUserEmails(ctx context.Context) ([]string, error) { + rows, err := s.db.QueryContext(ctx, `SELECT email FROM users WHERE status='active'`) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []string + for rows.Next() { + var email string + if err := rows.Scan(&email); err != nil { + return nil, err + } + out = append(out, email) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func nullIfEmpty(s string) any { + if s == "" { + return nil + } + return s +} + +func nullTime(t *time.Time) any { + if t == nil { + return nil + } + return *t +} diff --git a/server/internal/notices/store_sqlite_test.go b/server/internal/notices/store_sqlite_test.go new file mode 100644 index 0000000..4a21722 --- /dev/null +++ b/server/internal/notices/store_sqlite_test.go @@ -0,0 +1,96 @@ +package notices + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/config" + "github.com/wangjia/pangolin/server/internal/store" +) + +func openDB(t *testing.T) *sql.DB { + t.Helper() + db, err := store.Open(&config.Config{Driver: "sqlite", DSN: ":memory:"}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := store.MigrateUp(db, "sqlite"); err != nil { + t.Fatal(err) + } + _ = store.ApplyCodesLibMigrations(context.Background(), db, "sqlite") + return db +} + +func seedU(t *testing.T, db *sql.DB, id int64, uuid string) { + t.Helper() + if _, err := db.Exec(`INSERT INTO users (id,uuid,email,pw_hash,dp_uuid,status,created_at) + VALUES (?,?,?, 'x','dp-'||?, 'active', ?)`, id, uuid, uuid+"@x", uuid, time.Now().UTC()); err != nil { + t.Fatal(err) + } +} + +func TestListForUser_MergeFilterUnread(t *testing.T) { + db := openDB(t) + seedU(t, db, 1, "u1") + seedU(t, db, 2, "u2") + st := NewStore(db) + now := time.Now().UTC() + // 广播1条 + 我的定向1条 + 他人定向1条 + 已撤回1条 + 已过期1条 + if _, err := st.InsertBroadcast(context.Background(), "news", "b1", "b1", "", "", "", now.Add(-3*time.Hour), nil); err != nil { + t.Fatal(err) + } + tx, _ := db.Begin() + if err := st.InsertNoticeTx(context.Background(), tx, 1, "reward", "mine", "mine", "", "", "", now.Add(-2*time.Hour)); err != nil { + t.Fatal(err) + } + if err := st.InsertNoticeTx(context.Background(), tx, 2, "reward", "other", "other", "", "", "", now.Add(-1*time.Hour)); err != nil { + t.Fatal(err) + } + _ = tx.Commit() + rid, _ := st.InsertBroadcast(context.Background(), "promo", "revoked", "revoked", "", "", "", now, nil) + _ = st.Revoke(context.Background(), rid, now) + exp := now.Add(-time.Minute) + _, _ = st.InsertBroadcast(context.Background(), "promo", "expired", "expired", "", "", "", now.Add(-4*time.Hour), &exp) + + items, unread, err := st.ListForUser(context.Background(), 1, now, 50) + if err != nil { + t.Fatal(err) + } + if len(items) != 2 { + t.Fatalf("items=%d want 2(广播+我的;他人/撤回/过期均排除): %+v", len(items), items) + } + if items[0].TitleZH != "mine" || items[1].TitleZH != "b1" { + t.Fatalf("排序应 published_at 倒序: %+v", items) + } + if unread != 2 { + t.Fatalf("水位为空→全未读, unread=%d", unread) + } + + // 置水位到 -2.5h:广播(-3h)已读、定向(-2h)未读 + if err := st.MarkRead(context.Background(), 1, now.Add(-150*time.Minute)); err != nil { + t.Fatal(err) + } + items, unread, _ = st.ListForUser(context.Background(), 1, now, 50) + if unread != 1 || !items[0].Unread || items[1].Unread { + t.Fatalf("水位判定错: unread=%d items=%+v", unread, items) + } +} + +func TestInsertNoticeTx_RollsBackWithTx(t *testing.T) { + db := openDB(t) + seedU(t, db, 1, "u1") + st := NewStore(db) + tx, _ := db.Begin() + if err := st.InsertNoticeTx(context.Background(), tx, 1, "reward", "t", "t", "", "", "", time.Now().UTC()); err != nil { + t.Fatal(err) + } + _ = tx.Rollback() + var n int + _ = db.QueryRow(`SELECT COUNT(*) FROM notices`).Scan(&n) + if n != 0 { + t.Fatalf("回滚后应零孤儿通知, got %d", n) + } +} diff --git a/server/internal/pay/catalog.go b/server/internal/pay/catalog.go index 75823f2..872c25f 100644 --- a/server/internal/pay/catalog.go +++ b/server/internal/pay/catalog.go @@ -3,21 +3,29 @@ package pay import "github.com/wangjia/pangolin/server/internal/codes" // CatalogItem 是可购档位。SKU 与 pay 侧 products.biz_code 一一对应 -// (webhook product_biz_code 原样回带);PriceMinor 仅展示(CNY 分), -// 实际扣款以 pay 侧 ProductPrice/price 为准——一致性列入联调 checklist。 +// (webhook product_biz_code 原样回带);价格字段**仅展示**(实际扣款以 pay 侧 +// per-currency ProductPrice 为准——一致性列入联调 checklist): +// - PriceMinor:CNY 分(支付宝/哪吒等法币渠道) +// - PriceUsdtMicro:USDT 微元(6 位精度,加密货币渠道) type CatalogItem struct { - SKU string `json:"sku"` - Plan string `json:"plan"` - Days int `json:"days"` - PriceMinor int64 `json:"price_minor"` - Currency string `json:"currency"` + SKU string `json:"sku"` + Plan string `json:"plan"` + Days int `json:"days"` + PriceMinor int64 `json:"price_minor"` // CNY 分(展示) + Currency string `json:"currency"` // CNY + PriceUsdtMicro int64 `json:"price_usdt_micro"` // USDT 微元(展示;各币种一口价、非汇率换算) + Promo bool `json:"promo,omitempty"` // true=优惠档,客户端用特殊卡片展示(全员可见) } -// Catalog 三档单源。时长取宽松口径(31/92/366 覆盖大月与最长季)。 +// Catalog 单源。时长取宽松口径(31/92/366 覆盖大月与最长季)。 +// USDT 展示价与 pay 侧 products 一口价对齐,不做实时汇率换算。 +// pro_month_promo 是全员优惠月付(¥6/$1);**pay 侧需种同 biz_code 商品后方可真下单**, +// 未种前下单 pay 回 product_not_found → 404(UI 先就绪)。 var Catalog = []CatalogItem{ - {SKU: "pro_month", Plan: string(codes.PlanPro), Days: 31, PriceMinor: 2999, Currency: "CNY"}, - {SKU: "pro_quarter", Plan: string(codes.PlanPro), Days: 92, PriceMinor: 6888, Currency: "CNY"}, - {SKU: "pro_year", Plan: string(codes.PlanPro), Days: 366, PriceMinor: 19999, Currency: "CNY"}, + {SKU: "pro_month_promo", Plan: string(codes.PlanPro), Days: 31, PriceMinor: 600, Currency: "CNY", PriceUsdtMicro: 1000000, Promo: true}, + {SKU: "pro_month", Plan: string(codes.PlanPro), Days: 31, PriceMinor: 2999, Currency: "CNY", PriceUsdtMicro: 4990000}, + {SKU: "pro_quarter", Plan: string(codes.PlanPro), Days: 92, PriceMinor: 6888, Currency: "CNY", PriceUsdtMicro: 12990000}, + {SKU: "pro_year", Plan: string(codes.PlanPro), Days: 366, PriceMinor: 19999, Currency: "CNY", PriceUsdtMicro: 34990000}, } func CatalogBySKU(sku string) (CatalogItem, bool) { @@ -28,3 +36,21 @@ func CatalogBySKU(sku string) (CatalogItem, bool) { } return CatalogItem{}, false } + +// SettlementCurrency 按支付方式给出结算币种(展示/落台账用;实际以 pay 侧为准)。 +// crypto→USDT;其余法币渠道(alipay/nezha/…)→CNY。 +func SettlementCurrency(method string) string { + if method == "crypto" { + return "USDT" + } + return "CNY" +} + +// DisplayAmountMinor 按支付方式给出该档「展示金额」(下单时预估、落台账, +// webhook 到账后由实际结算金额覆盖)。crypto→USDT 微元;法币→CNY 分。 +func DisplayAmountMinor(it CatalogItem, method string) int64 { + if method == "crypto" { + return it.PriceUsdtMicro + } + return it.PriceMinor +} diff --git a/server/internal/pay/handler.go b/server/internal/pay/handler.go index aba7369..93d6fb6 100644 --- a/server/internal/pay/handler.go +++ b/server/internal/pay/handler.go @@ -109,7 +109,8 @@ func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) { apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest) return } - if _, ok := CatalogBySKU(req.SKU); !ok || req.Method == "" { + item, ok := CatalogBySKU(req.SKU) + if !ok || req.Method == "" { apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest) return } @@ -124,22 +125,107 @@ func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) { return } + // 优惠档(Promo SKU)每账号限购一次:该用户已有该 SKU 的 paid 台账则拒单, + // 不打上游(真机实测发现用户可反复购买优惠档)。Retry/Cancel 不受限——恢复原单不受限。 + if item.Promo { + used, err := h.store.HasPaidPurchase(ctx, uid, req.SKU) + if err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + if used { + apierr.WriteJSON(w, http.StatusConflict, + apierr.New("PROMO_ALREADY_USED", "优惠套餐每个账号限购一次,你已购买过", "This promotional plan is limited to one per account and you have already purchased it")) + return + } + } + res, err := h.client.CreateOrder(ctx, req.SKU, req.Method, bizRef, filterMetadata(req.Metadata)) if err != nil { writePayErr(w, err) return } - if err := h.store.Insert(ctx, uid, bizRef, req.SKU, res.OrderNo, req.Method); err != nil { + // 下单即落展示金额/结算币种(按支付方式);webhook 到账后由实际结算金额覆盖。 + amount := DisplayAmountMinor(item, req.Method) + currency := SettlementCurrency(req.Method) + if err := h.store.Insert(ctx, uid, bizRef, req.SKU, res.OrderNo, req.Method, amount, currency); err != nil { // 不吞单:webhook 会按 biz_ref 兜底补台账,这里记日志便于追查。 slog.Error("pay: 台账写入失败(webhook 将按 biz_ref 兜底)", "order_no", res.OrderNo, "err", err) } writeJSON(w, sessionResponse{OrderNo: res.OrderNo, Session: res.Session}) } +// ─── GET /v1/pay/orders (列表) ────────────────────────────────────────────── + +// orderView 是订单的台账视图(列表/详情共用富字段;金额为结算币种 minor:CNY 分 / USDT 微元)。 +type orderView struct { + OrderNo string `json:"order_no"` + SKU string `json:"sku"` + Plan string `json:"plan"` + Days int `json:"days"` + Method string `json:"method"` + Status string `json:"status"` // 本地台账:created | paid | canceled + AmountMinor int64 `json:"amount_minor"` + Currency string `json:"currency"` + Channel string `json:"channel,omitempty"` + CreatedAt string `json:"created_at"` // RFC3339 + PaidAt *string `json:"paid_at,omitempty"` +} + +// 未付订单展示层过期窗口(近似 pay 会话时效;仅影响列表/详情显示,不改台账)。 +const orderPendingTTL = 30 * time.Minute + +func orderViewFromRow(row *PurchaseRow) orderView { + v := orderView{ + OrderNo: row.OutTradeNo, SKU: row.SKU, Method: row.Method, Status: row.Status, + AmountMinor: row.AmountMinor, Currency: row.Currency, Channel: row.Channel, + CreatedAt: row.CreatedAt.UTC().Format(time.RFC3339), + } + if it, ok := CatalogBySKU(row.SKU); ok { + v.Plan, v.Days = it.Plan, it.Days + // 展示金额兜底:老单/webhook 前 amount_minor=0(旧下单不落金额)→ 按档位+ + // 支付方式的结算币种回退到展示价,列表/详情不再显 0。webhook 到账后为实际金额,不触发。 + if v.AmountMinor == 0 { + v.AmountMinor = DisplayAmountMinor(it, row.Method) + if v.Currency == "" { + v.Currency = SettlementCurrency(row.Method) + } + } + } + if row.PaidAt.Valid { + s := row.PaidAt.Time.UTC().Format(time.RFC3339) + v.PaidAt = &s + } + if row.Status == "created" && row.CreatedAt.Add(orderPendingTTL).Before(time.Now()) { + v.Status = "expired" // 展示层:台账仍 created,可继续支付/取消 + } + return v +} + +// List 列出当前用户历史订单(倒序;纯本地台账,不逐单回查 pay 上游)。 +func (h *Handler) List(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + uid, ok := auth.UserIDFromContext(ctx) + if !ok { + apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) + return + } + rows, err := h.store.ListByUser(ctx, uid, 100) + if err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + out := make([]orderView, 0, len(rows)) + for i := range rows { + out = append(out, orderViewFromRow(&rows[i])) + } + writeJSON(w, map[string]any{"orders": out}) +} + // ─── GET /v1/pay/orders/{orderNo} ─────────────────────────────────────────── type orderStatusResponse struct { - OrderNo string `json:"order_no"` + orderView PayStatus string `json:"pay_status"` // pay 侧状态词汇原样透传 Activated bool `json:"activated"` // 本地台账已消费(权益已开通)——客户端轮询以此为成功判据 ExpiresAt *string `json:"expires_at,omitempty"` @@ -166,7 +252,7 @@ func (h *Handler) GetOrder(w http.ResponseWriter, r *http.Request) { writePayErr(w, err) return } - resp := orderStatusResponse{OrderNo: orderNo, PayStatus: st.Status, Activated: row.Status == "paid"} + resp := orderStatusResponse{orderView: orderViewFromRow(row), PayStatus: st.Status, Activated: row.Status == "paid"} if row.SubID.Valid { if exp, err := h.store.SubscriptionExpiry(ctx, row.SubID.Int64); err == nil { s := exp.UTC().Format(time.RFC3339) diff --git a/server/internal/pay/handler_test.go b/server/internal/pay/handler_test.go index d2f5e2c..cfa7d67 100644 --- a/server/internal/pay/handler_test.go +++ b/server/internal/pay/handler_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/go-chi/chi/v5" @@ -27,6 +28,7 @@ func newHandlerRig(t *testing.T, payFn http.HandlerFunc) (*chi.Mux, *Store) { h := NewHandler(NewClient(srv.URL, "pangolin", testSecret), st, db) r := chi.NewRouter() r.Get("/v1/pay/catalog", h.Catalog) + r.Get("/v1/pay/orders", h.List) r.Post("/v1/pay/orders", h.CreateOrder) r.Get("/v1/pay/orders/{orderNo}", h.GetOrder) r.Post("/v1/pay/orders/{orderNo}/retry", h.Retry) @@ -86,7 +88,7 @@ func TestGetOrder_OwnershipEnforced(t *testing.T) { _, _ = w.Write([]byte(`{"data":{"order_no":"pay001","status":"pending", "subject":"s","amount_minor":2999,"currency":"CNY"}}`)) }) - if err := st.Insert(context.Background(), 1, "uuid-1", "pro_month", "pay001", "alipay"); err != nil { + if err := st.Insert(context.Background(), 1, "uuid-1", "pro_month", "pay001", "alipay", 2999, "CNY"); err != nil { t.Fatal(err) } // 属主可查 @@ -116,7 +118,7 @@ func TestRetry_CurrencyMismatchMapped409(t *testing.T) { w.WriteHeader(http.StatusConflict) _, _ = w.Write([]byte(`{"code":"currency_mismatch","message":"换渠道需新单"}`)) }) - _ = st.Insert(context.Background(), 1, "uuid-1", "pro_month", "pay001", "crypto") + _ = st.Insert(context.Background(), 1, "uuid-1", "pro_month", "pay001", "crypto", 4990000, "USDT") body := []byte(`{"method":"alipay"}`) w := httptest.NewRecorder() router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodPost, "/v1/pay/orders/pay001/retry", bytes.NewReader(body)), 1)) @@ -132,6 +134,228 @@ func TestRetry_CurrencyMismatchMapped409(t *testing.T) { } } +// 下单时按支付方式落「展示金额 + 结算币种」(crypto→USDT 微元 / 法币→CNY 分)。 +// 单 rig(rig 内 DB 复用),mock 按转发来的 method 回不同 order_no。 +func TestCreateOrder_StoresDisplayAmountByMethod(t *testing.T) { + router, st := newHandlerRig(t, func(w http.ResponseWriter, r *http.Request) { + var fwd struct { + Method string `json:"method"` + } + _ = json.NewDecoder(r.Body).Decode(&fwd) + _, _ = w.Write([]byte(`{"data":{"order_no":"pay-` + fwd.Method + `","session":{ + "render_type":"redirect","payload":{"url":"https://x"}}}}`)) + }) + cases := []struct { + method string + wantAmt int64 + wantCurr string + }{ + {"crypto", 4990000, "USDT"}, // pro_month USDT 微元 + {"alipay", 2999, "CNY"}, // pro_month CNY 分 + } + for _, tc := range cases { + body := []byte(`{"sku":"pro_month","method":"` + tc.method + `"}`) + w := httptest.NewRecorder() + router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodPost, "/v1/pay/orders", bytes.NewReader(body)), 1)) + if w.Code != http.StatusOK { + t.Fatalf("%s: code = %d, body = %s", tc.method, w.Code, w.Body) + } + row, err := st.GetForUser(context.Background(), 1, "pay-"+tc.method) + if err != nil { + t.Fatalf("%s: 台账未落: %v", tc.method, err) + } + if row.AmountMinor != tc.wantAmt || row.Currency != tc.wantCurr { + t.Fatalf("%s: amount=%d curr=%q, want %d/%q", tc.method, row.AmountMinor, row.Currency, tc.wantAmt, tc.wantCurr) + } + } +} + +// 订单列表:只列本人、倒序、含台账富字段。 +func TestList_OwnershipAndFields(t *testing.T) { + router, st := newHandlerRig(t, nil) + ctx := context.Background() + // user 1 两单(先月后年),user 2 一单(不该出现在 user 1 列表) + if err := st.Insert(ctx, 1, "uuid-1", "pro_month", "o1", "alipay", 2999, "CNY"); err != nil { + t.Fatal(err) + } + if err := st.Insert(ctx, 1, "uuid-1", "pro_year", "o2", "crypto", 34990000, "USDT"); err != nil { + t.Fatal(err) + } + if err := st.Insert(ctx, 2, "uuid-2", "pro_month", "o3", "alipay", 2999, "CNY"); err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodGet, "/v1/pay/orders", nil), 1)) + if w.Code != http.StatusOK { + t.Fatalf("code = %d, body = %s", w.Code, w.Body) + } + var resp struct { + Orders []orderView `json:"orders"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if len(resp.Orders) != 2 { + t.Fatalf("want 2 orders for user 1, got %d: %+v", len(resp.Orders), resp.Orders) + } + for _, o := range resp.Orders { + if o.OrderNo == "o3" { + t.Fatalf("越权:看到了他人订单 o3") + } + } + // 倒序:o2(pro_year, 后插)在前,且富字段完整 + first := resp.Orders[0] + if first.OrderNo != "o2" || first.SKU != "pro_year" || first.Plan != "pro" || + first.Method != "crypto" || first.AmountMinor != 34990000 || first.Currency != "USDT" || + first.Days != 366 || first.Status != "created" || first.CreatedAt == "" { + t.Fatalf("首单富字段不符: %+v", first) + } +} + +// 展示金额兜底:老单 amount_minor=0 → 列表按档位+方式回退到展示价(不显 0)。 +func TestList_ZeroAmountFallback(t *testing.T) { + router, st := newHandlerRig(t, nil) + ctx := context.Background() + // 模拟老单:amount=0、currency 空(旧下单不落金额) + if err := st.Insert(ctx, 1, "uuid-1", "pro_month", "old-alipay", "alipay", 0, ""); err != nil { + t.Fatal(err) + } + if err := st.Insert(ctx, 1, "uuid-1", "pro_year", "old-crypto", "crypto", 0, ""); err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodGet, "/v1/pay/orders", nil), 1)) + var resp struct { + Orders []orderView `json:"orders"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + got := map[string]orderView{} + for _, o := range resp.Orders { + got[o.OrderNo] = o + } + // alipay → CNY 展示价 2999;crypto → USDT 展示价 34990000 + if a := got["old-alipay"]; a.AmountMinor != 2999 || a.Currency != "CNY" { + t.Fatalf("alipay 兜底不符: amount=%d curr=%q, want 2999/CNY", a.AmountMinor, a.Currency) + } + if cr := got["old-crypto"]; cr.AmountMinor != 34990000 || cr.Currency != "USDT" { + t.Fatalf("crypto 兜底不符: amount=%d curr=%q, want 34990000/USDT", cr.AmountMinor, cr.Currency) + } +} + +// 订单详情:回带台账富字段 + pay_status/activated。 +func TestGetOrder_EnrichedFields(t *testing.T) { + router, st := newHandlerRig(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"order_no":"pay001","status":"pending","currency":"USDT"}}`)) + }) + if err := st.Insert(context.Background(), 1, "uuid-1", "pro_year", "pay001", "crypto", 34990000, "USDT"); err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodGet, "/v1/pay/orders/pay001", nil), 1)) + if w.Code != http.StatusOK { + t.Fatalf("code = %d", w.Code) + } + var resp struct { + OrderNo string `json:"order_no"` + SKU string `json:"sku"` + Plan string `json:"plan"` + Method string `json:"method"` + AmountMinor int64 `json:"amount_minor"` + Currency string `json:"currency"` + CreatedAt string `json:"created_at"` + PayStatus string `json:"pay_status"` + Activated bool `json:"activated"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp.SKU != "pro_year" || resp.Plan != "pro" || resp.Method != "crypto" || + resp.AmountMinor != 34990000 || resp.Currency != "USDT" || resp.CreatedAt == "" || + resp.PayStatus != "pending" || resp.Activated { + t.Fatalf("详情富字段不符: %+v", resp) + } +} + +// 优惠档(Promo SKU)限购一次:已有 paid 台账 → 下单 409 PROMO_ALREADY_USED, +// 且不打上游(fake pay client 一旦被调用即 t.Fatal)。 +func TestCreateOrder_PromoAlreadyUsed409(t *testing.T) { + router, st := newHandlerRig(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatal("已购过优惠档,不该打到 pay 上游") + }) + if err := st.Insert(context.Background(), 1, "uuid-1", "pro_month_promo", "promo-old", "alipay", 600, "CNY"); err != nil { + t.Fatal(err) + } + if _, err := st.db.ExecContext(context.Background(), + `UPDATE pay_purchases SET status = 'paid' WHERE out_trade_no = 'promo-old'`); err != nil { + t.Fatal(err) + } + body := []byte(`{"sku":"pro_month_promo","method":"alipay"}`) + w := httptest.NewRecorder() + router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodPost, "/v1/pay/orders", bytes.NewReader(body)), 1)) + if w.Code != http.StatusConflict { + t.Fatalf("code = %d, body = %s", w.Code, w.Body) + } + var e struct { + Code string `json:"code"` + } + if err := json.Unmarshal(w.Body.Bytes(), &e); err != nil { + t.Fatal(err) + } + if e.Code != "PROMO_ALREADY_USED" { + t.Fatalf("code = %q, want PROMO_ALREADY_USED", e.Code) + } +} + +// 优惠档历史只有 created/canceled(未 paid)→ 不受限,正常放行下单。 +func TestCreateOrder_PromoNotYetPaidAllowed(t *testing.T) { + router, st := newHandlerRig(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"order_no":"promo-new","session":{ + "render_type":"redirect","payload":{"url":"https://alipay.example/x"}}}}`)) + }) + if err := st.Insert(context.Background(), 1, "uuid-1", "pro_month_promo", "promo-created", "alipay", 600, "CNY"); err != nil { + t.Fatal(err) + } + if err := st.MarkCanceled(context.Background(), 1, "promo-created"); err != nil { + t.Fatal(err) + } + body := []byte(`{"sku":"pro_month_promo","method":"alipay"}`) + w := httptest.NewRecorder() + router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodPost, "/v1/pay/orders", bytes.NewReader(body)), 1)) + if w.Code != http.StatusOK { + t.Fatalf("code = %d, body = %s", w.Code, w.Body) + } + row, err := st.GetForUser(context.Background(), 1, "promo-new") + if err != nil { + t.Fatalf("台账未落: %v", err) + } + if row.SKU != "pro_month_promo" || row.Status != "created" { + t.Fatalf("台账行不符: %+v", row) + } +} + +// 非 promo SKU 即使已 paid 也不受限购约束(限购只针对 Promo==true 的 SKU)。 +func TestCreateOrder_NonPromoSKUUnlimited(t *testing.T) { + router, st := newHandlerRig(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"order_no":"month-2","session":{ + "render_type":"redirect","payload":{"url":"https://alipay.example/x"}}}}`)) + }) + if err := st.Insert(context.Background(), 1, "uuid-1", "pro_month", "month-1", "alipay", 2999, "CNY"); err != nil { + t.Fatal(err) + } + if _, err := st.db.ExecContext(context.Background(), + `UPDATE pay_purchases SET status = 'paid' WHERE out_trade_no = 'month-1'`); err != nil { + t.Fatal(err) + } + body := []byte(`{"sku":"pro_month","method":"alipay"}`) + w := httptest.NewRecorder() + router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodPost, "/v1/pay/orders", bytes.NewReader(body)), 1)) + if w.Code != http.StatusOK { + t.Fatalf("code = %d, body = %s", w.Code, w.Body) + } +} + func TestCatalog(t *testing.T) { router, _ := newHandlerRig(t, nil) w := httptest.NewRecorder() @@ -140,7 +364,47 @@ func TestCatalog(t *testing.T) { Items []CatalogItem `json:"items"` } _ = json.Unmarshal(w.Body.Bytes(), &resp) - if len(resp.Items) != 3 || resp.Items[0].SKU != "pro_month" { + if len(resp.Items) != 4 || resp.Items[0].SKU != "pro_month_promo" { t.Fatalf("catalog = %+v", resp.Items) } + // 优惠档:全员可见,promo 标记 + ¥6/$1 展示价。 + promo := resp.Items[0] + if !promo.Promo || promo.PriceMinor != 600 || promo.PriceUsdtMicro != 1000000 { + t.Fatalf("promo item 不符: %+v", promo) + } +} + +// 老 created 单超 TTL → 列表视图显 expired;近期 created → 仍 created。 +func TestList_ExpiredWhenStale(t *testing.T) { + router, st := newHandlerRig(t, nil) + ctx := context.Background() + // 直接构造两条 created 单,改 created_at 到很久以前 / 刚刚 + if err := st.Insert(ctx, 1, "uuid-1", "pro_month", "old", "alipay", 2999, "CNY"); err != nil { + t.Fatal(err) + } + if err := st.Insert(ctx, 1, "uuid-1", "pro_month", "fresh", "alipay", 2999, "CNY"); err != nil { + t.Fatal(err) + } + // 把 old 的 created_at 回拨到 TTL 之前 + if _, err := st.db.ExecContext(ctx, + `UPDATE pay_purchases SET created_at = ? WHERE out_trade_no = 'old'`, + time.Now().Add(-2*orderPendingTTL).UTC()); err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodGet, "/v1/pay/orders", nil), 1)) + var resp struct { + Orders []orderView `json:"orders"` + } + _ = json.Unmarshal(w.Body.Bytes(), &resp) + got := map[string]string{} + for _, o := range resp.Orders { + got[o.OrderNo] = o.Status + } + if got["old"] != "expired" { + t.Fatalf("old 应过期, got %q", got["old"]) + } + if got["fresh"] != "created" { + t.Fatalf("fresh 应 created, got %q", got["fresh"]) + } } diff --git a/server/internal/pay/store.go b/server/internal/pay/store.go index f0bcdf0..130c312 100644 --- a/server/internal/pay/store.go +++ b/server/internal/pay/store.go @@ -23,6 +23,7 @@ type PurchaseRow struct { Channel string SubID sql.NullInt64 PaidAt sql.NullTime + CreatedAt time.Time } type Store struct { @@ -38,13 +39,15 @@ func (s *Store) BeginTx(ctx context.Context) (*sql.Tx, error) { return s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) } -// Insert 下单成功后落台账(status=created)。 -func (s *Store) Insert(ctx context.Context, userID int64, bizRef, sku, outTradeNo, method string) error { +// Insert 下单成功后落台账(status=created)。amountMinor/currency 是下单时的 +// **展示预估**(按支付方式的结算币种,见 DisplayAmountMinor),webhook 到账后由 +// MarkPaidTx 用实际结算金额覆盖——保证订单列表/详情在支付前也有金额可显。 +func (s *Store) Insert(ctx context.Context, userID int64, bizRef, sku, outTradeNo, method string, amountMinor int64, currency string) error { now := time.Now().UTC() _, err := s.db.ExecContext(ctx, - `INSERT INTO pay_purchases (user_id, biz_ref, sku, out_trade_no, method, status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, 'created', ?, ?)`, - userID, bizRef, sku, outTradeNo, method, now, now) + `INSERT INTO pay_purchases (user_id, biz_ref, sku, out_trade_no, method, status, amount_minor, currency, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 'created', ?, ?, ?, ?)`, + userID, bizRef, sku, outTradeNo, method, amountMinor, currency, now, now) if err != nil { return fmt.Errorf("pay.Store.Insert: %w", err) } @@ -52,17 +55,42 @@ func (s *Store) Insert(ctx context.Context, userID int64, bizRef, sku, outTradeN } const purchaseCols = `id, user_id, biz_ref, sku, out_trade_no, method, status, - amount_minor, currency, channel, sub_id, paid_at` + amount_minor, currency, channel, sub_id, paid_at, created_at` func scanPurchase(row *sql.Row) (*PurchaseRow, error) { var p PurchaseRow if err := row.Scan(&p.ID, &p.UserID, &p.BizRef, &p.SKU, &p.OutTradeNo, &p.Method, - &p.Status, &p.AmountMinor, &p.Currency, &p.Channel, &p.SubID, &p.PaidAt); err != nil { + &p.Status, &p.AmountMinor, &p.Currency, &p.Channel, &p.SubID, &p.PaidAt, &p.CreatedAt); err != nil { return nil, err } return &p, nil } +// ListByUser 按用户列历史订单(created_at 倒序,复用 idx_pay_user 索引)。 +// 供「订单列表」页;limit 上限保护。 +func (s *Store) ListByUser(ctx context.Context, userID int64, limit int) ([]PurchaseRow, error) { + if limit <= 0 || limit > 200 { + limit = 100 + } + rows, err := s.db.QueryContext(ctx, + `SELECT `+purchaseCols+` FROM pay_purchases WHERE user_id = ? ORDER BY created_at DESC, id DESC LIMIT ?`, + userID, limit) + if err != nil { + return nil, fmt.Errorf("pay.Store.ListByUser: %w", err) + } + defer rows.Close() + var out []PurchaseRow + for rows.Next() { + var p PurchaseRow + if err := rows.Scan(&p.ID, &p.UserID, &p.BizRef, &p.SKU, &p.OutTradeNo, &p.Method, + &p.Status, &p.AmountMinor, &p.Currency, &p.Channel, &p.SubID, &p.PaidAt, &p.CreatedAt); err != nil { + return nil, fmt.Errorf("pay.Store.ListByUser scan: %w", err) + } + out = append(out, p) + } + return out, rows.Err() +} + // GetForUser 按 (userID, outTradeNo) 取行——所有权校验由查询本身完成。 func (s *Store) GetForUser(ctx context.Context, userID int64, outTradeNo string) (*PurchaseRow, error) { return scanPurchase(s.db.QueryRowContext(ctx, @@ -130,6 +158,55 @@ func (s *Store) MarkCanceled(ctx context.Context, userID int64, outTradeNo strin return nil } +// HasPaidPurchase 该用户是否已有该 SKU 的 paid 台账——供优惠档(Promo SKU)限购一次校验用 +// (CreateOrder 在调上游前查,已购则 409 拒单,不打上游)。 +func (s *Store) HasPaidPurchase(ctx context.Context, userID int64, sku string) (bool, error) { + var n int + err := s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM pay_purchases WHERE user_id = ? AND sku = ? AND status = 'paid'`, + userID, sku).Scan(&n) + if err != nil { + return false, fmt.Errorf("pay.Store.HasPaidPurchase: %w", err) + } + return n > 0, nil +} + +// HasPaidPurchaseExcludingTx 是 HasPaidPurchase 的事务内(锁行后)复查版本,供 +// webhook.go settle 在开通前对 Promo SKU 再查一次——CreateOrder 那次下单时的 +// 检查是裸 SELECT 无锁,并发/多挂起单可绕过(TOCTOU)。excludeID 排除本单自己 +// (本单尚未 MarkPaidTx,通常不会自匹配,但显式排除更稳妥、也便于未来复用)。 +func (s *Store) HasPaidPurchaseExcludingTx(ctx context.Context, tx *sql.Tx, userID int64, sku string, excludeID int64) (bool, error) { + var n int + err := tx.QueryRowContext(ctx, + `SELECT COUNT(*) FROM pay_purchases WHERE user_id = ? AND sku = ? AND status = 'paid' AND id <> ?`, + userID, sku, excludeID).Scan(&n) + if err != nil { + return false, fmt.Errorf("pay.Store.HasPaidPurchaseExcludingTx: %w", err) + } + return n > 0, nil +} + +// MarkDuplicatePromoTx 收口一笔在 TOCTOU 竞争中"输"掉的 Promo SKU 重复单—— +// 另一笔同 user+SKU 的订单已先一步 settle 为 paid(见 webhook.go settle 的 +// promo 复查)。刻意标记为 'canceled' 而非 'paid':(user_id, sku) WHERE +// status='paid' 是部分唯一索引(migration 000027,仅 sqlite)本就不允许同一 +// user+promo-SKU 出现第二条 paid 行,这里若也写 paid 会在 sqlite 上直接撞 +// 约束报错、把整个 webhook 打成 500 引发 pay 无限重投——与"吞掉重复单,不 +// 再重投"的目标相反。仍落一次结算回执字段(amount/currency/channel/paid_at) +// 供人工核对"钱是否真收到过、为何没有二次开通",不同于用户主动取消未付 +// 单的语义(MarkCanceled 的原生用途),但复用同一 status 取值。 +func (s *Store) MarkDuplicatePromoTx(ctx context.Context, tx *sql.Tx, id int64, amountMinor int64, currency, channel string, paidAt time.Time) error { + _, err := tx.ExecContext(ctx, + `UPDATE pay_purchases SET status = 'canceled', amount_minor = ?, currency = ?, + channel = ?, paid_at = ?, updated_at = ? + WHERE id = ?`, + amountMinor, currency, channel, paidAt, time.Now().UTC(), id) + if err != nil { + return fmt.Errorf("pay.Store.MarkDuplicatePromoTx: %w", err) + } + return nil +} + // SubscriptionExpiry 查开通行的到期时间(查单响应回带给客户端)。 func (s *Store) SubscriptionExpiry(ctx context.Context, subID int64) (time.Time, error) { var exp time.Time diff --git a/server/internal/pay/webhook.go b/server/internal/pay/webhook.go index a91de4d..945849d 100644 --- a/server/internal/pay/webhook.go +++ b/server/internal/pay/webhook.go @@ -35,6 +35,8 @@ type WebhookHandler struct { tolerance time.Duration nonceTTL time.Duration now func() time.Time // 测试注入 + rewarder Rewarder // 可选:首充邀请奖励钩子(nil 则跳过) + noticer Noticer // 可选:购买开通到账通知钩子(nil 则跳过) } func NewWebhookHandler(store *Store, granter Granter, db *sql.DB, rdb *redis.Client, @@ -43,6 +45,22 @@ func NewWebhookHandler(store *Store, granter Granter, db *sql.DB, rdb *redis.Cli system: system, secret: secret, tolerance: tolerance, nonceTTL: nonceTTL, now: time.Now} } +// Rewarder(可选)在首充同事务内发放邀请首充奖励;nil 则跳过。 +type Rewarder interface { + OnFirstPaidTx(ctx context.Context, tx *sql.Tx, inviteeID int64, now time.Time) error +} + +// SetRewarder 挂载首充邀请奖励钩子(reward.Service 满足此接口)。 +func (h *WebhookHandler) SetRewarder(r Rewarder) { h.rewarder = r } + +// Noticer 抽象 notices.Store 的事务内插入入口(测试可替身;生产传 notices.NewStore(db))。 +type Noticer interface { + InsertNoticeTx(ctx context.Context, tx *sql.Tx, userID int64, typ, titleZH, titleEN, bodyZH, bodyEN, link string, now time.Time) error +} + +// SetNoticer 挂载购买开通到账通知钩子;为 nil 时跳过(装配前兼容)。 +func (h *WebhookHandler) SetNoticer(n Noticer) { h.noticer = n } + // webhookEvent 对应 pay settle.go::enqueuePaymentSucceeded 的 payload // (注意:payment.succeeded 无 refund_id 字段)。 type webhookEvent struct { @@ -160,17 +178,52 @@ func (h *WebhookHandler) settle(ctx context.Context, ev *webhookEvent) error { purchaseID, userID = row.ID, row.UserID } + paidAt, perr := time.Parse(time.RFC3339, ev.PaidAt) + if perr != nil { + paidAt = h.now().UTC() + } + + // C2 安全修复(promo 限购 TOCTOU):CreateOrder 下单时的 HasPaidPurchase 只在 + // 下单一刻查、裸 SELECT 无锁——并发/多笔挂起单可绕过"每账号限购一次"。这里 + // 在锁行、开通前对 Promo SKU 再复查一次:命中说明另一笔同 user+SKU 的订单 + // 已抢先 settle 为 paid,本单跳过发放(不二次 +N 天),仍需 ACK 200,否则 pay + // 会把 500 当失败无限重投。sqlite 侧另有 migration 000027 的部分唯一索引 + // (user_id, sku) WHERE status='paid' 兜底;mysql 不支持部分索引,本检查是 + // mysql 侧唯一防线(见该迁移文件注释)。 + if item.Promo { + dup, err := h.store.HasPaidPurchaseExcludingTx(ctx, tx, userID, ev.ProductBizCode, purchaseID) + if err != nil { + return err + } + if dup { + slog.Warn("pay webhook: promo 限购 TOCTOU 命中,跳过发放并吞掉重复单", + "order_no", ev.OutTradeNo, "user_id", userID, "sku", ev.ProductBizCode) + if err := h.store.MarkDuplicatePromoTx(ctx, tx, purchaseID, ev.AmountMinor, ev.Currency, ev.Channel, paidAt); err != nil { + return err + } + return tx.Commit() + } + } + subID, _, err := h.granter.GrantPaidSubscriptionTx(ctx, tx, userID, codes.PlanCode(item.Plan), item.Days, "pay:"+ev.OutTradeNo) if err != nil { return err } - paidAt, perr := time.Parse(time.RFC3339, ev.PaidAt) - if perr != nil { - paidAt = h.now().UTC() - } if err := h.store.MarkPaidTx(ctx, tx, purchaseID, ev.AmountMinor, ev.Currency, ev.Channel, subID, paidAt); err != nil { return err } + if h.rewarder != nil { + if err := h.rewarder.OnFirstPaidTx(ctx, tx, userID, h.now().UTC()); err != nil { + return err // 同事务:发奖失败则整笔回滚,webhook 重试 + } + } + if h.noticer != nil { + zh := fmt.Sprintf("已开通 Pro · %d 天", item.Days) + en := fmt.Sprintf("Pro activated · %d days", item.Days) + if err := h.noticer.InsertNoticeTx(ctx, tx, userID, "reward", zh, en, "", "", "", h.now().UTC()); err != nil { + return err // 同事务:通知插入失败则整笔回滚,webhook 重试 + } + } return tx.Commit() } diff --git a/server/internal/pay/webhook_notice_sqlite_test.go b/server/internal/pay/webhook_notice_sqlite_test.go new file mode 100644 index 0000000..42ad1cb --- /dev/null +++ b/server/internal/pay/webhook_notice_sqlite_test.go @@ -0,0 +1,28 @@ +package pay + +import ( + "context" + "testing" + + "github.com/wangjia/pangolin/server/internal/notices" +) + +func TestSettle_InsertsPurchaseNotice(t *testing.T) { + h, db, st := newWebhookRig(t) + h.SetNoticer(notices.NewStore(db)) + ctx := context.Background() + if err := st.Insert(ctx, 1, "uuid-1", "pro_month", "pay-notice-1", "crypto", 4990000, "USDT"); err != nil { + t.Fatal(err) + } + w := deliver(t, h, succeededPayload("pay-notice-1", "pro_month")) + if w.Code != 200 { + t.Fatalf("code=%d body=%s", w.Code, w.Body) + } + var n int + if err := db.QueryRow(`SELECT COUNT(*) FROM notices WHERE type='reward' AND user_id=1`).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("购买开通应插入一条到账通知, got %d", n) + } +} diff --git a/server/internal/pay/webhook_promo_sqlite_test.go b/server/internal/pay/webhook_promo_sqlite_test.go new file mode 100644 index 0000000..d43f49f --- /dev/null +++ b/server/internal/pay/webhook_promo_sqlite_test.go @@ -0,0 +1,140 @@ +package pay + +import ( + "context" + "net/http" + "strings" + "testing" + "time" +) + +// -------------------------------------------------------------------------- +// C2 · promo 限购 TOCTOU: CreateOrder's HasPaidPurchase check is a bare, +// unlocked SELECT at order-creation time — concurrent/multi-pending orders +// for the same promo SKU can both reach 'created' before either settles. +// webhook.go's settle must re-check inside the locked transaction, before +// granting, and swallow the duplicate (skip the grant, still ACK so pay +// doesn't retry forever) rather than double-granting +31 days per order. +// -------------------------------------------------------------------------- + +// seedPromoDuplicateOrders inserts two 'created' orders for the SAME user + +// promo SKU (pro_month_promo) — simulating the TOCTOU window where both +// orders were created before either was paid. +func seedPromoDuplicateOrders(t *testing.T, st *Store) { + t.Helper() + ctx := context.Background() + if err := st.Insert(ctx, 1, "uuid-1", "pro_month_promo", "pay-promo-1", "alipay", 600, "CNY"); err != nil { + t.Fatalf("insert promo order 1: %v", err) + } + if err := st.Insert(ctx, 1, "uuid-1", "pro_month_promo", "pay-promo-2", "wxpay", 600, "CNY"); err != nil { + t.Fatalf("insert promo order 2: %v", err) + } +} + +func TestWebhook_PromoDuplicateSettleSkipsGrantButAcks(t *testing.T) { + h, db, st := newWebhookRig(t) + seedPromoDuplicateOrders(t, st) + + // First promo order settles normally: paid + granted. + w1 := deliver(t, h, succeededPayload("pay-promo-1", "pro_month_promo")) + if w1.Code != http.StatusOK || !strings.Contains(w1.Body.String(), "SUCCESS") { + t.Fatalf("第一单应正常开通: %d %q", w1.Code, w1.Body.String()) + } + var expiresAfterFirst time.Time + if err := db.QueryRow(`SELECT expires_at FROM subscriptions WHERE user_id = 1`).Scan(&expiresAfterFirst); err != nil { + t.Fatalf("第一单未开通订阅: %v", err) + } + + // Second promo order for the SAME user+SKU settles (TOCTOU duplicate): + // must still ACK 200 SUCCESS (else pay retries this webhook forever), + // but must NOT grant a second +31 days. + w2 := deliver(t, h, succeededPayload("pay-promo-2", "pro_month_promo")) + if w2.Code != http.StatusOK || !strings.Contains(w2.Body.String(), "SUCCESS") { + t.Fatalf("重复 promo 单应仍 ACK(否则 pay 会无限重投): %d %q", w2.Code, w2.Body.String()) + } + + var n int + if err := db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id = 1`).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("不得为重复 promo 单二次开通: subscriptions rows = %d, want 1", n) + } + var expiresAfterSecond time.Time + if err := db.QueryRow(`SELECT expires_at FROM subscriptions WHERE user_id = 1`).Scan(&expiresAfterSecond); err != nil { + t.Fatal(err) + } + if !expiresAfterFirst.Equal(expiresAfterSecond) { + t.Errorf("到期被重复叠加: %v → %v(应保持不变,promo 限购一次)", expiresAfterFirst, expiresAfterSecond) + } + + // The duplicate order's own ledger row must not become a second 'paid' + // row for the same (user_id, sku) — that's exactly what migration 000027's + // partial unique index forbids on sqlite, and what the settle-side + // re-check must avoid ever attempting. + row2, err := st.GetForUser(context.Background(), 1, "pay-promo-2") + if err != nil { + t.Fatalf("重复单台账未找到: %v", err) + } + if row2.Status == "paid" { + t.Errorf("重复 promo 单不应被标记为二条 paid(会撞 (user_id,sku) 唯一索引): status = %q", row2.Status) + } + if row2.SubID.Valid { + t.Errorf("重复 promo 单不应挂 sub_id: %+v", row2.SubID) + } + + // Redelivery of the SAME duplicate webhook (new nonce, same out_trade_no) + // must remain idempotent — no further side effects, still ACK. + w3 := deliver(t, h, succeededPayload("pay-promo-2", "pro_month_promo")) + if w3.Code != http.StatusOK || !strings.Contains(w3.Body.String(), "SUCCESS") { + t.Fatalf("重复单再次重投应仍 ACK: %d %q", w3.Code, w3.Body.String()) + } + if err := db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id = 1`).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("重投重复单不得二次开通: subscriptions rows = %d, want 1", n) + } +} + +// -------------------------------------------------------------------------- +// Defense-in-depth: migration 000027's sqlite partial unique index directly +// forbids two 'paid' rows for the same (user_id, sku='pro_month_promo'), +// independent of the application-layer settle check above. This guards +// against any other write path (bug, manual SQL, future code) accidentally +// double-marking a promo purchase 'paid'. +// -------------------------------------------------------------------------- + +func TestPayPurchases_PromoPaidUniqueIndex_SQLite(t *testing.T) { + db := openMigratedSQLite(t) + seedUser(t, db, 1, "uuid-1") + now := time.Now().UTC() + + if _, err := db.Exec( + `INSERT INTO pay_purchases (user_id, biz_ref, sku, out_trade_no, method, status, amount_minor, currency, created_at, updated_at) + VALUES (1, 'uuid-1', 'pro_month_promo', 'ux-1', 'alipay', 'paid', 600, 'CNY', ?, ?)`, + now, now); err != nil { + t.Fatalf("first paid promo row should insert cleanly: %v", err) + } + + _, err := db.Exec( + `INSERT INTO pay_purchases (user_id, biz_ref, sku, out_trade_no, method, status, amount_minor, currency, created_at, updated_at) + VALUES (1, 'uuid-1', 'pro_month_promo', 'ux-2', 'wxpay', 'paid', 600, 'CNY', ?, ?)`, + now, now) + if err == nil { + t.Fatal("第二条同 user+promo-sku 的 paid 行应被部分唯一索引拒绝,却插入成功") + } + if !strings.Contains(err.Error(), "UNIQUE") { + t.Errorf("err = %v, want a UNIQUE constraint violation", err) + } + + // A non-promo SKU (or a 'created'/'canceled' status row) must be + // unaffected by the partial index — sanity check it isn't over-broad. + if _, err := db.Exec( + `INSERT INTO pay_purchases (user_id, biz_ref, sku, out_trade_no, method, status, amount_minor, currency, created_at, updated_at) + VALUES (1, 'uuid-1', 'pro_month', 'ux-3', 'alipay', 'paid', 4990000, 'USDT', ?, ?)`, + now, now); err != nil { + t.Errorf("非 promo SKU 的 paid 行不应受此索引影响: %v", err) + } + +} diff --git a/server/internal/pay/webhook_referral_sqlite_test.go b/server/internal/pay/webhook_referral_sqlite_test.go new file mode 100644 index 0000000..4f41129 --- /dev/null +++ b/server/internal/pay/webhook_referral_sqlite_test.go @@ -0,0 +1,48 @@ +package pay + +import ( + "context" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/codes" + "github.com/wangjia/pangolin/server/internal/reward" +) + +func TestSettle_FirstPaidGrantsReferralBonus(t *testing.T) { + h, db, _ := newWebhookRig(t) // 复用本包 rig;rig 里 seedUser(1) + seedUser(t, db, 2, "invitee-uuid") // 被邀请人 = 付费者 + // 建 referral: inviter=1, invitee=2, 已 reg_rewarded + now := time.Now().UTC() + if _, err := db.Exec(`INSERT INTO referrals (inviter_id,invitee_id,status,reg_rewarded_at,created_at) VALUES (1,2,'reg_rewarded',?,?)`, now, now); err != nil { + t.Fatalf("seed referral: %v", err) + } + // 装 rewarder + g := codes.NewService(codes.NewStore(db), nil, 5, time.Hour) + rw := reward.NewService(db, reward.NewStore(db), g, reward.Config{RegDays: 3, PaidDays: 7, TgDays: 3, RegMonthlyCap: 10}, time.Now) + h.SetRewarder(rw) + + // 触发 settle(照本包已有 webhook 测试构造 ev:invitee=2 的一笔 paid;biz_ref=其 uuid) + err := h.settle(context.Background(), &webhookEvent{ + ProductBizCode: "pro_month", OutTradeNo: "ot-1", BizRef: "invitee-uuid", + Channel: "nezha", AmountMinor: 600, Currency: "CNY", PaidAt: now.Format(time.RFC3339), + }) + if err != nil { + t.Fatalf("settle: %v", err) + } + + var status string + if err := db.QueryRow(`SELECT status FROM referrals WHERE invitee_id=2`).Scan(&status); err != nil { + t.Fatalf("query referral status: %v", err) + } + if status != "paid_rewarded" { + t.Fatalf("status=%q, want paid_rewarded", status) + } + var fp int + if err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE id=2 AND first_paid_at IS NOT NULL`).Scan(&fp); err != nil { + t.Fatalf("query first_paid_at: %v", err) + } + if fp != 1 { + t.Fatalf("first_paid_at not set") + } +} diff --git a/server/internal/pay/webhook_sqlite_test.go b/server/internal/pay/webhook_sqlite_test.go index 5255d7b..fc7b3c7 100644 --- a/server/internal/pay/webhook_sqlite_test.go +++ b/server/internal/pay/webhook_sqlite_test.go @@ -60,7 +60,7 @@ func succeededPayload(orderNo, sku string) map[string]any { func TestWebhook_HappyPath(t *testing.T) { h, db, st := newWebhookRig(t) ctx := context.Background() - if err := st.Insert(ctx, 1, "uuid-1", "pro_month", "pay001", "crypto"); err != nil { + if err := st.Insert(ctx, 1, "uuid-1", "pro_month", "pay001", "crypto", 4990000, "USDT"); err != nil { t.Fatal(err) } w := deliver(t, h, succeededPayload("pay001", "pro_month")) @@ -92,7 +92,7 @@ func TestWebhook_HappyPath(t *testing.T) { func TestWebhook_RedeliveryIdempotent(t *testing.T) { h, db, st := newWebhookRig(t) ctx := context.Background() - _ = st.Insert(ctx, 1, "uuid-1", "pro_month", "pay001", "crypto") + _ = st.Insert(ctx, 1, "uuid-1", "pro_month", "pay001", "crypto", 4990000, "USDT") deliver(t, h, succeededPayload("pay001", "pro_month")) var exp1 time.Time _ = db.QueryRow(`SELECT expires_at FROM subscriptions WHERE user_id = 1`).Scan(&exp1) @@ -117,8 +117,8 @@ func TestWebhook_RedeliveryIdempotent(t *testing.T) { func TestWebhook_TwoOrdersStack(t *testing.T) { h, db, st := newWebhookRig(t) ctx := context.Background() - _ = st.Insert(ctx, 1, "uuid-1", "pro_month", "pay001", "crypto") - _ = st.Insert(ctx, 1, "uuid-1", "pro_quarter", "pay002", "crypto") + _ = st.Insert(ctx, 1, "uuid-1", "pro_month", "pay001", "crypto", 4990000, "USDT") + _ = st.Insert(ctx, 1, "uuid-1", "pro_quarter", "pay002", "crypto", 12990000, "USDT") deliver(t, h, succeededPayload("pay001", "pro_month")) deliver(t, h, succeededPayload("pay002", "pro_quarter")) var n int diff --git a/server/internal/reward/handler.go b/server/internal/reward/handler.go new file mode 100644 index 0000000..636a940 --- /dev/null +++ b/server/internal/reward/handler.go @@ -0,0 +1,152 @@ +package reward + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/wangjia/pangolin/server/internal/apierr" + "github.com/wangjia/pangolin/server/internal/auth" +) + +// tgMsgs 是 TG bot 回执文案表(中/英双语)。key 与 ClaimResult / 失效态对应。 +type tgMsgSet struct { + expired, granted, already, notMember, errored string +} + +var tgMsgsZH = tgMsgSet{ + expired: "链接已失效,请回 App 重新点「验证领取」。", + granted: "已到账 +3 天 Pro,感谢加入!", + already: "你已领取过本奖励(+3 天),无需重复领取。", + notMember: "未检测到你已加入频道 %s,请先加入频道,再回 App 点「验证领取」。", + errored: "验证出错,请稍后重试。", +} + +var tgMsgsEN = tgMsgSet{ + expired: "This link has expired. Please tap \u201cVerify & claim\u201d in the app again.", + granted: "+3 days of Pro credited — thanks for joining!", + already: "You've already claimed this reward (+3 days) — no need to claim again.", + notMember: "You don't seem to be a member of %s yet. Please join the channel first, then tap \u201cVerify & claim\u201d in the app.", + errored: "Verification failed, please try again later.", +} + +// tgMsgSetFor 按 Telegram language_code 选中/英文案表:zh* → 中文,其余(含空)→ 英文。 +func tgMsgSetFor(languageCode string) tgMsgSet { + if strings.HasPrefix(languageCode, "zh") { + return tgMsgsZH + } + return tgMsgsEN +} + +const inviteLinkBase = "https://pangolin.yanmeiai.com/i/" + +type Handler struct { + svc *Service + st *Store + tgEnabled bool + channel string +} + +func NewHandler(s *Service, st *Store, tgEnabled bool, channel string) *Handler { + return &Handler{svc: s, st: st, tgEnabled: tgEnabled, channel: channel} +} + +func (h *Handler) GetInvite(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + uid, ok := auth.UserIDFromContext(ctx) + if !ok { + apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) + return + } + code, err := h.svc.EnsureCode(ctx, uid) + if err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + invited, converted, earned, err := h.st.Summary(ctx, uid) + if err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + joined, _ := h.st.TelegramClaimed(ctx, uid) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{ + "invite_code": code, + "invite_link": inviteLinkBase + code, + "invited": invited, + "converted": converted, + "earned_days": earned, + "telegram": map[string]any{ + "enabled": h.tgEnabled, + "joined": joined, + "channel": h.channel, + }, + }) +} + +func (h *Handler) TelegramStart(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + uid, ok := auth.UserIDFromContext(ctx) + if !ok { + apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) + return + } + if !h.tgEnabled { + apierr.WriteJSON(w, http.StatusNotFound, apierr.ErrNotFound) + return + } + tok, err := h.svc.IssueTelegramToken(ctx, uid) + if err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{"deep_link": h.svc.TelegramDeepLink(tok)}) +} + +// TelegramWebhook 处理 Telegram Bot API 回调:校验 secret 头 → 解析 `/start ` +// → ConsumeTelegramToken → ClaimTelegram → sendMessage 回执。Telegram 只要求 200 即不重投, +// 业务结果通过 sendMessage 异步告知用户。 +func (h *Handler) TelegramWebhook(w http.ResponseWriter, r *http.Request) { + if !h.tgEnabled || r.Header.Get("X-Telegram-Bot-Api-Secret-Token") != h.svc.WebhookSecret() { + w.WriteHeader(http.StatusNotFound) + return + } + var upd struct { + Message struct { + Text string `json:"text"` + From struct { + ID int64 `json:"id"` + LanguageCode string `json:"language_code"` + } `json:"from"` + } `json:"message"` + } + _ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&upd) + w.WriteHeader(http.StatusOK) // Telegram 只要 200;业务异步在下方 + text, fromID := upd.Message.Text, upd.Message.From.ID + if !strings.HasPrefix(text, "/start ") || fromID == 0 { + return + } + msgs := tgMsgSetFor(upd.Message.From.LanguageCode) + token := strings.TrimSpace(strings.TrimPrefix(text, "/start ")) + ctx := r.Context() + uid, ok, _ := h.svc.ConsumeTelegramToken(ctx, token) + if !ok { + h.svc.SendTelegram(ctx, fromID, msgs.expired) + return + } + result, err := h.svc.ClaimTelegram(ctx, uid, strconv.FormatInt(fromID, 10)) + switch { + case err != nil: + h.svc.SendTelegram(ctx, fromID, msgs.errored) + case result == ClaimGranted: + h.svc.SendTelegram(ctx, fromID, msgs.granted) + case result == ClaimAlready: + h.svc.SendTelegram(ctx, fromID, msgs.already) + default: // ClaimNotMember + h.svc.SendTelegram(ctx, fromID, fmt.Sprintf(msgs.notMember, h.svc.Channel())) + } +} + diff --git a/server/internal/reward/handler_invite_test.go b/server/internal/reward/handler_invite_test.go new file mode 100644 index 0000000..f9cd688 --- /dev/null +++ b/server/internal/reward/handler_invite_test.go @@ -0,0 +1,39 @@ +package reward + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/codes" +) + +func TestGetInvite_ReturnsCodeAndSummary(t *testing.T) { + db := openDB(t) + seedU(t, db, 1, "u1") + st := NewStore(db) + g := codes.NewService(codes.NewStore(db), nil, 5, time.Hour) + svc := NewService(db, st, g, Config{RegDays: 3, PaidDays: 7, TgDays: 3, RegMonthlyCap: 10}, time.Now) + h := NewHandler(svc, st, true, "@pangolin_app") + + r := httptest.NewRequest(http.MethodGet, "/v1/invite", nil) + r = r.WithContext(context.WithValue(r.Context(), codes.CtxKeyUserID, int64(1))) + w := httptest.NewRecorder() + h.GetInvite(w, r) // 直接调 handler,不经 router + + if w.Code != 200 { + t.Fatalf("code=%d body=%s", w.Code, w.Body) + } + var got map[string]any + json.Unmarshal(w.Body.Bytes(), &got) + if got["invite_code"] == "" || got["invite_code"] == nil { + t.Fatalf("no invite_code: %v", got) + } + tg, _ := got["telegram"].(map[string]any) + if tg == nil || tg["enabled"] != true { + t.Fatalf("telegram block wrong: %v", got["telegram"]) + } +} diff --git a/server/internal/reward/notices_hook_sqlite_test.go b/server/internal/reward/notices_hook_sqlite_test.go new file mode 100644 index 0000000..fa2f645 --- /dev/null +++ b/server/internal/reward/notices_hook_sqlite_test.go @@ -0,0 +1,78 @@ +package reward + +import ( + "context" + "database/sql" + "fmt" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/notices" +) + +// failingGranter 总是拒绝发放,用于验证「发放失败 → 通知(与本事务其余写入)一并回滚」。 +type failingGranter struct{} + +func (failingGranter) GrantRewardTx(ctx context.Context, tx *sql.Tx, userID int64, days int, source, auditAction, ref string) (int64, time.Time, error) { + return 0, time.Time{}, fmt.Errorf("boom") +} + +func TestOnRegister_InsertsRewardNotices(t *testing.T) { + db := openDB(t) + seedU(t, db, 1, "inviter") + seedU(t, db, 2, "invitee") + s := newSvc(t, db) + s.SetNoticer(notices.NewStore(db)) + code, _ := s.EnsureCode(context.Background(), 1) + s.OnRegister(context.Background(), 2, code, "dev-2") + var n int + db.QueryRow(`SELECT COUNT(*) FROM notices WHERE type='reward' AND user_id IN (1,2)`).Scan(&n) + if n != 2 { + t.Fatalf("注册段应双方各一条到账通知, got %d", n) + } +} + +func TestClaimTelegram_NoticeRollsBackWithGrantFailure(t *testing.T) { + db := openDB(t) + seedU(t, db, 1, "u1") + s := NewService(db, NewStore(db), failingGranter{}, Config{TgDays: 3}, time.Now) + s.SetTelegram("bot", "@ch", "tok", "sec") + s.SetMemberChecker(fakeChecker{member: true}) + s.SetNoticer(notices.NewStore(db)) + + if _, err := s.ClaimTelegram(context.Background(), 1, "tg-1"); err == nil { + t.Fatalf("expected grant failure error, got nil") + } + var n int + db.QueryRow(`SELECT COUNT(*) FROM notices`).Scan(&n) + if n != 0 { + t.Fatalf("grant 失败应整事务回滚,notices should be 0, got %d", n) + } +} + +func TestOnFirstPaidTx_InsertsRewardNotices(t *testing.T) { + db := openDB(t) + seedU(t, db, 1, "inviter") + seedU(t, db, 2, "invitee") + s := newSvc(t, db) + s.SetNoticer(notices.NewStore(db)) + code, _ := s.EnsureCode(context.Background(), 1) + s.OnRegister(context.Background(), 2, code, "dev-2") + + tx, err := db.Begin() + if err != nil { + t.Fatal(err) + } + if err := s.OnFirstPaidTx(context.Background(), tx, 2, time.Now().UTC()); err != nil { + t.Fatal(err) + } + if err := tx.Commit(); err != nil { + t.Fatal(err) + } + var n int + db.QueryRow(`SELECT COUNT(*) FROM notices WHERE type='reward' AND user_id IN (1,2)`).Scan(&n) + // 注册段 2 条 + 首充段 2 条 = 4 条 + if n != 4 { + t.Fatalf("注册+首充共应 4 条到账通知, got %d", n) + } +} diff --git a/server/internal/reward/service.go b/server/internal/reward/service.go new file mode 100644 index 0000000..dfded6b --- /dev/null +++ b/server/internal/reward/service.go @@ -0,0 +1,194 @@ +package reward + +import ( + "context" + "crypto/rand" + "database/sql" + "fmt" + "log/slog" + "sync" + "time" + + "github.com/redis/go-redis/v9" +) + +type Granter interface { + GrantRewardTx(ctx context.Context, tx *sql.Tx, userID int64, days int, source, auditAction, ref string) (int64, time.Time, error) +} + +// Noticer 抽象 notices.Store 的事务内插入入口(测试可替身;生产传 notices.NewStore(db))。 +// reward → notices 单向依赖,不引入 import cycle,故不需要适配器,直接实现即可。 +type Noticer interface { + InsertNoticeTx(ctx context.Context, tx *sql.Tx, userID int64, typ, titleZH, titleEN, bodyZH, bodyEN, link string, now time.Time) error +} + +type Config struct{ RegDays, PaidDays, TgDays, RegMonthlyCap int } + +type Service struct { + db *sql.DB + st *Store + g Granter + cfg Config + now func() time.Time + noticer Noticer + + rdb *redis.Client + tg tgConfig + checker ChatMemberChecker + memMu sync.Mutex + memTok map[string]int64 +} + +// SetNoticer 注入到账通知钩子(notices.Store 满足此接口);为 nil 时四接缝跳过(装配前兼容)。 +func (s *Service) SetNoticer(n Noticer) { s.noticer = n } + +// rewardNoticeTitles 按事件渲染双语标题(reward 类通知无正文,一行标题即可)。 +func rewardNoticeTitles(kind string, days int) (zh, en string) { + switch kind { + case "invite_reg": + return fmt.Sprintf("邀请奖励 +%d 天已到账", days), fmt.Sprintf("Invite reward +%d days credited", days) + case "invite_paid": + return fmt.Sprintf("好友首购奖励 +%d 天已到账", days), fmt.Sprintf("Friend's first purchase: +%d days credited", days) + case "task_tg": + return fmt.Sprintf("任务奖励 +%d 天已到账", days), fmt.Sprintf("Task reward +%d days credited", days) + } + return "", "" +} + +func NewService(db *sql.DB, st *Store, g Granter, cfg Config, now func() time.Time) *Service { + if now == nil { + now = func() time.Time { return time.Now().UTC() } + } + return &Service{db: db, st: st, g: g, cfg: cfg, now: now} +} + +const inviteAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" // 去 0/O/1/I,32 字符 + +// GenInviteCode 返回 8 位随机码。crypto/rand 失败时用 now 派生兜底(避免 panic)。 +func GenInviteCode() string { + b := make([]byte, 8) + if _, err := rand.Read(b); err != nil { + t := time.Now().UnixNano() + for i := range b { + b[i] = byte(t >> (8 * i)) + } + } + out := make([]byte, 8) + for i, x := range b { + out[i] = inviteAlphabet[int(x)%len(inviteAlphabet)] + } + return string(out) +} + +func (s *Service) EnsureCode(ctx context.Context, userID int64) (string, error) { + return s.st.EnsureInviteCode(ctx, userID, GenInviteCode) +} + +// OnRegister best-effort:与建号解耦(自有事务,失败只 log,不回滚注册)。 +func (s *Service) OnRegister(ctx context.Context, inviteeID int64, inviteCode, deviceUUID string) { + if inviteCode == "" { + return + } + inviterID, ok, err := s.st.ResolveInviteCode(ctx, inviteCode) + if err != nil || !ok { + if err != nil { + slog.Warn("reward: resolve invite code failed", "err", err) + } + return // 无效码:静默,不报错(注册已成功) + } + if inviterID == inviteeID { + return // 自邀请:不建关系 + } + now := s.now() + + // 防刷判定:设备复用 or 月度封顶 → 记关系但 status=rejected、不发注册奖。 + rejected := false + if used, e := s.st.DeviceUsedByOther(ctx, deviceUUID, inviteeID); e == nil && used { + rejected = true + } + if !rejected { + monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) + if cnt, e := s.st.RegRewardCountThisMonth(ctx, inviterID, monthStart); e == nil && cnt >= s.cfg.RegMonthlyCap { + rejected = true + } + } + + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) + if err != nil { + slog.Warn("reward: begin tx failed", "err", err) + return + } + defer func() { _ = tx.Rollback() }() + + status := "reg_rewarded" + var regAt *time.Time + if rejected { + status = "rejected" + } else { + regAt = &now + } + if err := s.st.InsertReferralTx(ctx, tx, inviterID, inviteeID, deviceUUID, status, regAt, now); err != nil { + if err != ErrClaimExists { + slog.Warn("reward: insert referral failed", "err", err) + } + return // invitee 已绑定过(唯一约束)→ 放弃 + } + if !rejected { + if _, _, err := s.g.GrantRewardTx(ctx, tx, inviterID, s.cfg.RegDays, "invite", "invite_reg_inviter", fmt.Sprintf("invite:reg:invitee=%d", inviteeID)); err != nil { + slog.Warn("reward: grant inviter reg failed", "err", err) + return + } + if _, _, err := s.g.GrantRewardTx(ctx, tx, inviteeID, s.cfg.RegDays, "invite", "invite_reg_invitee", fmt.Sprintf("invite:reg:inviter=%d", inviterID)); err != nil { + slog.Warn("reward: grant invitee reg failed", "err", err) + return + } + if s.noticer != nil { + zh, en := rewardNoticeTitles("invite_reg", s.cfg.RegDays) + if err := s.noticer.InsertNoticeTx(ctx, tx, inviterID, "reward", zh, en, "", "", "", now); err != nil { + slog.Warn("reward: insert inviter notice failed", "err", err) + return + } + if err := s.noticer.InsertNoticeTx(ctx, tx, inviteeID, "reward", zh, en, "", "", "", now); err != nil { + slog.Warn("reward: insert invitee notice failed", "err", err) + return + } + } + } + if err := tx.Commit(); err != nil { + slog.Warn("reward: commit failed", "err", err) + } +} + +// OnFirstPaidTx 在 pay webhook 的同一事务内调用:标记 first_paid_at,若确为首充且该用户 +// 是被邀请人(referral 命中且未发过首充奖)→ 发首充段双方各 PaidDays 天。幂等。 +func (s *Service) OnFirstPaidTx(ctx context.Context, tx *sql.Tx, inviteeID int64, now time.Time) error { + first, err := s.st.MarkFirstPaidTx(ctx, tx, inviteeID, now) + if err != nil || !first { + return err // 非首充 → 不发 + } + inviterID, status, ok, err := s.st.ReferralByInvitee(ctx, tx, inviteeID) + if err != nil || !ok || status == "paid_rewarded" || status == "rejected" { + return err // 无邀请关系 / 已发过 / 被拒 → 不发 + } + if _, _, err := s.g.GrantRewardTx(ctx, tx, inviterID, s.cfg.PaidDays, "invite", "invite_paid_inviter", fmt.Sprintf("invite:paid:invitee=%d", inviteeID)); err != nil { + return err + } + if _, _, err := s.g.GrantRewardTx(ctx, tx, inviteeID, s.cfg.PaidDays, "invite", "invite_paid_invitee", fmt.Sprintf("invite:paid:inviter=%d", inviterID)); err != nil { + return err + } + if s.noticer != nil { + zh, en := rewardNoticeTitles("invite_paid", s.cfg.PaidDays) + if err := s.noticer.InsertNoticeTx(ctx, tx, inviterID, "reward", zh, en, "", "", "", now); err != nil { + return err + } + if err := s.noticer.InsertNoticeTx(ctx, tx, inviteeID, "reward", zh, en, "", "", "", now); err != nil { + return err + } + } + return s.st.MarkPaidRewardedTx(ctx, tx, inviteeID, now) +} + +// TelegramDeepLink 拼 t.me 深链,携带绑定 token。 +func (s *Service) TelegramDeepLink(tok string) string { + return "https://t.me/" + s.tg.botUser + "?start=" + tok +} diff --git a/server/internal/reward/service_sqlite_test.go b/server/internal/reward/service_sqlite_test.go new file mode 100644 index 0000000..18e13cb --- /dev/null +++ b/server/internal/reward/service_sqlite_test.go @@ -0,0 +1,80 @@ +package reward + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/codes" +) + +func newSvc(t *testing.T, db *sql.DB) *Service { + g := codes.NewService(codes.NewStore(db), nil, 5, time.Hour) + return NewService(db, NewStore(db), g, Config{RegDays: 3, PaidDays: 7, TgDays: 3, RegMonthlyCap: 10}, time.Now) +} + +func proDays(t *testing.T, db *sql.DB, userID int64) int { // 该用户 pro 剩余天数(粗算) + var exp sql.NullTime + db.QueryRow(`SELECT expires_at FROM subscriptions s JOIN plans p ON p.id=s.plan_id + WHERE s.user_id=? AND p.code='pro' ORDER BY expires_at DESC LIMIT 1`, userID).Scan(&exp) + if !exp.Valid { + return 0 + } + return int(time.Until(exp.Time).Hours()/24) + 1 +} + +func TestOnRegister_BothGetThreeDays(t *testing.T) { + db := openDB(t) + seedU(t, db, 1, "inviter") + seedU(t, db, 2, "invitee") + s := newSvc(t, db) + code, _ := s.EnsureCode(context.Background(), 1) + s.OnRegister(context.Background(), 2, code, "dev-2") + + var status string + db.QueryRow(`SELECT status FROM referrals WHERE invitee_id=2`).Scan(&status) + if status != "reg_rewarded" { + t.Fatalf("status=%q", status) + } + if d := proDays(t, db, 1); d < 3 { + t.Fatalf("inviter pro days=%d, want>=3", d) + } + if d := proDays(t, db, 2); d < 3 { + t.Fatalf("invitee pro days=%d, want>=3", d) + } +} + +func TestOnRegister_SelfInviteRejected(t *testing.T) { + db := openDB(t) + seedU(t, db, 1, "u1") + s := newSvc(t, db) + code, _ := s.EnsureCode(context.Background(), 1) + s.OnRegister(context.Background(), 1, code, "dev-1") // 自邀请 + var n int + db.QueryRow(`SELECT COUNT(*) FROM referrals`).Scan(&n) + if n != 0 { + t.Fatalf("self-invite created %d referrals, want 0", n) + } +} + +func TestOnRegister_DeviceReusedRejectsReward(t *testing.T) { + db := openDB(t) + seedU(t, db, 1, "inv") + seedU(t, db, 2, "old") + seedU(t, db, 3, "new") + // old(2) 的设备 dev-X 已在 devices + db.Exec(`INSERT INTO devices (uuid,user_id,name,platform,last_seen,created_at) + VALUES ('dev-X',2,'n','android',?,?)`, time.Now().UTC(), time.Now().UTC()) + s := newSvc(t, db) + code, _ := s.EnsureCode(context.Background(), 1) + s.OnRegister(context.Background(), 3, code, "dev-X") // 新用户复用老设备 + var status string + db.QueryRow(`SELECT status FROM referrals WHERE invitee_id=3`).Scan(&status) + if status != "rejected" { + t.Fatalf("status=%q, want rejected", status) + } + if d := proDays(t, db, 1); d >= 3 { + t.Fatalf("inviter got reward despite device reuse: %d", d) + } +} diff --git a/server/internal/reward/store.go b/server/internal/reward/store.go new file mode 100644 index 0000000..fd81f4e --- /dev/null +++ b/server/internal/reward/store.go @@ -0,0 +1,156 @@ +// Package reward 承载邀请奖励与奖励任务的数据访问 + 发奖 + 防刷。 +package reward + +import ( + "context" + "database/sql" + "errors" + "strings" + "time" +) + +var ErrClaimExists = errors.New("reward: claim already exists") + +type Store struct{ db *sql.DB } + +func NewStore(db *sql.DB) *Store { return &Store{db: db} } + +// EnsureInviteCode 惰性生成邀请码;已有则返回旧值。gen 生成候选码(冲突时重试到成功)。 +func (s *Store) EnsureInviteCode(ctx context.Context, userID int64, gen func() string) (string, error) { + var existing sql.NullString + if err := s.db.QueryRowContext(ctx, `SELECT invite_code FROM users WHERE id=?`, userID).Scan(&existing); err != nil { + return "", err + } + if existing.Valid && existing.String != "" { + return existing.String, nil + } + for i := 0; i < 5; i++ { + code := gen() + _, err := s.db.ExecContext(ctx, `UPDATE users SET invite_code=? WHERE id=? AND invite_code IS NULL`, code, userID) + if err != nil { + if isDup(err) { + continue + } + return "", err + } + // 读回(并发下可能是别的并发写入的值) + var got sql.NullString + if err := s.db.QueryRowContext(ctx, `SELECT invite_code FROM users WHERE id=?`, userID).Scan(&got); err != nil { + return "", err + } + if got.Valid && got.String != "" { + return got.String, nil + } + } + return "", errors.New("reward: invite code generation exhausted") +} + +func (s *Store) ResolveInviteCode(ctx context.Context, code string) (int64, bool, error) { + var id int64 + err := s.db.QueryRowContext(ctx, `SELECT id FROM users WHERE invite_code=? AND status='active'`, code).Scan(&id) + if err == sql.ErrNoRows { + return 0, false, nil + } + return id, err == nil, err +} + +func (s *Store) DeviceUsedByOther(ctx context.Context, deviceUUID string, exceptUserID int64) (bool, error) { + if deviceUUID == "" { + return false, nil + } + var n int + err := s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM devices WHERE uuid=? AND user_id<>?`, deviceUUID, exceptUserID).Scan(&n) + return n > 0, err +} + +func (s *Store) RegRewardCountThisMonth(ctx context.Context, inviterID int64, since time.Time) (int, error) { + var n int + err := s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM referrals WHERE inviter_id=? AND status='reg_rewarded' AND reg_rewarded_at>=?`, + inviterID, since).Scan(&n) + return n, err +} + +func (s *Store) InsertReferralTx(ctx context.Context, tx *sql.Tx, inviterID, inviteeID int64, + deviceUUID, status string, regRewardedAt *time.Time, now time.Time) error { + _, err := tx.ExecContext(ctx, + `INSERT INTO referrals (inviter_id,invitee_id,device_uuid,status,reg_rewarded_at,created_at) + VALUES (?,?,?,?,?,?)`, inviterID, inviteeID, deviceUUID, status, regRewardedAt, now) + if isDup(err) { + return ErrClaimExists + } + return err +} + +func (s *Store) ReferralByInvitee(ctx context.Context, tx *sql.Tx, inviteeID int64) (int64, string, bool, error) { + var inviter int64 + var status string + err := tx.QueryRowContext(ctx, `SELECT inviter_id,status FROM referrals WHERE invitee_id=?`, inviteeID). + Scan(&inviter, &status) + if err == sql.ErrNoRows { + return 0, "", false, nil + } + return inviter, status, err == nil, err +} + +func (s *Store) MarkFirstPaidTx(ctx context.Context, tx *sql.Tx, userID int64, at time.Time) (bool, error) { + res, err := tx.ExecContext(ctx, + `UPDATE users SET first_paid_at=? WHERE id=? AND first_paid_at IS NULL`, at, userID) + if err != nil { + return false, err + } + n, _ := res.RowsAffected() + return n == 1, nil +} + +func (s *Store) MarkPaidRewardedTx(ctx context.Context, tx *sql.Tx, inviteeID int64, at time.Time) error { + _, err := tx.ExecContext(ctx, + `UPDATE referrals SET status='paid_rewarded', paid_rewarded_at=? WHERE invitee_id=?`, at, inviteeID) + return err +} + +func (s *Store) InsertClaimTx(ctx context.Context, tx *sql.Tx, userID int64, taskKey, externalRef string, + days int, now time.Time) error { + _, err := tx.ExecContext(ctx, + `INSERT INTO reward_claims (user_id,task_key,external_ref,granted_days,granted_at) + VALUES (?,?,?,?,?)`, userID, taskKey, externalRef, days, now) + if isDup(err) { + return ErrClaimExists + } + return err +} + +func (s *Store) TelegramClaimed(ctx context.Context, userID int64) (bool, error) { + var n int + err := s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM reward_claims WHERE user_id=? AND task_key='telegram_join'`, userID).Scan(&n) + return n > 0, err +} + +// Summary: invited=已绑定人数;converted=已首充奖励人数;earnedDays=本人从奖励得到的总天数(audit_log 累加)。 +func (s *Store) Summary(ctx context.Context, userID int64) (invited, converted, earnedDays int, err error) { + if err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM referrals WHERE inviter_id=?`, userID).Scan(&invited); err != nil { + return + } + if err = s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM referrals WHERE inviter_id=? AND status='paid_rewarded'`, userID).Scan(&converted); err != nil { + return + } + // earnedDays: audit_log 里 actor=user: 且 action∈奖励动作,meta.days 累加(简化:reward_claims + referrals 估算) + var tg, reg, paid int + s.db.QueryRowContext(ctx, `SELECT COALESCE(SUM(granted_days),0) FROM reward_claims WHERE user_id=?`, userID).Scan(&tg) + s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM referrals WHERE inviter_id=? AND status IN ('reg_rewarded','paid_rewarded')`, userID).Scan(®) + s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM referrals WHERE inviter_id=? AND status='paid_rewarded'`, userID).Scan(&paid) + // 邀请人:每 reg_rewarded +3、每 paid_rewarded 再 +7;被邀请人自身得的天数不计入其「邀请战绩」。 + earnedDays = tg + reg*3 + paid*7 + return +} + +func isDup(err error) bool { + if err == nil { + return false + } + m := strings.ToLower(err.Error()) + return strings.Contains(m, "unique") || strings.Contains(m, "duplicate") +} diff --git a/server/internal/reward/store_sqlite_test.go b/server/internal/reward/store_sqlite_test.go new file mode 100644 index 0000000..b927aed --- /dev/null +++ b/server/internal/reward/store_sqlite_test.go @@ -0,0 +1,98 @@ +package reward + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/config" + "github.com/wangjia/pangolin/server/internal/store" +) + +func openDB(t *testing.T) *sql.DB { + db, err := store.Open(&config.Config{Driver: "sqlite", DSN: ":memory:"}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := store.MigrateUp(db, "sqlite"); err != nil { + t.Fatal(err) + } + if err := store.ApplyCodesLibMigrations(context.Background(), db, "sqlite"); err != nil { + t.Fatal(err) + } + return db +} + +func seedU(t *testing.T, db *sql.DB, id int64, uuid string) { + _, err := db.Exec(`INSERT INTO users (id,uuid,email,pw_hash,dp_uuid,status,created_at) + VALUES (?,?,?, 'x','dp-'||?, 'active', ?)`, id, uuid, uuid+"@x", uuid, time.Now().UTC()) + if err != nil { + t.Fatal(err) + } +} + +func TestEnsureAndResolveInviteCode(t *testing.T) { + db := openDB(t) + seedU(t, db, 1, "u1") + st := NewStore(db) + code, err := st.EnsureInviteCode(context.Background(), 1, func() string { return "ABCD2345" }) + if err != nil || code != "ABCD2345" { + t.Fatalf("ensure: %q %v", code, err) + } + again, _ := st.EnsureInviteCode(context.Background(), 1, func() string { return "ZZZZ9999" }) + if again != "ABCD2345" { + t.Fatalf("second ensure changed code: %q", again) + } + inviter, ok, _ := st.ResolveInviteCode(context.Background(), "ABCD2345") + if !ok || inviter != 1 { + t.Fatalf("resolve: %d %v", inviter, ok) + } +} + +func TestInsertClaimUniqueGuards(t *testing.T) { + db := openDB(t) + seedU(t, db, 1, "u1") + seedU(t, db, 2, "u2") + st := NewStore(db) + tx, _ := db.Begin() + if err := st.InsertClaimTx(context.Background(), tx, 1, "telegram_join", "tg-100", 3, time.Now().UTC()); err != nil { + t.Fatal(err) + } + tx.Commit() + // 同 user 再领 → ErrClaimExists + tx2, _ := db.Begin() + if err := st.InsertClaimTx(context.Background(), tx2, 1, "telegram_join", "tg-999", 3, time.Now().UTC()); err != ErrClaimExists { + t.Fatalf("same user reclaim err = %v, want ErrClaimExists", err) + } + tx2.Rollback() + // 同 telegram_id 换 user → ErrClaimExists + tx3, _ := db.Begin() + if err := st.InsertClaimTx(context.Background(), tx3, 2, "telegram_join", "tg-100", 3, time.Now().UTC()); err != ErrClaimExists { + t.Fatalf("same tgid reclaim err = %v, want ErrClaimExists", err) + } + tx3.Rollback() +} + +func TestInsertReferralUniqueInviteeGuard(t *testing.T) { + db := openDB(t) + seedU(t, db, 1, "u1") + seedU(t, db, 2, "u2") + seedU(t, db, 3, "u3") + st := NewStore(db) + now := time.Now().UTC() + + tx, _ := db.Begin() + if err := st.InsertReferralTx(context.Background(), tx, 1, 2, "dev", "reg_rewarded", &now, now); err != nil { + t.Fatal(err) + } + tx.Commit() + + // 同一 invitee(2)被另一 inviter(3)再次绑定 → invitee_id UNIQUE 命中,返回 ErrClaimExists + tx2, _ := db.Begin() + if err := st.InsertReferralTx(context.Background(), tx2, 3, 2, "dev", "reg_rewarded", &now, now); err != ErrClaimExists { + t.Fatalf("dup invitee insert err = %v, want ErrClaimExists", err) + } + tx2.Rollback() +} diff --git a/server/internal/reward/telegram.go b/server/internal/reward/telegram.go new file mode 100644 index 0000000..c61045a --- /dev/null +++ b/server/internal/reward/telegram.go @@ -0,0 +1,197 @@ +package reward + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/json" + "log/slog" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/redis/go-redis/v9" +) + +type tgConfig struct { + botUser, channel, botToken, webhookSecret string +} + +// ChatMemberChecker 判定某 telegramID 是否是频道/群成员。默认实现打 Telegram Bot API, +// 测试注入 fake 以避免真实网络调用。 +type ChatMemberChecker interface { + IsMember(ctx context.Context, channel, telegramID string) (bool, error) +} + +// SetMemberChecker 注入自定义成员校验器(测试用);生产走 SetTelegram 设置的默认 apiChecker。 +func (s *Service) SetMemberChecker(c ChatMemberChecker) { s.checker = c } + +// SetTelegram 注入 TG 配置(经 Service.tg)。若尚未注入 checker(测试未 SetMemberChecker), +// 默认设为打 Telegram Bot API 的 apiChecker。 +func (s *Service) SetTelegram(botUser, channel, botToken, webhookSecret string) { + s.tg = tgConfig{botUser: botUser, channel: channel, botToken: botToken, webhookSecret: webhookSecret} + if s.checker == nil { + s.checker = apiChecker{botToken: botToken, hc: &http.Client{Timeout: 8 * time.Second}} + } +} + +// WebhookSecret 返回 TG webhook 校验用的 secret token。 +func (s *Service) WebhookSecret() string { return s.tg.webhookSecret } + +// Channel 返回配置的 TG 频道(@ 形式)。 +func (s *Service) Channel() string { return s.tg.channel } + +// SetRedis 注入 redis 客户端(经 Service.rdb);为 nil 时 token 走内存兜底(测试/未配 redis)。 +func (s *Service) SetRedis(rdb *redis.Client) { s.rdb = rdb } + +func randToken() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// IssueTelegramToken 生成随机 token,Redis SETEX tg_bind: 600 ; +// Redis 为 nil 时用内存 map 兜底(测试/未配 redis)。 +func (s *Service) IssueTelegramToken(ctx context.Context, userID int64) (string, error) { + tok := randToken() + if s.rdb != nil { + return tok, s.rdb.Set(ctx, "tg_bind:"+tok, userID, 10*time.Minute).Err() + } + s.memMu.Lock() + if s.memTok == nil { + s.memTok = map[string]int64{} + } + s.memTok[tok] = userID + s.memMu.Unlock() + return tok, nil +} + +// ConsumeTelegramToken 一次性消费(GETDEL 语义)。 +func (s *Service) ConsumeTelegramToken(ctx context.Context, token string) (int64, bool, error) { + if s.rdb != nil { + v, err := s.rdb.GetDel(ctx, "tg_bind:"+token).Result() + if err == redis.Nil { + return 0, false, nil + } + if err != nil { + return 0, false, err + } + id, _ := strconv.ParseInt(v, 10, 64) + return id, true, nil + } + s.memMu.Lock() + defer s.memMu.Unlock() + id, ok := s.memTok[token] + if ok { + delete(s.memTok, token) + } + return id, ok, nil +} + +// ClaimResult 是 ClaimTelegram 的结果三态,供上层(handler)按态回不同文案。 +type ClaimResult int + +const ( + ClaimGranted ClaimResult = iota // 发放成功 + ClaimAlready // 已领取过(本账户或该 telegram_id) + ClaimNotMember // 不在频道 +) + +// ClaimTelegram: 真是频道成员则一次性发 TgDays 天(source='task'),唯一守卫防重复领取 +// (同 userID 或同 telegramID 只能成功一次)。 +func (s *Service) ClaimTelegram(ctx context.Context, userID int64, telegramID string) (ClaimResult, error) { + member, err := s.checker.IsMember(ctx, s.tg.channel, telegramID) + if err != nil { + return ClaimNotMember, err + } + if !member { + return ClaimNotMember, nil + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) + if err != nil { + return ClaimNotMember, err + } + defer func() { _ = tx.Rollback() }() + now := s.now() + if err := s.st.InsertClaimTx(ctx, tx, userID, "telegram_join", telegramID, s.cfg.TgDays, now); err != nil { + if err == ErrClaimExists { + return ClaimAlready, nil // 已领过(本账户 or 该 tgid) + } + return ClaimNotMember, err + } + if _, _, err := s.g.GrantRewardTx(ctx, tx, userID, s.cfg.TgDays, "task", "task_telegram_join", "tg:"+telegramID); err != nil { + return ClaimNotMember, err + } + if s.noticer != nil { + zh, en := rewardNoticeTitles("task_tg", s.cfg.TgDays) + if err := s.noticer.InsertNoticeTx(ctx, tx, userID, "reward", zh, en, "", "", "", now); err != nil { + return ClaimNotMember, err + } + } + if err := tx.Commit(); err != nil { + return ClaimNotMember, err + } + return ClaimGranted, nil +} + +// apiChecker 是 ChatMemberChecker 的默认生产实现:打 Telegram getChatMember。 +type apiChecker struct { + botToken string + hc *http.Client +} + +func (c apiChecker) IsMember(ctx context.Context, channel, telegramID string) (bool, error) { + u := "https://api.telegram.org/bot" + c.botToken + "/getChatMember" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return false, err + } + q := req.URL.Query() + q.Set("chat_id", channel) + q.Set("user_id", telegramID) + req.URL.RawQuery = q.Encode() + resp, err := c.hc.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + var out struct { + OK bool `json:"ok"` + Result struct { + Status string `json:"status"` + } `json:"result"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return false, err + } + st := out.Result.Status + return out.OK && (st == "member" || st == "administrator" || st == "creator"), nil +} + +// SendTelegram 打 Bot API sendMessage 给 chatID 发文案;失败只 log,不影响主流程。 +func (s *Service) SendTelegram(ctx context.Context, chatID int64, text string) { + u := "https://api.telegram.org/bot" + s.tg.botToken + "/sendMessage" + body := url.Values{} + body.Set("chat_id", strconv.FormatInt(chatID, 10)) + body.Set("text", text) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, strings.NewReader(body.Encode())) + if err != nil { + slog.Warn("reward: send telegram build request failed", "err", err) + return + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + hc := &http.Client{Timeout: 8 * time.Second} + resp, err := hc.Do(req) + if err != nil { + slog.Warn("reward: send telegram failed", "err", err) + return + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + slog.Warn("reward: send telegram non-2xx", "status", resp.StatusCode) + } +} + diff --git a/server/internal/reward/telegram_token_test.go b/server/internal/reward/telegram_token_test.go new file mode 100644 index 0000000..89838ee --- /dev/null +++ b/server/internal/reward/telegram_token_test.go @@ -0,0 +1,30 @@ +package reward + +import ( + "context" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/codes" +) + +func TestTelegramToken_IssueThenConsumeOnce(t *testing.T) { + db := openDB(t) + seedU(t, db, 5, "u5") + g := codes.NewService(codes.NewStore(db), nil, 5, time.Hour) + s := NewService(db, NewStore(db), g, Config{TgDays: 3}, time.Now) + s.SetTelegram("pangolin_reward_bot", "@pangolin_app", "bot-token", "wh-secret") // 内存兜底(rdb nil) + + tok, err := s.IssueTelegramToken(context.Background(), 5) + if err != nil || tok == "" { + t.Fatalf("issue: %q %v", tok, err) + } + uid, ok, _ := s.ConsumeTelegramToken(context.Background(), tok) + if !ok || uid != 5 { + t.Fatalf("consume: %d %v", uid, ok) + } + _, ok2, _ := s.ConsumeTelegramToken(context.Background(), tok) + if ok2 { + t.Fatalf("token consumable twice") + } +} diff --git a/server/internal/reward/telegram_webhook_test.go b/server/internal/reward/telegram_webhook_test.go new file mode 100644 index 0000000..55c6a3d --- /dev/null +++ b/server/internal/reward/telegram_webhook_test.go @@ -0,0 +1,74 @@ +package reward + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/codes" +) + +type fakeChecker struct{ member bool } + +func (f fakeChecker) IsMember(_ context.Context, _, _ string) (bool, error) { return f.member, nil } + +func newTgSvc(t *testing.T, db *sql.DB, member bool) *Service { + g := codes.NewService(codes.NewStore(db), nil, 5, time.Hour) + s := NewService(db, NewStore(db), g, Config{TgDays: 3}, time.Now) + s.SetTelegram("bot", "@ch", "tok", "sec") + s.SetMemberChecker(fakeChecker{member: member}) + return s +} + +func TestClaimTelegram_MemberGrantsOnce(t *testing.T) { + db := openDB(t) + seedU(t, db, 1, "u1") + s := newTgSvc(t, db, true) + res, err := s.ClaimTelegram(context.Background(), 1, "tg-777") + if err != nil || res != ClaimGranted { + t.Fatalf("claim1: %v %v", res, err) + } + if d := proDays(t, db, 1); d < 3 { + t.Fatalf("pro days=%d", d) + } + // 再领 → 已领取过(唯一守卫),不再发 + res2, err2 := s.ClaimTelegram(context.Background(), 1, "tg-777") + if err2 != nil || res2 != ClaimAlready { + t.Fatalf("claim2: %v %v", res2, err2) + } +} + +func TestClaimTelegram_NonMemberNoGrant(t *testing.T) { + db := openDB(t) + seedU(t, db, 1, "u1") + s := newTgSvc(t, db, false) + res, err := s.ClaimTelegram(context.Background(), 1, "tg-1") + if err != nil || res != ClaimNotMember { + t.Fatalf("claim: %v %v", res, err) + } + if d := proDays(t, db, 1); d >= 3 { + t.Fatalf("granted days to non-member: %d", d) + } +} + +func TestTgMsgSetFor_LanguageSelection(t *testing.T) { + cases := []struct { + lang string + zh bool + }{ + {"zh-hans", true}, + {"zh", true}, + {"zh-CN", true}, + {"en", false}, + {"de", false}, + {"", false}, + } + for _, c := range cases { + got := tgMsgSetFor(c.lang) + isZH := got.granted == tgMsgsZH.granted + if isZH != c.zh { + t.Fatalf("lang=%q: expected zh=%v, got zh=%v", c.lang, c.zh, isZH) + } + } +} diff --git a/server/internal/store/codes_lib_migrate_test.go b/server/internal/store/codes_lib_migrate_test.go index 5fed542..529c2ae 100644 --- a/server/internal/store/codes_lib_migrate_test.go +++ b/server/internal/store/codes_lib_migrate_test.go @@ -15,7 +15,7 @@ import ( ) // TestApplyCodesLibMigrations verifies the shared codes library's tables are -// created on top of a fully-migrated pangolin sqlite DB (post-000020, the +// created on top of a fully-migrated pangolin sqlite DB (post-000022, the // legacy tables are renamed away so the lib's `codes` table name is free). func TestApplyCodesLibMigrations(t *testing.T) { ctx := context.Background() @@ -49,7 +49,7 @@ func TestApplyCodesLibMigrations(t *testing.T) { // newSQLiteStepper builds a golang-migrate instance identical to the one // internal/store.MigrateUp/MigrateDown use internally (same embedded FS, // same sqlite driver), except it exposes Steps() so the test can land -// exactly on the 000020 boundary instead of only being able to invoke a +// exactly on the 000022 boundary instead of only being able to invoke a // full up-to-latest / down-to-zero round trip like the public API does. // Mirrors internal/store/migrate.go's newMigrator — kept in the test // because that constructor is unexported. @@ -71,18 +71,18 @@ func newSQLiteStepper(t *testing.T, database *sql.DB) *migrate.Migrate { } // TestCodesLibMigrateRoundTrip exercises the real wired path end-to-end: -// MigrateUp -> ApplyCodesLibMigrations -> step 000020 back down -> assert +// MigrateUp -> ApplyCodesLibMigrations -> step 000022 back down -> assert // legacy restored/lib gone -> step back up -> ApplyCodesLibMigrations again // -> finally the full `store.MigrateDown` (== cmd/migrate's "down" command) // all the way to version 0, which is the exact call the reviewer reported // as failing hard. // -// Reproduces the reviewer-found collision: 000020's down does +// Reproduces the reviewer-found collision: 000022's down does // legacy_codes -> codes renames, but the codes-lib's own `codes` table // (created by ApplyCodesLibMigrations, untracked by golang-migrate) is // still sitting there, so the rename used to fail with "table already // exists" (sqlite) / "ALTER TABLE ... table already exists" (mysql -// RENAME TABLE semantics). 000020's down script must DROP the lib-owned +// RENAME TABLE semantics). 000022's down script must DROP the lib-owned // tables before renaming legacy_* back. func TestCodesLibMigrateRoundTrip(t *testing.T) { ctx := context.Background() @@ -100,19 +100,34 @@ func TestCodesLibMigrateRoundTrip(t *testing.T) { t.Fatalf("ApplyCodesLibMigrations: %v", err) } - // 2. Step exactly 000020 back down (19 <- 20). This is the precise seam + // 2. Step exactly 000022 back down (21 <- 22). This is the precise seam // the reviewer's repro hit: the lib's `codes` table collides with the - // name 000020's down script renames legacy_codes back to. + // name 000022's down script renames legacy_codes back to. m := newSQLiteStepper(t, db) - // 000021 (pay_purchases/source-enum) now sits on top of 000020 and is - // unrelated to this collision — step it back down first so we land - // exactly on the 000020 boundary the test targets. + // 000027 (pay_promo_paid_unique), 000026 (notices), 000025 + // (user_device_limit_override), 000024 (invite_rewards) and 000023 + // (pay_purchases/source-enum) now sit on top of 000022 + // (codes_lib_legacy_rename) and are unrelated to this collision — step + // them back down first so we land exactly on the 000022 boundary the + // test targets. if err := m.Steps(-1); err != nil { - t.Fatalf("step 000021 down: %v", err) + t.Fatalf("step 000027 down: %v", err) } if err := m.Steps(-1); err != nil { - t.Fatalf("step 000020 down: %v (this is the reviewer-reported collision — "+ - "000020 down must DROP the codes-lib tables before renaming legacy_* back)", err) + t.Fatalf("step 000026 down: %v", err) + } + if err := m.Steps(-1); err != nil { + t.Fatalf("step 000025 down: %v", err) + } + if err := m.Steps(-1); err != nil { + t.Fatalf("step 000024 down: %v", err) + } + if err := m.Steps(-1); err != nil { + t.Fatalf("step 000023 down: %v", err) + } + if err := m.Steps(-1); err != nil { + t.Fatalf("step 000022 down: %v (this is the reviewer-reported collision — "+ + "000022 down must DROP the codes-lib tables before renaming legacy_* back)", err) } // 3. Legacy tables restored to their original pangolin names/shape; @@ -136,16 +151,16 @@ func TestCodesLibMigrateRoundTrip(t *testing.T) { `SELECT name FROM sqlite_master WHERE type='table' AND name=?`, tbl, ).Scan(&name) if err == nil { - t.Errorf("lib table %q should have been dropped by the 000020 down step, still present", tbl) + t.Errorf("lib table %q should have been dropped by the 000022 down step, still present", tbl) } else if err != sql.ErrNoRows { t.Errorf("checking lib table %q gone: %v", tbl, err) } } - // 4. Step 000020 back up + reapply the lib migrations: idempotent, must + // 4. Step 000022 back up + reapply the lib migrations: idempotent, must // stay green. if err := m.Steps(1); err != nil { - t.Fatalf("step 000020 up (2nd round-trip): %v", err) + t.Fatalf("step 000022 up (2nd round-trip): %v", err) } if err := store.ApplyCodesLibMigrations(ctx, db, "sqlite"); err != nil { t.Fatalf("ApplyCodesLibMigrations (2nd round-trip): %v", err) diff --git a/server/internal/store/pay_migration_rehearsal_test.go b/server/internal/store/pay_migration_rehearsal_test.go index 8321ddc..993750a 100644 --- a/server/internal/store/pay_migration_rehearsal_test.go +++ b/server/internal/store/pay_migration_rehearsal_test.go @@ -19,8 +19,8 @@ import ( // migratorAt builds a golang-migrate instance against a file-backed SQLite DB // and steps it to exactly `version` (unlike store.MigrateUp/Down, which always // target head/0). This is Task 8 Step 2's upgrade rehearsal: a real file DB -// (not :memory:) simulating a production sqlite store carrying pre-000021 rows -// through the 000021 subscriptions-table rebuild. +// (not :memory:) simulating a production sqlite store carrying pre-000023 rows +// through the 000023 subscriptions-table rebuild. func migratorAt(t *testing.T, database *sql.DB, version uint) { t.Helper() src, err := iofs.New(migrations.SQLiteFS, "sqlite") @@ -44,7 +44,7 @@ func migratorAt(t *testing.T, database *sql.DB, version uint) { } } -// insertLegacySubRow inserts one subscriptions row with a pre-000021 source +// insertLegacySubRow inserts one subscriptions row with a pre-000023 source // value ('trial' or 'code') for a fresh user, returning the assigned user_id // and subscription id. func insertLegacySubRow(t *testing.T, database *sql.DB, uuidSuffix, source string) (userID, subID int64) { @@ -82,7 +82,7 @@ func insertLegacySubRow(t *testing.T, database *sql.DB, uuidSuffix, source strin } // TestSQLitePayMigrationRehearsal_UpgradeWithData is Task 8 Step 2: rehearse -// the 000021 upgrade (subscriptions table rebuild for source='pay') against a +// the 000023 upgrade (subscriptions table rebuild for source='pay') against a // file-backed SQLite DB pre-loaded with real 'trial'/'code' rows, and assert // no rows are lost and the id/AUTOINCREMENT sequence is preserved. func TestSQLitePayMigrationRehearsal_UpgradeWithData(t *testing.T) { @@ -101,15 +101,15 @@ func TestSQLitePayMigrationRehearsal_UpgradeWithData(t *testing.T) { _ = trialUserID _ = codeUserID - // 2. Up to 000021 — subscriptions_new rebuild + pay_purchases creation. - migratorAt(t, database, 21) + // 2. Up to 000023 — subscriptions_new rebuild + pay_purchases creation. + migratorAt(t, database, 23) v, dirty, err := store.MigrateVersion(database, "sqlite") if err != nil { t.Fatalf("MigrateVersion: %v", err) } - if dirty || v != 21 { - t.Fatalf("after up to 21: version=%d dirty=%v, want 21/false", v, dirty) + if dirty || v != 23 { + t.Fatalf("after up to 23: version=%d dirty=%v, want 23/false", v, dirty) } // 3. Row count and ids preserved across the rebuild. @@ -118,7 +118,7 @@ func TestSQLitePayMigrationRehearsal_UpgradeWithData(t *testing.T) { t.Fatalf("count subscriptions: %v", err) } if count != 2 { - t.Errorf("subscriptions count after 000021 = %d, want 2 (rows lost in rebuild)", count) + t.Errorf("subscriptions count after 000023 = %d, want 2 (rows lost in rebuild)", count) } for _, want := range []struct { @@ -127,7 +127,7 @@ func TestSQLitePayMigrationRehearsal_UpgradeWithData(t *testing.T) { }{{trialSubID, "trial"}, {codeSubID, "code"}} { var gotSource string if err := database.QueryRow(`SELECT source FROM subscriptions WHERE id = ?`, want.id).Scan(&gotSource); err != nil { - t.Errorf("subscription id=%d missing after 000021: %v", want.id, err) + t.Errorf("subscription id=%d missing after 000023: %v", want.id, err) continue } if gotSource != want.source { @@ -146,7 +146,7 @@ func TestSQLitePayMigrationRehearsal_UpgradeWithData(t *testing.T) { trialUserID, planID, time.Now().Add(30*24*time.Hour).UTC().Format("2006-01-02 15:04:05"), ) if err != nil { - t.Fatalf("insert source='pay' subscription after 000021: %v", err) + t.Fatalf("insert source='pay' subscription after 000023: %v", err) } paySubID, err := res.LastInsertId() if err != nil { @@ -156,10 +156,10 @@ func TestSQLitePayMigrationRehearsal_UpgradeWithData(t *testing.T) { t.Errorf("pay subscription id=%d did not continue AUTOINCREMENT sequence (prior max id=%d)", paySubID, codeSubID) } - // 5. pay_purchases table exists and is empty (fresh table from 000021). + // 5. pay_purchases table exists and is empty (fresh table from 000023). var payCount int if err := database.QueryRow(`SELECT COUNT(*) FROM pay_purchases`).Scan(&payCount); err != nil { - t.Fatalf("count pay_purchases (table should exist post-000021): %v", err) + t.Fatalf("count pay_purchases (table should exist post-000023): %v", err) } if payCount != 0 { t.Errorf("pay_purchases count = %d, want 0 (fresh table)", payCount) @@ -167,11 +167,11 @@ func TestSQLitePayMigrationRehearsal_UpgradeWithData(t *testing.T) { } // TestSQLitePayMigrationRehearsal_DownUpIdempotent covers the second half of -// Task 8 Step 2: with only pre-000021 ('trial'/'code') data present, down -// (rollback 000021) then up (re-apply) must be idempotent and lossless. +// Task 8 Step 2: with only pre-000023 ('trial'/'code') data present, down +// (rollback 000023) then up (re-apply) must be idempotent and lossless. // // It also documents an intentional safety property: once a source='pay' row -// exists, 000021's down.sql (which rebuilds subscriptions with the stricter +// exists, 000023's down.sql (which rebuilds subscriptions with the stricter // CHECK (source IN ('trial','code'))) correctly REFUSES to downgrade rather // than silently dropping paid-subscription rows — see the trailing assertion. func TestSQLitePayMigrationRehearsal_DownUpIdempotent(t *testing.T) { @@ -186,9 +186,9 @@ func TestSQLitePayMigrationRehearsal_DownUpIdempotent(t *testing.T) { _, trialSubID := insertLegacySubRow(t, database, "trial2", "trial") _, codeSubID := insertLegacySubRow(t, database, "code2", "code") - migratorAt(t, database, 21) + migratorAt(t, database, 23) - // down: 000021 -> 000020. No 'pay' rows exist yet, so this must succeed + // down: 000023 -> 000020. No 'pay' rows exist yet, so this must succeed // and preserve the trial/code rows with their original ids. migratorAt(t, database, 20) @@ -219,14 +219,14 @@ func TestSQLitePayMigrationRehearsal_DownUpIdempotent(t *testing.T) { t.Errorf("pay_purchases table still present after down to 000020 (err=%v)", err) } - // up: 000020 -> 000021 again — idempotent re-apply, same rows/ids. - migratorAt(t, database, 21) + // up: 000020 -> 000023 again — idempotent re-apply, same rows/ids. + migratorAt(t, database, 23) v, dirty, err = store.MigrateVersion(database, "sqlite") if err != nil { t.Fatalf("MigrateVersion after re-up: %v", err) } - if dirty || v != 21 { - t.Fatalf("after re-up to 21: version=%d dirty=%v, want 21/false", v, dirty) + if dirty || v != 23 { + t.Fatalf("after re-up to 23: version=%d dirty=%v, want 23/false", v, dirty) } if err := database.QueryRow(`SELECT COUNT(*) FROM subscriptions`).Scan(&count); err != nil { t.Fatalf("count subscriptions after re-up: %v", err) @@ -237,7 +237,7 @@ func TestSQLitePayMigrationRehearsal_DownUpIdempotent(t *testing.T) { // Safety-net documentation: once a source='pay' row exists, down must be // refused (CHECK (source IN ('trial','code')) on the down-rebuilt table), - // not silently drop it. This is NOT a bug — see 000021.down.sql. + // not silently drop it. This is NOT a bug — see 000023.down.sql. var planID int64 if err := database.QueryRow(`SELECT id FROM plans WHERE code = 'pro'`).Scan(&planID); err != nil { t.Fatalf("lookup pro plan id: %v", err) diff --git a/server/internal/store/sqlite_migrate_test.go b/server/internal/store/sqlite_migrate_test.go index 01fae41..ab79d56 100644 --- a/server/internal/store/sqlite_migrate_test.go +++ b/server/internal/store/sqlite_migrate_test.go @@ -29,8 +29,8 @@ func TestSQLiteMigrateUpDown(t *testing.T) { if dirty { t.Fatalf("schema dirty after MigrateUp") } - if v != 21 { - t.Errorf("version = %d, want 21", v) + if v != 27 { + t.Errorf("version = %d, want 27", v) } // 2. Core tables exist. @@ -39,7 +39,7 @@ func TestSQLiteMigrateUpDown(t *testing.T) { "usage_daily", "audit_log", "providers", "nodes", "node_events", "directory_version", "provision_idempotency", "replacements", "admins", "connect_credentials", "usage_device_daily", "sessions", "usage_hourly", "usage_device_hourly", - "pay_purchases", + "pay_purchases", "referrals", "reward_claims", "notices", } { var name string err := db.QueryRow( @@ -50,6 +50,19 @@ func TestSQLiteMigrateUpDown(t *testing.T) { } } + // 2b. idx_subs_user_exp survives the 000024 subscriptions table rebuild + // (SQLite table-rebuild migrations silently drop indexes unless recreated; + // this guards against that regression — see 000024_invite_rewards.{up,down}.sql). + var idxCount int + if err := db.QueryRow( + `SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_subs_user_exp'`, + ).Scan(&idxCount); err != nil { + t.Fatalf("query idx_subs_user_exp: %v", err) + } + if idxCount != 1 { + t.Errorf("idx_subs_user_exp count = %d, want 1 (index lost on subscriptions rebuild?)", idxCount) + } + // 3. Seed: 3 plans + directory_version singleton. var plans int if err := db.QueryRow(`SELECT COUNT(*) FROM plans`).Scan(&plans); err != nil { diff --git a/server/migrations/mysql/000024_invite_rewards.down.sql b/server/migrations/mysql/000024_invite_rewards.down.sql new file mode 100644 index 0000000..3911751 --- /dev/null +++ b/server/migrations/mysql/000024_invite_rewards.down.sql @@ -0,0 +1,7 @@ +ALTER TABLE subscriptions MODIFY source ENUM('trial','code','pay') NOT NULL; +DROP TABLE reward_claims; +DROP TABLE referrals; +ALTER TABLE users + DROP INDEX ux_users_invite_code, + DROP COLUMN first_paid_at, + DROP COLUMN invite_code; diff --git a/server/migrations/mysql/000024_invite_rewards.up.sql b/server/migrations/mysql/000024_invite_rewards.up.sql new file mode 100644 index 0000000..7062aa3 --- /dev/null +++ b/server/migrations/mysql/000024_invite_rewards.up.sql @@ -0,0 +1,32 @@ +ALTER TABLE users + ADD COLUMN invite_code VARCHAR(16) NULL, + ADD COLUMN first_paid_at DATETIME(6) NULL, + ADD UNIQUE KEY ux_users_invite_code (invite_code); + +CREATE TABLE referrals ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + inviter_id BIGINT UNSIGNED NOT NULL, + invitee_id BIGINT UNSIGNED NOT NULL UNIQUE, + device_uuid VARCHAR(64) NULL, + status ENUM('bound','reg_rewarded','paid_rewarded','rejected') NOT NULL DEFAULT 'bound', + reg_rewarded_at DATETIME(6) NULL, + paid_rewarded_at DATETIME(6) NULL, + created_at DATETIME(6) NOT NULL, + INDEX ix_referrals_inviter (inviter_id, created_at), + FOREIGN KEY (inviter_id) REFERENCES users(id), + FOREIGN KEY (invitee_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE reward_claims ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + task_key VARCHAR(32) NOT NULL, + external_ref VARCHAR(64) NOT NULL DEFAULT '', + granted_days INT NOT NULL, + granted_at DATETIME(6) NOT NULL, + UNIQUE KEY ux_claim_user_task (user_id, task_key), + UNIQUE KEY ux_claim_task_ref (task_key, external_ref), + FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +ALTER TABLE subscriptions MODIFY source ENUM('trial','code','pay','invite','task') NOT NULL; diff --git a/server/migrations/mysql/000025_user_device_limit_override.down.sql b/server/migrations/mysql/000025_user_device_limit_override.down.sql new file mode 100644 index 0000000..2d7984a --- /dev/null +++ b/server/migrations/mysql/000025_user_device_limit_override.down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN max_devices_override; diff --git a/server/migrations/mysql/000025_user_device_limit_override.up.sql b/server/migrations/mysql/000025_user_device_limit_override.up.sql new file mode 100644 index 0000000..262b6b8 --- /dev/null +++ b/server/migrations/mysql/000025_user_device_limit_override.up.sql @@ -0,0 +1,2 @@ +-- 单用户设备上限覆盖:NULL=走套餐(plans.max_devices)默认;设为正整数则以它为准。 +ALTER TABLE users ADD COLUMN max_devices_override INT NULL; diff --git a/server/migrations/mysql/000026_notices.down.sql b/server/migrations/mysql/000026_notices.down.sql new file mode 100644 index 0000000..fa031de --- /dev/null +++ b/server/migrations/mysql/000026_notices.down.sql @@ -0,0 +1,2 @@ +DROP TABLE notices; +ALTER TABLE users DROP COLUMN notices_read_at; diff --git a/server/migrations/mysql/000026_notices.up.sql b/server/migrations/mysql/000026_notices.up.sql new file mode 100644 index 0000000..148906f --- /dev/null +++ b/server/migrations/mysql/000026_notices.up.sql @@ -0,0 +1,18 @@ +ALTER TABLE users ADD COLUMN notices_read_at DATETIME(6) NULL; + +CREATE TABLE notices ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + type ENUM('important','feature','news','reward','version','promo') NOT NULL, + user_id BIGINT UNSIGNED NULL, + title_zh TEXT NOT NULL, + title_en TEXT NOT NULL, + body_zh TEXT NULL, + body_en TEXT NULL, + link TEXT NULL, + published_at DATETIME(6) NOT NULL, + expires_at DATETIME(6) NULL, + revoked_at DATETIME(6) NULL, + email_sent_at DATETIME(6) NULL, + INDEX ix_notices_user_pub (user_id, published_at), + FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/server/migrations/mysql/000027_pay_promo_paid_unique.down.sql b/server/migrations/mysql/000027_pay_promo_paid_unique.down.sql new file mode 100644 index 0000000..5173dab --- /dev/null +++ b/server/migrations/mysql/000027_pay_promo_paid_unique.down.sql @@ -0,0 +1 @@ +-- no-op(与 up 对称,无 DDL 可回滚) diff --git a/server/migrations/mysql/000027_pay_promo_paid_unique.up.sql b/server/migrations/mysql/000027_pay_promo_paid_unique.up.sql new file mode 100644 index 0000000..1c2bd51 --- /dev/null +++ b/server/migrations/mysql/000027_pay_promo_paid_unique.up.sql @@ -0,0 +1,7 @@ +-- no-op(MySQL 8):MySQL 不支持部分唯一索引(WHERE 子句),无法照搬 sqlite 版 +-- 的 (user_id, sku) WHERE status='paid' AND sku='pro_month_promo' 约束——建全表 +-- 唯一索引 (user_id, sku, status) 会连带拦掉 created/canceled 状态下的正常 +-- 复购/重试流程,不可行。mysql 侧的 promo 限购 TOCTOU 防线仅落在应用层: +-- webhook.go settle 在锁行开通前对 item.Promo 的 SKU 复查一次 HasPaidPurchase, +-- 命中则跳过发放、吞掉重复单(详见该函数注释)。此文件仅占位对齐 sqlite 的 +-- 000027 编号,不执行任何 DDL。 diff --git a/server/migrations/sqlite/000024_invite_rewards.down.sql b/server/migrations/sqlite/000024_invite_rewards.down.sql new file mode 100644 index 0000000..b644e9c --- /dev/null +++ b/server/migrations/sqlite/000024_invite_rewards.down.sql @@ -0,0 +1,22 @@ +-- 还原 subscriptions.source CHECK +CREATE TABLE subscriptions_old ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + plan_id INTEGER NOT NULL, + expires_at DATETIME NOT NULL, + source TEXT NOT NULL CHECK (source IN ('trial','code','pay')), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (plan_id) REFERENCES plans(id) +); +INSERT INTO subscriptions_old SELECT id,user_id,plan_id,expires_at,source,created_at + FROM subscriptions WHERE source IN ('trial','code','pay'); +DROP TABLE subscriptions; +ALTER TABLE subscriptions_old RENAME TO subscriptions; +CREATE INDEX idx_subs_user_exp ON subscriptions (user_id, expires_at); + +DROP TABLE reward_claims; +DROP TABLE referrals; +DROP INDEX ux_users_invite_code; +ALTER TABLE users DROP COLUMN first_paid_at; +ALTER TABLE users DROP COLUMN invite_code; diff --git a/server/migrations/sqlite/000024_invite_rewards.up.sql b/server/migrations/sqlite/000024_invite_rewards.up.sql new file mode 100644 index 0000000..b66e5a6 --- /dev/null +++ b/server/migrations/sqlite/000024_invite_rewards.up.sql @@ -0,0 +1,50 @@ +-- users 加两列 +ALTER TABLE users ADD COLUMN invite_code TEXT; +ALTER TABLE users ADD COLUMN first_paid_at DATETIME; +CREATE UNIQUE INDEX ux_users_invite_code ON users(invite_code); + +-- 邀请关系(一对一绑定) +CREATE TABLE referrals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + inviter_id INTEGER NOT NULL, + invitee_id INTEGER NOT NULL UNIQUE, + device_uuid TEXT, + status TEXT NOT NULL DEFAULT 'bound' + CHECK (status IN ('bound','reg_rewarded','paid_rewarded','rejected')), + reg_rewarded_at DATETIME, + paid_rewarded_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (inviter_id) REFERENCES users(id), + FOREIGN KEY (invitee_id) REFERENCES users(id) +); +CREATE INDEX ix_referrals_inviter ON referrals(inviter_id, created_at); + +-- 通用一次性任务领取 +CREATE TABLE reward_claims ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + task_key TEXT NOT NULL, + external_ref TEXT NOT NULL DEFAULT '', + granted_days INTEGER NOT NULL, + granted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) +); +CREATE UNIQUE INDEX ux_claim_user_task ON reward_claims(user_id, task_key); +CREATE UNIQUE INDEX ux_claim_task_ref ON reward_claims(task_key, external_ref); + +-- subscriptions.source 扩容(SQLite 需重建表) +CREATE TABLE subscriptions_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + plan_id INTEGER NOT NULL, + expires_at DATETIME NOT NULL, + source TEXT NOT NULL CHECK (source IN ('trial','code','pay','invite','task')), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (plan_id) REFERENCES plans(id) +); +INSERT INTO subscriptions_new (id,user_id,plan_id,expires_at,source,created_at) + SELECT id,user_id,plan_id,expires_at,source,created_at FROM subscriptions; +DROP TABLE subscriptions; +ALTER TABLE subscriptions_new RENAME TO subscriptions; +CREATE INDEX idx_subs_user_exp ON subscriptions (user_id, expires_at); diff --git a/server/migrations/sqlite/000025_user_device_limit_override.down.sql b/server/migrations/sqlite/000025_user_device_limit_override.down.sql new file mode 100644 index 0000000..2d7984a --- /dev/null +++ b/server/migrations/sqlite/000025_user_device_limit_override.down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN max_devices_override; diff --git a/server/migrations/sqlite/000025_user_device_limit_override.up.sql b/server/migrations/sqlite/000025_user_device_limit_override.up.sql new file mode 100644 index 0000000..f1e7274 --- /dev/null +++ b/server/migrations/sqlite/000025_user_device_limit_override.up.sql @@ -0,0 +1,2 @@ +-- 单用户设备上限覆盖:NULL=走套餐(plans.max_devices)默认;设为正整数则以它为准。 +ALTER TABLE users ADD COLUMN max_devices_override INTEGER; diff --git a/server/migrations/sqlite/000026_notices.down.sql b/server/migrations/sqlite/000026_notices.down.sql new file mode 100644 index 0000000..fa031de --- /dev/null +++ b/server/migrations/sqlite/000026_notices.down.sql @@ -0,0 +1,2 @@ +DROP TABLE notices; +ALTER TABLE users DROP COLUMN notices_read_at; diff --git a/server/migrations/sqlite/000026_notices.up.sql b/server/migrations/sqlite/000026_notices.up.sql new file mode 100644 index 0000000..03355f7 --- /dev/null +++ b/server/migrations/sqlite/000026_notices.up.sql @@ -0,0 +1,18 @@ +ALTER TABLE users ADD COLUMN notices_read_at DATETIME; + +CREATE TABLE notices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL CHECK (type IN ('important','feature','news','reward','version','promo')), + user_id INTEGER, -- NULL=全员广播;非空=定向该用户 + title_zh TEXT NOT NULL, + title_en TEXT NOT NULL, + body_zh TEXT, + body_en TEXT, + link TEXT, + published_at DATETIME NOT NULL, + expires_at DATETIME, + revoked_at DATETIME, + email_sent_at DATETIME, + FOREIGN KEY (user_id) REFERENCES users(id) +); +CREATE INDEX ix_notices_user_pub ON notices(user_id, published_at); diff --git a/server/migrations/sqlite/000027_pay_promo_paid_unique.down.sql b/server/migrations/sqlite/000027_pay_promo_paid_unique.down.sql new file mode 100644 index 0000000..dd63831 --- /dev/null +++ b/server/migrations/sqlite/000027_pay_promo_paid_unique.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS ux_pay_promo_paid; diff --git a/server/migrations/sqlite/000027_pay_promo_paid_unique.up.sql b/server/migrations/sqlite/000027_pay_promo_paid_unique.up.sql new file mode 100644 index 0000000..b95d496 --- /dev/null +++ b/server/migrations/sqlite/000027_pay_promo_paid_unique.up.sql @@ -0,0 +1,9 @@ +-- C2 安全修复(promo 限购 TOCTOU):CreateOrder 下单时的 HasPaidPurchase 只是裸 +-- SELECT(无锁),并发/多挂起单可绕过「每账号限购一次」。这里加部分唯一索引兜底: +-- 同一 user_id 的 pro_month_promo 至多一条 status='paid'。sqlite 支持部分索引 +-- (WHERE 子句),精确限定 sku='pro_month_promo'(目前唯一的 Promo=true 档位), +-- 不影响其余 SKU 正常复购。webhook.go settle 侧另有应用层复查作为第一道防线 +-- (mysql 侧 000027 是 no-op,不支持部分索引——见 mysql 版本注释)。 +CREATE UNIQUE INDEX ux_pay_promo_paid + ON pay_purchases (user_id, sku) + WHERE status = 'paid' AND sku = 'pro_month_promo'; diff --git a/tools/check-l1-sync.mjs b/tools/check-l1-sync.mjs index 5a56301..b9ad23e 100644 --- a/tools/check-l1-sync.mjs +++ b/tools/check-l1-sync.mjs @@ -1,145 +1,62 @@ #!/usr/bin/env node -// tools/check-l1-sync.mjs — L1 设计真相源跨端同源闸(纯 Node 零依赖) -// -// 治理依据:根 CLAUDE.md「前端设计系统治理(ds-flow)」——tokens/icons 以 -// design/prototype/ 为单一真源,Web 两端(website/usercenter)只能是同步副本。 -// Flutter 的 token 同源由 ci/check-codegen-drift.sh 守护(codegen 零 diff), -// 本闸补 Web token 值同源 + 三端图标 ⊆ 原型 sprite + Web 硬编码色扫描。 -// -// 本地/CI 直接跑:node tools/check-l1-sync.mjs -// -// 四道检查(任一违规 exit 1): -// ① website token 值同源 web/website/src/styles/tokens.gen.css 每个 token -// 值 ≡ design/prototype/tokens.css(:root + dark)。 -// ② usercenter token 同源 web/usercenter/public/colors_and_type.css 同上。 -// ③ 图标 ⊆ 原型 sprite usercenter LUCIDE(键+路径)+ Flutter pangolin_icons -// _byName(键)必须 ⊆ 原型 icons.js ICONS。 -// ④ Web 硬编码色扫描 website src / usercenter app|components|lib 禁裸 hex; -// 白名单 #fff/#000 + 品牌 logo 固定色;行内 `ds-allow` 豁免; -// 排除生成的 token 定义文件。 - -import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'; -import { join } from 'node:path'; +// check-l1-sync.mjs —— L1 跨端同源闸(ds-flow)。零依赖。 +// ① 图标集合:Flutter 图标集 == 原型 sprite 图标集(camel 化后逐名相等)。 +// 真源 design/prototype/icons.js;镜像 client/lib/widgets/pangolin_icons.dart。 +// ② l10n 完整性:app_text.dart 声明的每个 getter,6 个语言文件必须全部实现 +// (谁少 key 就红 —— 防「某语言漏字串」漂移,如支付字串只加 zh/en)。 +// 任一不通过 → 列出并 exit 1。 +import { readFileSync, existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; -const ROOT = fileURLToPath(new URL('..', import.meta.url)); -const read = (p) => readFileSync(join(ROOT, p), 'utf8'); -const problems = []; +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, '..'); +let failed = 0; -// 解析 CSS 块里的 --var: value;(压掉空白便于比较) -function parseVars(cssBlock) { - const out = {}; - for (const [, k, v] of cssBlock.matchAll(/(--[\w-]+)\s*:\s*([^;}]+)[;}]/g)) { - out[k] = v.replace(/\s+/g, ' ').trim(); - } - return out; -} -// 取 tokens.css 的 :root + [data-theme="dark"] 全量 token -function protoTokens() { - const css = read('design/prototype/tokens.css'); - const root = css.match(/:root\s*\{([^}]*)\}/s)?.[1] ?? ''; - const dark = css.match(/\[data-theme="dark"\]\s*\{([^}]*)\}/s)?.[1] ?? ''; - return { ...parseVars(root), ...parseVars(dark) }; +/* ① 图标集合 */ +const spriteKeys = new Set( + [...readFileSync(resolve(root, 'design/prototype/icons.js'), 'utf8') + .matchAll(/'([a-z0-9-]+)'\s*:\s*' m[1].replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase())) +); +const dartKeys = new Set( + [...readFileSync(resolve(root, 'client/lib/widgets/pangolin_icons.dart'), 'utf8') + .matchAll(/LucideIcons\.([A-Za-z0-9]+)/g)].map((m) => m[1]) +); +const iconMissing = [...spriteKeys].filter((k) => !dartKeys.has(k)).sort(); +const iconExtra = [...dartKeys].filter((k) => !spriteKeys.has(k)).sort(); +if (iconMissing.length === 0 && iconExtra.length === 0) { + console.log(`✓ 图标同集 OK —— ${spriteKeys.size} 个 sprite ↔ Flutter 逐名相等`); +} else { + failed++; + console.error('✗ 图标同集 MISMATCH:'); + if (iconMissing.length) console.error(' sprite 有 / Flutter 缺:', iconMissing.join(', ')); + if (iconExtra.length) console.error(' Flutter 多 / sprite 无:', iconExtra.join(', ')); + console.error(' 修复:node design/codegen/gen_flutter_icons.mjs'); } -// ── ①② Web token 值同源 ──────────────────────────────────── -function checkWebTokenSync(label, webPath) { - if (!existsSync(join(ROOT, webPath))) { - problems.push(`[${label}] 找不到 ${webPath} —— 先跑 npm run gen:tokens`); - return; +/* ② Flutter l10n 完整性 */ +const L10N = resolve(root, 'client/lib/l10n'); +const BASE = resolve(L10N, 'app_text.dart'); +const LANGS = ['zh', 'en', 'ja', 'ko', 'ru', 'es']; +const getterSet = (src) => new Set([...src.matchAll(/\bString\s+get\s+(\w+)\b/g)].map((m) => m[1])); +if (!existsSync(BASE)) { + console.log('· l10n 完整性:跳过(无 app_text.dart)'); +} else { + // canonical = 仅抽象 getter(`String get X;` 分号结尾);块体默认 `String get X {…}` 是继承默认,不要求 langs 实现 + const canonical = new Set([...readFileSync(BASE, 'utf8').matchAll(/\bString\s+get\s+(\w+)\s*;/g)].map((m) => m[1])); + const gaps = []; + for (const lang of LANGS) { + const f = resolve(L10N, `strings_${lang}.dart`); + if (!existsSync(f)) { gaps.push(` ${lang}: 文件缺失`); continue; } + const miss = [...canonical].filter((k) => !getterSet(readFileSync(f, 'utf8')).has(k)).sort(); + if (miss.length) gaps.push(` ${lang}: 缺 ${miss.length} 个 → ${miss.slice(0, 12).join(', ')}${miss.length > 12 ? ' …' : ''}`); } - const proto = protoTokens(); - const web = parseVars(read(webPath)); - for (const [k, v] of Object.entries(proto)) { - if (!(k in web)) { - problems.push(`[${label}] 缺 token ${k} —— ${webPath} 未同步原型(重跑 gen:tokens)`); - } else if (web[k] !== v) { - problems.push(`[${label}] ${k} 值漂移:原型=${v} Web=${web[k]} —— ${webPath} 应由 build-tokens 从原型再生,勿手改`); - } + if (gaps.length === 0) { + console.log(`✓ l10n 完整 OK —— ${canonical.size} 个 getter × ${LANGS.length} 语言全实现`); + } else { + failed++; + console.error('✗ l10n 不完整(某语言漏 key):'); + gaps.forEach((g) => console.error(g)); } } - -// ── ③ 图标 ⊆ 原型 sprite ──────────────────────────────────── -{ - // 原型 icons.js: var ICONS = { 'name': '', ... } - const protoJs = read('design/prototype/icons.js'); - const protoBody = protoJs.match(/var ICONS\s*=\s*\{([\s\S]*?)\n\s*\};/)?.[1] ?? ''; - const protoIcons = {}; - for (const [, id, body] of protoBody.matchAll(/'([^']+)'\s*:\s*'([^']*)'/g)) protoIcons[id] = body; - if (!Object.keys(protoIcons).length) { - problems.push('[icons] 解析原型 icons.js ICONS 为空 —— 检查文件结构'); - } - - // usercenter: export const LUCIDE: Record = { 'name': '', ... } - const ucJs = read('web/usercenter/components/icons.tsx'); - const ucBody = ucJs.match(/LUCIDE\s*:[^=]*=\s*\{([\s\S]*?)\n\};/)?.[1] ?? ucJs.match(/LUCIDE\s*=\s*\{([\s\S]*?)\n\};/)?.[1] ?? ''; - // 键可能带引号('refresh-cw')或裸标识符(home)—— 两种都匹配(裸键此前被漏检)。 - for (const [, id, body] of ucBody.matchAll(/['"]?([\w-]+)['"]?\s*:\s*'([^']*)'/g)) { - if (!(id in protoIcons)) { - problems.push(`[icons] usercenter 图标「${id}」不在原型 sprite —— 先登记 design/prototype/icons.js 再用`); - } else if (protoIcons[id] !== body) { - problems.push(`[icons] usercenter 图标「${id}」路径与原型 sprite 不一致 —— 以原型为准`); - } - } - - // Flutter: static const Map _byName = { 'name': ..., } - const dart = read('client/lib/widgets/pangolin_icons.dart'); - const dartBody = dart.match(/_byName\s*=\s*\{([\s\S]*?)\};/)?.[1] ?? ''; - for (const [, id] of dartBody.matchAll(/'([^']+)'\s*:/g)) { - if (!(id in protoIcons)) { - problems.push(`[icons] Flutter 图标「${id}」不在原型 sprite —— 新图标先登记 design/prototype/icons.js`); - } - } -} - -// ── ④ Web 硬编码色扫描 ────────────────────────────────────── -{ - // 白名单:纯白/纯黑 + 品牌 logo 固定色(SVG 内联 fill,非业务散色) - const ALLOW = new Set(['fff', 'ffffff', '000', '000000', 'b96a3d', 'faf3ed', 'f4efe8', '9e5630', '3d2213']); - // 排除:生成的 token 定义文件 + 构建产物 - const SKIP = new Set([ - 'web/website/src/styles/tokens.gen.css', - 'web/usercenter/public/colors_and_type.css', - ]); - const SKIP_DIR = new Set(['node_modules', 'dist', 'out', '.next', 'build', '.astro', 'public']); - const ROOTS = ['web/website/src', 'web/usercenter/app', 'web/usercenter/components', 'web/usercenter/lib']; - const files = []; - const walk = (dir) => { - if (!existsSync(join(ROOT, dir))) return; - for (const name of readdirSync(join(ROOT, dir))) { - if (SKIP_DIR.has(name)) continue; - const rel = `${dir}/${name}`; - const st = statSync(join(ROOT, rel)); - if (st.isDirectory()) walk(rel); - else if (/\.(astro|jsx|tsx|ts|js|css)$/.test(name) && !SKIP.has(rel)) files.push(rel); - } - }; - ROOTS.forEach(walk); - for (const f of files) { - read(f).split('\n').forEach((line, i) => { - if (line.includes('ds-allow')) return; - for (const [hex] of line.matchAll(/#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/g)) { - const v = hex.slice(1).toLowerCase(); - if (!ALLOW.has(v)) { - problems.push(`[web-hex] ${f}:${i + 1} 硬编码色 ${hex} —— 改用 var(--token),确需保留(如品牌 logo)加行内 ds-allow 注释`); - } - } - }); - } -} - -checkWebTokenSync('website-token', 'web/website/src/styles/tokens.gen.css'); -checkWebTokenSync('usercenter-token', 'web/usercenter/public/colors_and_type.css'); - -// ── 汇总 ──────────────────────────────────────────────────── -const line = '='.repeat(60); -if (problems.length) { - console.error(line); - console.error(`✗ L1 跨端同源闸未过(${problems.length} 处):`); - for (const p of problems) console.error(' · ' + p); - console.error(line); - process.exit(1); -} -console.log(line); -console.log('✓ 通过:L1 跨端同源(website/usercenter token 值 · 三端图标 ⊆ 原型 sprite · Web 无硬编码色)'); -console.log(line); +process.exit(failed ? 1 : 0); diff --git a/web/website/public/_headers b/web/website/public/_headers index 48f0400..4ea85ee 100644 --- a/web/website/public/_headers +++ b/web/website/public/_headers @@ -1,9 +1,12 @@ # Cloudflare Pages 安全响应头 — 由 public/_headers 原样下发到产物根。 # 设计:严格 CSP(白名单仅 self + 自托管资源)、HSTS、隐私无第三方。 -# 站点完全静态、自托管字体与脚本、无第三方统计/CDN,故所有 *-src 收敛到 'self'。 +# 站点完全静态、自托管字体与脚本、无第三方统计/CDN,故大部分 *-src 收敛到 'self'; +# 唯一例外 connect-src 放行 api.yanmeiai.com(控制面 API,Cloudflare Tunnel 出口, +# CORS 已放行本站 origin)——落地页/注册页/首页注册入口浏览器端直连该域发验证码、 +# 注册(见 src/components/RegisterCard.jsx),不经站内代理。 /* - Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests + Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://api.yanmeiai.com; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests Strict-Transport-Security: max-age=63072000; includeSubDomains; preload X-Content-Type-Options: nosniff X-Frame-Options: DENY diff --git a/web/website/public/_redirects b/web/website/public/_redirects new file mode 100644 index 0000000..bb42c83 --- /dev/null +++ b/web/website/public/_redirects @@ -0,0 +1,11 @@ +# Cloudflare Pages 重写规则。 +# 邀请落地页:App 分享链接固定形如 /i/<8位邀请码>(语言中立,默认落到英文根); +# 各语言前缀下也有对应的邀请落地页(/zh/i/、/ja/i/ 等,用户从 Header 切换语言后停留)。 +# CF Pages 静态托管无路由参数,用重写把任意邀请码路径统一指到各自语言的静态产物, +# 邀请码本身由页面 JS 从 location.pathname 解析(见 src/components/InviteCard.jsx)。 +/i/* /i/ 200 +/zh/i/* /zh/i/ 200 +/ja/i/* /ja/i/ 200 +/ko/i/* /ko/i/ 200 +/ru/i/* /ru/i/ 200 +/es/i/* /es/i/ 200 diff --git a/web/website/src/components/AnnouncementBar.jsx b/web/website/src/components/AnnouncementBar.jsx index aa2cde2..02faad7 100644 --- a/web/website/src/components/AnnouncementBar.jsx +++ b/web/website/src/components/AnnouncementBar.jsx @@ -1,18 +1,31 @@ /** * AnnouncementBar.jsx — 顶部公告条(React island)。迁自原型 .ann + site.js __closeAnn。 + * + * CTA 直接跳**本语言**的独立注册页 `/register`(不再锚点滚动到 hero 的 #signup —— + * 那边现在本身也只是跳 register 的一层转发,直达更省一跳)。路径拼接走 + * lib/langPath.js,与 Header/SignupForm 同一实现。已登录(见 lib/authState.js)时整条 + * 隐藏:老用户不需要看到「新用户注册」的横幅。无 JS 时默认展示(渐进增强)。 */ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Gift, X } from 'lucide-react'; +import { isLoggedIn } from '../lib/authState'; +import { langPath } from '../lib/langPath'; -export default function AnnouncementBar({ text, cta }) { +export default function AnnouncementBar({ text, cta, lang = 'en' }) { const [closed, setClosed] = useState(false); - if (closed) return null; + const [loggedIn, setLoggedIn] = useState(false); + + useEffect(() => { + setLoggedIn(isLoggedIn()); + }, []); + + if (closed || loggedIn) return null; return (
{text} - {cta} + {cta} diff --git a/web/website/src/components/Header.jsx b/web/website/src/components/Header.jsx index d7ecbd0..33cda28 100644 --- a/web/website/src/components/Header.jsx +++ b/web/website/src/components/Header.jsx @@ -1,11 +1,14 @@ /** * Header.jsx — 交互式顶栏(React island,@astrojs/react 渲染 + 水合)。 * 迁自 design/ui_kits/website/index.html 的
+ site.js 的菜单/滚动毛玻璃逻辑。 - * 语言切换由原型的 JS 文本替换改为「路由跳转」(zh=/, en=/en/),单显不并排(铁律 6)。 + * 语言切换由原型的 JS 文本替换改为「路由跳转」(en=/, zh=/zh/),单显不并排(铁律 6); + * 拼路径统一走 lib/langPath.js,`pagePath` 非空时(邀请/注册页)原地切语言而非跳回首页。 */ import { useEffect, useRef, useState } from 'react'; import { Download, Menu } from 'lucide-react'; import { SITE } from '../config/site'; +import { isLoggedIn, getStoredEmail, clearStoredSession } from '../lib/authState'; +import { langPath } from '../lib/langPath'; function Mark() { return ( @@ -26,7 +29,7 @@ function Mark() { ); } -export default function Header({ lang = 'zh', t = {} }) { +export default function Header({ lang = 'zh', t = {}, pagePath = '' }) { const [open, setOpen] = useState(false); const [scrolled, setScrolled] = useState(false); const [langOpen, setLangOpen] = useState(false); @@ -42,24 +45,15 @@ export default function Header({ lang = 'zh', t = {} }) { const displayName = (email && email.split('@')[0]) || 'Account'; // 与用户中心同源:登录后 localStorage 存 pg_uc_refresh(+ pg_uc_email)→ 显示用户名下拉。 + // 判断逻辑单一实现见 lib/authState.js(AnnouncementBar/SignupForm/RegisterCard/InviteCard 同源复用)。 useEffect(() => { - try { - setLoggedIn(!!localStorage.getItem('pg_uc_refresh')); - setEmail(localStorage.getItem('pg_uc_email') || ''); - } catch { - setLoggedIn(false); - } + setLoggedIn(isLoggedIn()); + setEmail(getStoredEmail()); }, []); // 清登录态并跳转(切换用户 = 回登录页;退出 = 回主页)。 - const clearSession = () => { - try { - localStorage.removeItem('pg_uc_refresh'); - localStorage.removeItem('pg_uc_email'); - } catch { /* ignore */ } - }; - const onSwitch = () => { clearSession(); window.location.href = loginHref; }; - const onLogout = () => { clearSession(); window.location.href = '/'; }; + const onSwitch = () => { clearStoredSession(); window.location.href = loginHref; }; + const onLogout = () => { clearStoredSession(); window.location.href = '/'; }; useEffect(() => { if (!langOpen) return; @@ -98,7 +92,8 @@ export default function Header({ lang = 'zh', t = {} }) { ['en', 'English'], ['zh', '中文'], ['ja', '日本語'], ['ko', '한국어'], ['ru', 'Русский'], ['es', 'Español'], ]; - const langHref = (l) => (l === 'en' ? '/' : `/${l}/`); + // pagePath 空串=首页;非空时(如 'i'/'register')保持在同一页面切语言,而不是跳回首页。 + const langHref = (l) => langPath(l, pagePath); return (
diff --git a/web/website/src/components/Hero.astro b/web/website/src/components/Hero.astro index 42a0651..8d0e870 100644 --- a/web/website/src/components/Hero.astro +++ b/web/website/src/components/Hero.astro @@ -1,10 +1,10 @@ --- import Icon from './Icon.astro'; import SignupForm from './SignupForm.jsx'; -import type { T } from '../i18n/strings'; +import type { T, Lang } from '../i18n/strings'; -interface Props { t: T } -const { t } = Astro.props; +interface Props { t: T; lang: Lang } +const { t, lang } = Astro.props; ---
@@ -15,11 +15,12 @@ const { t } = Astro.props;
diff --git a/web/website/src/components/InviteCard.jsx b/web/website/src/components/InviteCard.jsx new file mode 100644 index 0000000..a39f052 --- /dev/null +++ b/web/website/src/components/InviteCard.jsx @@ -0,0 +1,272 @@ +/** + * InviteCard.jsx — 邀请落地页正文(React island,/i/ 与 //i/ 唯一动态内容)。 + * + * 这条路由本质是运行时的:CF Pages 用 `_redirects` 把每个语言前缀下的 `//i/*` + * (含英文根 `/i/*`)统一重写到对应静态产物 `//i/index.html`,邀请码本身只存在于 + * 原始 URL 路径里,构建期完全不知道,所以只能在浏览器里从 `location.pathname` 取。 + * + * 语言现在跟随站点路由:由父级 layout(InvitePage.astro)在构建期把 `lang` 当 prop 传入, + * 六语(zh/en/ja/ko/ru/es)文案单显,不再有组件内嵌的 zh/en 运行时切换开关。 + * Header/Footer 同样按该 `lang` 单显对应语言(全站惯例)。 + * + * 主 CTA 是「注册领取 3 天」按钮,跳去**本语言**的独立注册页 `/register` + * (?invite=<码> 预填,见 RegisterCard.jsx;路径拼接见 lib/langPath.js),本页不再内联 + * 注册表单。已登录(同源 localStorage 里有 pg_uc_refresh,见 lib/authState.js)时把 CTA + * 换成提示文案——邀请码只在新账号注册时生效,老账号无法补绑。 + * + * App 发出的邀请链接本身是语言中立的根路径 `/i/<码>`(默认落到英文),不做自动语言重定向 + * ——与主页行为一致,用户可用 Header 的语言下拉自行切换(切换会带上当前的邀请码路径,见 + * Header.jsx 的 `pagePath` 机制)。 + */ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Copy, Check, Download, UserPlus, Gift, ArrowRight } from 'lucide-react'; +import { isLoggedIn } from '../lib/authState'; +import { INVITE_CODE_RE } from '../lib/inviteCode'; +import { langPath } from '../lib/langPath'; + +// 邀请码字母表定义单一实现在 lib/inviteCode.js(与 RegisterCard.jsx 的 ?invite= +// 解析共用,避免两处定义漂移)。 + +function extractCode(pathname) { + const m = /\/i\/([^/]+)/i.exec(pathname || ''); + if (!m) return null; + let raw = ''; + try { + raw = decodeURIComponent(m[1]); + } catch { + raw = m[1]; + } + raw = raw.trim().toUpperCase(); + return INVITE_CODE_RE.test(raw) ? raw : null; +} + +// 文案沿用 App l10n 现成措辞(client/lib/l10n/strings_*.dart 的 invite*/auth 相关 getter), +// ja/ko/ru/es 依此自然本地化,非机翻。 +const COPY = { + zh: { + eyebrow: '邀请', + heroWithCode: '你的朋友邀请你使用穿山甲', + heroNoCode: '一起用穿山甲,网络更顺滑', + codeLabel: '邀请码', + codeFallback: '通用邀请', + copy: '复制邀请码', + copied: '已复制', + rewardsTitle: '奖励规则', + reward1: '好友注册 · 双方各得 3 天', + reward2: '好友首购 · 双方各得 7 天', + stepsTitle: '如何领取', + step1: '下载安装穿山甲', + step2: '注册时在「邀请码(选填)」填入此码', + step3: '双方到账,天数自动加到账户', + dl: '免费下载', + ctaRegister: '注册领取 3 天', + ctaLoggedIn: '你已登录,邀请码仅新账号注册时可用', + }, + en: { + eyebrow: 'Invite', + heroWithCode: 'A friend invited you to try Pangolin', + heroNoCode: 'Try Pangolin — smoother, faster connections', + codeLabel: 'Invite code', + codeFallback: 'General invite', + copy: 'Copy invite code', + copied: 'Copied', + rewardsTitle: 'Rewards', + reward1: 'Friend signs up · +3 days each', + reward2: 'Friend’s first purchase · +7 days each', + stepsTitle: 'How to claim', + step1: 'Download and install Pangolin', + step2: 'Enter this code under “Invite code (optional)” at sign-up', + step3: 'Days are credited to both accounts automatically', + dl: 'Download free', + ctaRegister: 'Register & claim +3 days', + ctaLoggedIn: "You're already signed in — invite codes only apply at sign-up", + }, + ja: { + eyebrow: '招待', + heroWithCode: '友達があなたをPangolinに招待しました', + heroNoCode: 'Pangolinで、もっと快適なネットワークを', + codeLabel: '招待コード', + codeFallback: '共通招待', + copy: '招待コードをコピー', + copied: 'コピーしました', + rewardsTitle: '特典ルール', + reward1: '友達が登録 · 双方に3日間付与', + reward2: '友達が初回購入 · 双方に7日間付与', + stepsTitle: '受け取り方', + step1: 'Pangolinをダウンロード・インストール', + step2: '登録時に「招待コード(任意)」にこのコードを入力', + step3: '双方に自動で日数が加算されます', + dl: '無料ダウンロード', + ctaRegister: '登録して3日間を受け取る', + ctaLoggedIn: 'すでにログイン済みです — 招待コードは新規登録時のみ有効です', + }, + ko: { + eyebrow: '초대', + heroWithCode: '친구가 당신을 Pangolin에 초대했습니다', + heroNoCode: 'Pangolin과 함께, 더 매끄러운 네트워크를', + codeLabel: '초대 코드', + codeFallback: '공통 초대', + copy: '초대 코드 복사', + copied: '복사됨', + rewardsTitle: '보상 규칙', + reward1: '친구 가입 · 양쪽 각 3일 지급', + reward2: '친구 첫 구매 · 양쪽 각 7일 지급', + stepsTitle: '받는 방법', + step1: 'Pangolin 다운로드 및 설치', + step2: '가입 시 「초대 코드(선택)」에 이 코드를 입력', + step3: '양쪽 계정에 일수가 자동으로 적립됩니다', + dl: '무료 다운로드', + ctaRegister: '가입하고 3일 받기', + ctaLoggedIn: '이미 로그인되어 있습니다 — 초대 코드는 신규 가입 시에만 적용됩니다', + }, + ru: { + eyebrow: 'Приглашение', + heroWithCode: 'Друг пригласил вас попробовать Pangolin', + heroNoCode: 'Pangolin — быстрее и стабильнее в сети', + codeLabel: 'Код приглашения', + codeFallback: 'Общее приглашение', + copy: 'Скопировать код', + copied: 'Скопировано', + rewardsTitle: 'Правила наград', + reward1: 'Регистрация друга · +3 дня каждому', + reward2: 'Первая покупка друга · +7 дней каждому', + stepsTitle: 'Как получить', + step1: 'Скачайте и установите Pangolin', + step2: 'При регистрации введите этот код в поле «Код приглашения (необязательно)»', + step3: 'Дни автоматически зачисляются на оба аккаунта', + dl: 'Скачать бесплатно', + ctaRegister: 'Зарегистрироваться и получить +3 дня', + ctaLoggedIn: 'Вы уже вошли в аккаунт — коды приглашений действуют только при регистрации', + }, + es: { + eyebrow: 'Invitación', + heroWithCode: 'Un amigo te invitó a probar Pangolin', + heroNoCode: 'Prueba Pangolin — conexiones más fluidas y rápidas', + codeLabel: 'Código de invitación', + codeFallback: 'Invitación general', + copy: 'Copiar código', + copied: 'Copiado', + rewardsTitle: 'Reglas de recompensas', + reward1: 'Registro de amigo · +3 días cada uno', + reward2: 'Primera compra del amigo · +7 días cada uno', + stepsTitle: 'Cómo reclamar', + step1: 'Descarga e instala Pangolin', + step2: 'Introduce este código en «Código de invitación (opcional)» al registrarte', + step3: 'Los días se acreditan automáticamente en ambas cuentas', + dl: 'Descargar gratis', + ctaRegister: 'Regístrate y reclama +3 días', + ctaLoggedIn: 'Ya tienes sesión iniciada — los códigos de invitación solo aplican al registrarte', + }, +}; + +export default function InviteCard({ lang = 'en' }) { + const [code, setCode] = useState(null); + const [copied, setCopied] = useState(false); + const [loggedIn, setLoggedIn] = useState(false); + // onCopy 里 1.5s 后把 copied 复位的 setTimeout 存 ref,卸载时 clear—— + // 否则用户复制后立刻离开页面,定时器仍会在已卸载组件上触发 setState。 + const copyTimeoutRef = useRef(null); + + useEffect(() => { + setCode(extractCode(window.location.pathname)); + setLoggedIn(isLoggedIn()); + }, []); + + useEffect(() => () => clearTimeout(copyTimeoutRef.current), []); + + const t = COPY[lang] || COPY.en; + const dlHref = useMemo(() => `${langPath(lang)}#download`, [lang]); + const registerHref = useMemo( + () => (code ? `${langPath(lang, 'register')}?invite=${code}` : langPath(lang, 'register')), + [lang, code], + ); + + async function onCopy() { + if (!code) return; + try { + await navigator.clipboard.writeText(code); + setCopied(true); + clearTimeout(copyTimeoutRef.current); + copyTimeoutRef.current = setTimeout(() => setCopied(false), 1500); + } catch { + // 剪贴板权限失败时静默,用户仍可长按/选中手动复制。 + } + } + + return ( +
+
+
+
{t.eyebrow}
+

{code ? t.heroWithCode : t.heroNoCode}

+ +
+ {t.codeLabel} + {code ? ( + {code} + ) : ( + {t.codeFallback} + )} +
+ + {code && ( + + )} + + {loggedIn ? ( +
{t.ctaLoggedIn}
+ ) : ( + + + {t.ctaRegister} + + + )} + +
+
+ + {t.rewardsTitle} +
+
    +
  • + + {t.reward1} +
  • +
  • + + {t.reward2} +
  • +
+
+ +
+
{t.stepsTitle}
+
    +
  1. + 1 + {t.step1} +
  2. +
  3. + 2 + {t.step2} +
  4. +
  5. + 3 + {t.step3} +
  6. +
+
+ + + + {t.dl} + +
+
+
+ ); +} diff --git a/web/website/src/components/RegisterCard.jsx b/web/website/src/components/RegisterCard.jsx new file mode 100644 index 0000000..77f4d60 --- /dev/null +++ b/web/website/src/components/RegisterCard.jsx @@ -0,0 +1,582 @@ +/** + * RegisterCard.jsx — 独立注册页 `/register` 与 `//register` 正文(React island)。 + * + * 承接两处入口:① 首页 hero「免费注册」表单跳转(?email= 预填);② 邀请落地页 + * `//i/`「注册领取 3 天」跳转(?invite= 预填)。二者共用同一套「验证码 + 设密码」 + * 注册流程,行为与后端契约见 server/internal/auth/handler.go: + * + * POST /v1/auth/code body {email} → 204 + * POST /v1/auth/register body {email,code,password,invite_code?} + * (device 整个省略——web 无设备指纹,后端零值容忍) + * → 200 {access_token,refresh_token,...}(网页不存,注册成功即弃) + * + * 错误统一 {code,message_zh,message_en};ja/ko/ru/es 后端暂无对应本地化字段,网络/接口错误 + * 一律回退本组件的六语通用兜底文案(不影响功能,仅错误提示语言与后端返回是否精确对应)。 + * + * 语言现在跟随站点路由:由父级 layout(RegisterPage.astro)在构建期把 `lang` 当 prop + * 传入,六语单显,不再有组件内嵌的 zh/en 运行时切换开关。 + * + * 已登录(同源 localStorage 里有 pg_uc_refresh,见 lib/authState.js)直接短路成 + * 「你已登录」提示,不渲染注册表单(无 JS 时默认展示表单,渐进增强)。注册成功后倒计时 + * 自动跳回**本语言**首页(`langPath(lang)`)。 + */ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Mail, KeyRound, Lock, Loader2, AlertCircle, CheckCircle2, Download, ArrowRight } from 'lucide-react'; +import { SITE } from '../config/site'; +import { isLoggedIn } from '../lib/authState'; +import { normalizeInviteCode } from '../lib/inviteCode'; +import { langPath } from '../lib/langPath'; + +const API_BASE = import.meta.env.PUBLIC_API_BASE || SITE.api; +const RESEND_SECS = 60; +const REDIRECT_SECS = 3; + +// 文案沿用 App l10n 现成措辞(client/lib/l10n/strings_*.dart 的 auth 相关 getter: +// emailLabel/pwLabel/codeLabel/sendCode/resend/tabRegister 等),ja/ko/ru/es 依此自然 +// 本地化;网络错误/表单校验/成功页等站点特有文案参照全站 i18n/strings.ts 的口吻续写,非机翻。 +const COPY = { + zh: { + eyebrow: '注册', + titleInvite: '注册穿山甲,双方各得 3 天', + titlePlain: '创建你的穿山甲账户', + sub: '一个邮箱即可开始,免费注册,无需任何付款信息。', + emailLabel: '邮箱', + emailPh: '你的邮箱', + sendCode: '发送验证码', + resend: '重新发送', + resendIn: (n) => `${n}s 后可重发`, + sending: '发送中…', + codeLabel: '验证码', + codePh: '6 位验证码', + pwLabel: '设置密码', + pwPh: '至少 8 位', + pwHint: '密码至少 8 位', + submitInvite: '注册并领取 3 天', + submitPlain: '注册', + submitting: '注册中…', + netErr: '网络异常,请检查网络后重试', + emailInvalid: '请输入正确的邮箱地址', + formIncomplete: '请填写完整的验证码与密码(密码至少 8 位)', + successTitle: '注册成功', + successSubInvite: '3 天已到账,用刚注册的邮箱登录穿山甲即可查收。', + successSubPlain: '请下载穿山甲,用刚注册的邮箱登录。', + dlMac: '下载 macOS 版', + dlWin: '下载 Windows 版', + dlAndroid: '下载 Android 版', + dlGeneric: '前往下载', + backHome: '返回首页', + redirecting: (n) => `${n} 秒后自动返回首页`, + loggedInTitle: '你已登录', + loggedInSub: '无需重复注册,直接返回首页或前往用户中心即可。', + loggedInHome: '返回首页', + loggedInCenter: '打开用户中心', + }, + en: { + eyebrow: 'Register', + titleInvite: 'Sign up — you and your friend each get +3 days', + titlePlain: 'Create your Pangolin account', + sub: 'Just an email to start. Free to join, no payment details.', + emailLabel: 'Email', + emailPh: 'Your email', + sendCode: 'Send code', + resend: 'Resend', + resendIn: (n) => `Resend in ${n}s`, + sending: 'Sending…', + codeLabel: 'Verification code', + codePh: '6-digit code', + pwLabel: 'Set a password', + pwPh: 'At least 8 characters', + pwHint: 'Password must be at least 8 characters', + submitInvite: 'Register & claim +3 days', + submitPlain: 'Register', + submitting: 'Registering…', + netErr: 'Network error, please check your connection and retry', + emailInvalid: 'Please enter a valid email address', + formIncomplete: 'Please fill in the code and a password (at least 8 characters)', + successTitle: 'Registration complete', + successSubInvite: 'Your +3 days are credited. Sign in to the app with this email to see them.', + successSubPlain: 'Download Pangolin and sign in with this email.', + dlMac: 'Download for macOS', + dlWin: 'Download for Windows', + dlAndroid: 'Download for Android', + dlGeneric: 'Go to downloads', + backHome: 'Back to home', + redirecting: (n) => `Returning to home in ${n}s`, + loggedInTitle: "You're already signed in", + loggedInSub: 'No need to register again — head back home or open the account center.', + loggedInHome: 'Back to home', + loggedInCenter: 'Open account center', + }, + ja: { + eyebrow: '登録', + titleInvite: '登録して、あなたと友達に3日間ずつ付与', + titlePlain: 'Pangolinアカウントを作成', + sub: '必要なのはメールアドレスだけ。登録は無料、支払い情報は不要です。', + emailLabel: 'メールアドレス', + emailPh: 'メールアドレスを入力', + sendCode: 'コードを送信', + resend: '再送信', + resendIn: (n) => `${n}秒後に再送信可能`, + sending: '送信中…', + codeLabel: '確認コード', + codePh: '6桁のコード', + pwLabel: 'パスワードを設定', + pwPh: '8文字以上', + pwHint: 'パスワードは8文字以上で入力してください', + submitInvite: '登録して+3日間を受け取る', + submitPlain: '登録', + submitting: '登録中…', + netErr: 'ネットワークエラーです。接続を確認して再試行してください', + emailInvalid: '正しいメールアドレスを入力してください', + formIncomplete: '確認コードとパスワード(8文字以上)を入力してください', + successTitle: '登録完了', + successSubInvite: '+3日間が付与されました。このメールアドレスでアプリにログインしてご確認ください。', + successSubPlain: 'Pangolinをダウンロードし、このメールアドレスでログインしてください。', + dlMac: 'macOS版をダウンロード', + dlWin: 'Windows版をダウンロード', + dlAndroid: 'Android版をダウンロード', + dlGeneric: 'ダウンロードページへ', + backHome: 'ホームに戻る', + redirecting: (n) => `${n}秒後にホームへ自動的に戻ります`, + loggedInTitle: 'すでにログイン済みです', + loggedInSub: '再登録の必要はありません。ホームまたはアカウントセンターへどうぞ。', + loggedInHome: 'ホームに戻る', + loggedInCenter: 'アカウントセンターを開く', + }, + ko: { + eyebrow: '가입', + titleInvite: '가입하고 나와 친구 모두 +3일 받기', + titlePlain: 'Pangolin 계정 만들기', + sub: '이메일 하나로 시작. 가입은 무료이며 결제 정보가 필요 없습니다.', + emailLabel: '이메일', + emailPh: '이메일 주소', + sendCode: '코드 전송', + resend: '재전송', + resendIn: (n) => `${n}초 후 재전송 가능`, + sending: '전송 중…', + codeLabel: '인증 코드', + codePh: '6자리 코드', + pwLabel: '비밀번호 설정', + pwPh: '8자 이상', + pwHint: '비밀번호는 8자 이상이어야 합니다', + submitInvite: '가입하고 +3일 받기', + submitPlain: '가입', + submitting: '가입 중…', + netErr: '네트워크 오류입니다. 연결을 확인한 후 다시 시도해 주세요', + emailInvalid: '올바른 이메일 주소를 입력해 주세요', + formIncomplete: '인증 코드와 비밀번호(8자 이상)를 모두 입력해 주세요', + successTitle: '가입 완료', + successSubInvite: '+3일이 적립되었습니다. 이 이메일로 앱에 로그인해 확인하세요.', + successSubPlain: 'Pangolin을 다운로드하고 이 이메일로 로그인하세요.', + dlMac: 'macOS용 다운로드', + dlWin: 'Windows용 다운로드', + dlAndroid: 'Android용 다운로드', + dlGeneric: '다운로드 페이지로', + backHome: '홈으로 돌아가기', + redirecting: (n) => `${n}초 후 홈으로 자동 이동합니다`, + loggedInTitle: '이미 로그인되어 있습니다', + loggedInSub: '다시 가입할 필요 없이 홈으로 가거나 계정 센터를 열어보세요.', + loggedInHome: '홈으로 돌아가기', + loggedInCenter: '계정 센터 열기', + }, + ru: { + eyebrow: 'Регистрация', + titleInvite: 'Зарегистрируйтесь — вы и друг получите по +3 дня', + titlePlain: 'Создайте аккаунт Pangolin', + sub: 'Для начала нужна только почта. Регистрация бесплатна, платёжные данные не требуются.', + emailLabel: 'Эл. почта', + emailPh: 'Ваша почта', + sendCode: 'Отправить код', + resend: 'Отправить снова', + resendIn: (n) => `Повторно через ${n} с`, + sending: 'Отправка…', + codeLabel: 'Код подтверждения', + codePh: '6-значный код', + pwLabel: 'Задайте пароль', + pwPh: 'Минимум 8 символов', + pwHint: 'Пароль должен содержать не менее 8 символов', + submitInvite: 'Зарегистрироваться и получить +3 дня', + submitPlain: 'Зарегистрироваться', + submitting: 'Регистрация…', + netErr: 'Ошибка сети, проверьте соединение и попробуйте снова', + emailInvalid: 'Введите корректный адрес электронной почты', + formIncomplete: 'Заполните код подтверждения и пароль (минимум 8 символов)', + successTitle: 'Регистрация завершена', + successSubInvite: 'Ваши +3 дня зачислены. Войдите в приложение с этой почтой, чтобы увидеть их.', + successSubPlain: 'Скачайте Pangolin и войдите с этой почтой.', + dlMac: 'Скачать для macOS', + dlWin: 'Скачать для Windows', + dlAndroid: 'Скачать для Android', + dlGeneric: 'Перейти к загрузкам', + backHome: 'На главную', + redirecting: (n) => `Возврат на главную через ${n} с`, + loggedInTitle: 'Вы уже вошли в аккаунт', + loggedInSub: 'Повторная регистрация не нужна — вернитесь на главную или откройте личный кабинет.', + loggedInHome: 'На главную', + loggedInCenter: 'Открыть личный кабинет', + }, + es: { + eyebrow: 'Registro', + titleInvite: 'Regístrate: tú y tu amigo ganan +3 días cada uno', + titlePlain: 'Crea tu cuenta de Pangolin', + sub: 'Solo necesitas un correo para empezar. Registro gratis, sin datos de pago.', + emailLabel: 'Correo', + emailPh: 'Tu correo', + sendCode: 'Enviar código', + resend: 'Reenviar', + resendIn: (n) => `Reenviar en ${n}s`, + sending: 'Enviando…', + codeLabel: 'Código de verificación', + codePh: 'Código de 6 dígitos', + pwLabel: 'Crea una contraseña', + pwPh: 'Al menos 8 caracteres', + pwHint: 'La contraseña debe tener al menos 8 caracteres', + submitInvite: 'Regístrate y reclama +3 días', + submitPlain: 'Registrarse', + submitting: 'Registrando…', + netErr: 'Error de red, comprueba tu conexión e inténtalo de nuevo', + emailInvalid: 'Introduce una dirección de correo válida', + formIncomplete: 'Completa el código y una contraseña (al menos 8 caracteres)', + successTitle: 'Registro completado', + successSubInvite: 'Tus +3 días ya están acreditados. Inicia sesión en la app con este correo para verlos.', + successSubPlain: 'Descarga Pangolin e inicia sesión con este correo.', + dlMac: 'Descargar para macOS', + dlWin: 'Descargar para Windows', + dlAndroid: 'Descargar para Android', + dlGeneric: 'Ir a descargas', + backHome: 'Volver al inicio', + redirecting: (n) => `Volviendo al inicio en ${n}s`, + loggedInTitle: 'Ya tienes sesión iniciada', + loggedInSub: 'No necesitas registrarte de nuevo: vuelve al inicio o abre tu cuenta.', + loggedInHome: 'Volver al inicio', + loggedInCenter: 'Abrir mi cuenta', + }, +}; + +function detectPlatform() { + if (typeof navigator === 'undefined') return 'unknown'; + const ua = navigator.userAgent || ''; + const platform = navigator.platform || ''; + // iPadOS 13+ 把 UA 伪装成 Mac,靠触点数区分(真 Mac 无多点触控)。 + const isIOS = /iPhone|iPad|iPod/.test(ua) || (platform === 'MacIntel' && navigator.maxTouchPoints > 1); + if (isIOS) return 'ios'; + if (/Android/.test(ua)) return 'android'; + if (/Win(dows)?|Win32|Win64/.test(ua) || /Win32|Win64/.test(platform)) return 'windows'; + if (/Mac/.test(platform) || /Macintosh/.test(ua)) return 'macos'; + return 'unknown'; +} + +async function callApi(path, body) { + let res; + try { + res = await fetch(API_BASE + path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } catch { + return { ok: false, network: true }; + } + let data = null; + try { + const text = await res.text(); + if (text) data = JSON.parse(text); + } catch { + data = null; + } + if (!res.ok) return { ok: false, network: false, error: data }; + return { ok: true, data }; +} + +export default function RegisterCard({ lang = 'en' }) { + const [loggedIn, setLoggedIn] = useState(false); + const [checkedAuth, setCheckedAuth] = useState(false); + + const [email, setEmail] = useState(''); + const [invite, setInvite] = useState(null); // 规范化后的邀请码,或 null + + const [code, setCode] = useState(''); + const [password, setPassword] = useState(''); + const [pwTouched, setPwTouched] = useState(false); + + const [codeSent, setCodeSent] = useState(false); + const [sendingCode, setSendingCode] = useState(false); + const [codeErr, setCodeErr] = useState(''); + const [cooldown, setCooldown] = useState(0); + const cooldownRef = useRef(null); + + const [submitting, setSubmitting] = useState(false); + const [regErr, setRegErr] = useState(''); + const [success, setSuccess] = useState(false); + const [redirectIn, setRedirectIn] = useState(REDIRECT_SECS); + const redirectRef = useRef(null); + const codeInputRef = useRef(null); + // 卸载守卫:onSendCode/onRegister 里 await callApi 期间组件若已卸载(用户导航离开), + // 之后的 setState 直接跳过——避免 unmounted 组件 setState 的告警/竞态。 + const mountedRef = useRef(true); + + const t = COPY[lang] || COPY.en; + // 后端错误字段只有 message_zh/message_en 两语;非中文一律取英文兜底(见组件顶部注释)。 + const msgKey = lang === 'zh' ? 'message_zh' : 'message_en'; + const homeHref = langPath(lang); + const dlAnchorHref = `${homeHref}#download`; + + // 读 query 参数(?email=&invite=) + 登录态;均只在挂载时读一次。 + useEffect(() => { + try { + const qs = new URLSearchParams(window.location.search); + const qEmail = qs.get('email'); + if (qEmail) setEmail(qEmail); + setInvite(normalizeInviteCode(qs.get('invite'))); + } catch { + /* ignore malformed query */ + } + setLoggedIn(isLoggedIn()); + setCheckedAuth(true); + }, []); + + useEffect(() => () => clearInterval(cooldownRef.current), []); + useEffect(() => () => clearInterval(redirectRef.current), []); + useEffect(() => () => { mountedRef.current = false; }, []); + + useEffect(() => { + if (codeSent && codeInputRef.current) codeInputRef.current.focus(); + }, [codeSent]); + + // 成功后自动倒计时返回首页(点「返回首页」等价立即触发)。 + useEffect(() => { + if (!success) return undefined; + setRedirectIn(REDIRECT_SECS); + redirectRef.current = setInterval(() => { + setRedirectIn((s) => { + if (s <= 1) { + clearInterval(redirectRef.current); + window.location.href = homeHref; + return 0; + } + return s - 1; + }); + }, 1000); + return () => clearInterval(redirectRef.current); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [success]); + + function startCooldown() { + setCooldown(RESEND_SECS); + clearInterval(cooldownRef.current); + cooldownRef.current = setInterval(() => { + setCooldown((s) => { + if (s <= 1) { + clearInterval(cooldownRef.current); + return 0; + } + return s - 1; + }); + }, 1000); + } + + async function onSendCode(e) { + e.preventDefault(); + setCodeErr(''); + const trimmed = email.trim(); + if (!/\S+@\S+\.\S+/.test(trimmed)) { + setCodeErr(t.emailInvalid); + return; + } + setSendingCode(true); + const r = await callApi('/v1/auth/code', { email: trimmed }); + if (!mountedRef.current) return; + setSendingCode(false); + if (!r.ok) { + setCodeErr(r.network ? t.netErr : (r.error && r.error[msgKey]) || t.netErr); + return; + } + setCodeSent(true); + startCooldown(); + } + + async function onRegister(e) { + e.preventDefault(); + setRegErr(''); + const trimmedEmail = email.trim(); + if (!/\S+@\S+\.\S+/.test(trimmedEmail) || code.length !== 6 || password.length < 8) { + setPwTouched(true); + setRegErr(t.formIncomplete); + return; + } + setSubmitting(true); + const body = { email: trimmedEmail, code, password }; + if (invite) body.invite_code = invite; + const r = await callApi('/v1/auth/register', body); + if (!mountedRef.current) return; + setSubmitting(false); + if (!r.ok) { + setRegErr(r.network ? t.netErr : (r.error && r.error[msgKey]) || t.netErr); + return; + } + setSuccess(true); + } + + const plat = useMemo(() => detectPlatform(), []); + const dl = useMemo(() => { + if (plat === 'android') return { href: SITE.downloads.android, label: t.dlAndroid }; + if (plat === 'windows') return { href: SITE.downloads.windows, label: t.dlWin }; + if (plat === 'macos') return { href: SITE.downloads.macos, label: t.dlMac }; + return { href: dlAnchorHref, label: t.dlGeneric }; + }, [plat, t, dlAnchorHref]); + + const pwInvalid = pwTouched && password.length > 0 && password.length < 8; + const canSubmit = /\S+@\S+\.\S+/.test(email.trim()) && code.length === 6 && password.length >= 8 && !submitting; + + // 登录态检测前先不下结论(避免闪一下已登录态又变表单);未挂载完成时按未登录渲染, + // 与「无 JS 时默认展示注册表单」的渐进增强保持一致。 + if (checkedAuth && loggedIn) { + return ( +
+
+
+
+ +

{t.loggedInTitle}

+

{t.loggedInSub}

+ +
+
+
+
+ ); + } + + if (success) { + return ( +
+
+
+
+ +

{t.successTitle}

+

{invite ? t.successSubInvite : t.successSubPlain}

+ + + {dl.label} + + + {t.backHome} +
{t.redirecting(redirectIn)}
+
+
+
+
+ ); + } + + return ( +
+
+
+
{t.eyebrow}
+

{invite ? t.titleInvite : t.titlePlain}

+

{t.sub}

+ +
+
+
{t.emailLabel}
+
+
+ + setEmail(e.currentTarget.value)} + placeholder={t.emailPh} + autoComplete="email" + /> +
+ {!codeSent && ( + + )} +
+
+ + {codeErr && ( +
+ + {codeErr} +
+ )} + + {codeSent && ( + <> +
+
{t.codeLabel}
+
+
+ + setCode(e.currentTarget.value.replace(/\D/g, '').slice(0, 6))} + placeholder={t.codePh} + /> +
+ +
+
+ +
+
{t.pwLabel}
+
+ + setPassword(e.currentTarget.value)} + onBlur={() => setPwTouched(true)} + placeholder={t.pwPh} + autoComplete="new-password" + /> +
+ {pwInvalid &&
{t.pwHint}
} +
+ + {regErr && ( +
+ + {regErr} +
+ )} + + + + )} +
+
+
+
+ ); +} diff --git a/web/website/src/components/SignupForm.jsx b/web/website/src/components/SignupForm.jsx index 3e0b6d2..124511e 100644 --- a/web/website/src/components/SignupForm.jsx +++ b/web/website/src/components/SignupForm.jsx @@ -1,38 +1,56 @@ /** - * SignupForm.jsx — Hero 内联邮箱注册(演示,React island)。迁自原型 .signup + site.js __signup。 - * 注意(铁律 10):这不是支付表单;仅演示「发送验证码」交互,真实实现由后端 auth 接管。 + * SignupForm.jsx — Hero 内联邮箱入口(React island)。迁自原型 .signup + site.js __signup。 + * + * 不在这里发验证码 / 建账户:提交只是把邮箱透传给**本语言**的独立注册页 + * `/register`(?email= 预填),真正的「验证码 + 设密码」注册流程由 RegisterCard.jsx + * 承接(见该组件顶部注释)。空邮箱也允许跳转——register 页留空即可,用户到那边再填。 + * 路径拼接走 lib/langPath.js,与 Header 语言切换、InviteCard/RegisterCard 同一实现。 + * + * 已登录(同源 localStorage 里有 pg_uc_refresh,见 lib/authState.js)时,表单换成 + * 「打开用户中心」按钮:老用户不需要再看到注册入口。无 JS 时默认展示表单(渐进增强)。 */ -import { useState } from 'react'; -import { Mail, CheckCircle, Download } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { Mail, Download } from 'lucide-react'; +import { SITE } from '../config/site'; +import { isLoggedIn } from '../lib/authState'; +import { langPath } from '../lib/langPath'; -export default function SignupForm({ ph, btn, ok, hint, dl }) { - const [done, setDone] = useState(false); +export default function SignupForm({ lang = 'en', ph, btn, hint, dl, centerLabel }) { + const [loggedIn, setLoggedIn] = useState(false); + + useEffect(() => { + setLoggedIn(isLoggedIn()); + }, []); const onSubmit = (e) => { e.preventDefault(); - const email = e.currentTarget.elements['su-email']; - if (!/\S+@\S+\.\S+/.test(email.value)) { - email.focus(); - return; - } - setDone(true); + const emailEl = e.currentTarget.elements['su-email']; + const email = (emailEl && emailEl.value ? emailEl.value : '').trim(); + const qs = email ? `?email=${encodeURIComponent(email)}` : ''; + window.location.href = `${langPath(lang, 'register')}${qs}`; }; + if (loggedIn) { + return ( + + ); + } + return (