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

# Conflicts:
#	docs/index.html
#	server/cmd/server/main.go
This commit is contained in:
wangjia
2026-07-11 16:44:05 +08:00
228 changed files with 13418 additions and 1106 deletions
+19 -3
View File
@@ -99,7 +99,23 @@ class AccountApi {
Future<RedeemResult> redeem(String code) async =>
RedeemResult.fromJson(await _c.postJson('/v1/redeem', {'code': code}));
/// POST /v1/ads/unlock — 看广告解锁今日免费额度
Future<void> adUnlock({required String deviceId, required String adToken}) =>
_c.postJson('/v1/ads/unlock', {'device_id': deviceId, 'ad_token': adToken});
/// POST /v1/ads/unlock — 看广告加时(累加式)。返回本次加时分钟与最新剩余分钟
Future<AdUnlockResult> adUnlock({required String deviceId, required String adToken}) async {
final body = await _c.postJson('/v1/ads/unlock', {'device_id': deviceId, 'ad_token': adToken});
return AdUnlockResult(
grantedMinutes: (body['granted_minutes'] as num?)?.toInt() ?? 0,
minutesRemaining: (body['minutes_remaining'] as num?)?.toInt() ?? 0,
);
}
}
/// 看广告加时结果(POST /v1/ads/unlock 响应)。
class AdUnlockResult {
const AdUnlockResult({required this.grantedMinutes, required this.minutesRemaining});
/// 本次广告实际加时分钟(已达每日封顶时为 0)。
final int grantedMinutes;
/// 加时后账户当日剩余分钟(全账户共享)。
final int minutesRemaining;
}
+11 -2
View File
@@ -2,8 +2,17 @@
//
// 历史上各 service/provider 各自重复声明 _kApiUrl;统一收敛到这里,
// 由 --dart-define=PANGOLIN_API_URL 注入。
// TODO(联调临时): 默认值改成测试节点,避免 release 构建漏传 dart-define;发版前改回 localhost 或正式控制面域名
// 控制面 API 基址(单源,全端 providers 共用)。默认走 CF Tunnel 的 https 域名;
// 本地联调可 --dart-define=PANGOLIN_API_URL=http://127.0.0.1:8080 覆盖。
const String kApiBaseUrl = String.fromEnvironment(
'PANGOLIN_API_URL',
defaultValue: 'http://103.119.13.48:8080',
defaultValue: 'https://api.yanmeiai.com',
);
/// 用户中心(网页)基址(单源)。已从独立子域 app.yanmeiai.com 迁到主站子路径
/// pangolin.yanmeiai.com/user/(旧子域已停用)。末尾不带斜杠;调用方自行拼 path。
/// 本地联调可 --dart-define=PANGOLIN_USERCENTER_URL=http://127.0.0.1:3000 覆盖。
const String kWebUserCenterBaseUrl = String.fromEnvironment(
'PANGOLIN_USERCENTER_URL',
defaultValue: 'https://pangolin.yanmeiai.com/user',
);
+22
View File
@@ -121,5 +121,27 @@ class ConnectApi {
return response.body;
}
/// 通知控制面吊销本设备在 [nodeId] 上的数据面凭证(F4)。
///
/// best-effort:断开的本地拆隧道不依赖它,任何失败(网络/401/超时)都吞掉——
/// 凭证最迟到 TTL 也会过期,这里只是让「断开」在服务端即刻生效。
Future<void> disconnect({
required String nodeId,
required String deviceId,
}) async {
try {
await _client
.post(
Uri.parse('$baseUrl/v1/nodes/$nodeId/disconnect'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $authToken',
},
body: jsonEncode({'device_id': deviceId}),
)
.timeout(const Duration(seconds: 5));
} catch (_) {/* best-effort */}
}
void dispose() => _client.close();
}
+27
View File
@@ -0,0 +1,27 @@
// web_launch.dart — App→Web 单点登录跳转(SSO 换票)。
//
// 「用户中心(网页)」入口:先向控制面签一张短时单次票据
// (POST /v1/auth/web-ticket,需登录),再打开 用户中心网页版 的
// /sso?t=<票>&redirect=<路径> 落地页兑票登录——避免用户在网页端重新输入密码。
// 签票失败(未登录/网络异常)时降级为直接打开目标页(未登录态浏览)。
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
import '../state/account_providers.dart';
import 'api_config.dart';
/// 打开用户中心网页版 [path](默认首页),尝试免登录(SSO 换票)。
Future<void> openWebUserCenter(WidgetRef ref, {String path = '/'}) async {
Uri target = Uri.parse('$kWebUserCenterBaseUrl$path');
try {
final resp = await ref.read(apiClientProvider).postJson('/v1/auth/web-ticket');
final ticket = resp['ticket'] as String?;
if (ticket != null && ticket.isNotEmpty) {
target = Uri.parse(
'$kWebUserCenterBaseUrl/sso?t=$ticket&redirect=${Uri.encodeComponent(path)}');
}
} catch (_) {
// 签票失败(未登录/网络异常)降级:直接打开目标页(未登录态浏览)。
}
await launchUrl(target, mode: LaunchMode.externalApplication);
}