迁移 Flutter 端与训练脚本,模型/训练产物移出 git(遵循纯代码约定)

This commit is contained in:
2026-08-24 12:35:24 +08:00
parent d056f01965
commit 961523d94c
218 changed files with 13391 additions and 3232 deletions
@@ -0,0 +1,79 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../auth/session_store.dart';
import 'models.dart';
import 'order_api.dart';
/// 授权管理:本地缓存用于主界面展示到期时间;识别入口强制服务端校验。
/// 缓存按手机号隔离(键含账号),切换账号不会读到上一账号的到期时间。
class LicenseService {
static const _storage = FlutterSecureStorage();
final OrderApi orderApi;
final SessionStore sessionStore;
LicenseService({required this.orderApi, required this.sessionStore});
/// 主界面展示/启动加载:先读本账号本地缓存,有效则直接用;
/// 无效或缺失时询问服务端(网络失败按缓存兜底,未登录按过期处理)。
Future<LicenseStatus> check() async {
final phone = await sessionStore.readPhone();
if (phone == null) return const LicenseStatus(active: false);
final cached = await _readCache(phone);
final now = DateTime.now();
if (cached != null && cached.expiresAt!.isAfter(now)) return cached;
if (await sessionStore.readToken() == null) {
return const LicenseStatus(active: false);
}
try {
final remote = await orderApi.fetchLicense();
try {
if (remote.active && remote.expiresAt != null) {
await _writeCache(phone, remote.expiresAt!);
} else if (!remote.active) {
await _clearCache(phone);
}
} catch (_) {
// 本地缓存不可用不影响授权状态展示
}
return remote;
} on OrderApiException {
return cached ?? const LicenseStatus(active: false);
}
}
/// 识别入口强制校验:必须走服务端且 active 才放行,失败即抛错(不进相机)
Future<LicenseStatus> verifyServer() async {
if (await sessionStore.readToken() == null) {
throw const SessionExpiredException();
}
return orderApi.fetchLicense();
}
/// 支付成功后立即刷新(清缓存强制走服务端,避免旧授权干扰)
Future<LicenseStatus> refresh() async {
final phone = await sessionStore.readPhone();
if (phone != null) await _clearCache(phone);
return orderApi.fetchLicense();
}
Future<LicenseStatus?> _readCache(String phone) async {
try {
final raw = await _storage.read(key: _cacheKey(phone));
final t = raw == null ? null : DateTime.tryParse(raw);
if (t == null || !t.isAfter(DateTime.now())) return null;
return LicenseStatus(active: true, expiresAt: t);
} catch (_) {
return null;
}
}
Future<void> _writeCache(String phone, DateTime expiresAt) =>
_storage.write(key: _cacheKey(phone), value: expiresAt.toIso8601String());
Future<void> _clearCache(String phone) => _storage.delete(key: _cacheKey(phone));
static String _cacheKey(String phone) => 'license_expires_at:$phone';
}
+67
View File
@@ -0,0 +1,67 @@
/// 充值套餐(来自后端 GET /api/v1/plans,价格以服务端 config.yml 为准)
class Plan {
final String id;
final int days;
final int priceYuan;
const Plan({
required this.id,
required this.days,
required this.priceYuan,
});
/// 展示名由天数派生(接口无 label 字段)
String get label => '$days天';
factory Plan.fromJson(Map<String, dynamic> json) => Plan(
id: json['planId'] as String,
days: json['days'] as int,
priceYuan: (json['priceCents'] as num) ~/ 100,
);
}
/// 支付渠道
enum PayChannel { wechat, alipay }
/// 订单(由后端创建)
class Order {
final String orderId;
/// 微信支付参数(prepay_id / partner_id / nonce_str / time_stamp / sign 等)
final Map<String, dynamic> wechatParams;
/// 支付宝订单串(orderStr
final String? alipayOrderStr;
const Order({
required this.orderId,
this.wechatParams = const {},
this.alipayOrderStr,
});
}
/// 授权状态(服务端为准)
class LicenseStatus {
final bool active;
final DateTime? expiresAt;
const LicenseStatus({required this.active, this.expiresAt});
bool get isActive => active && (expiresAt?.isAfter(DateTime.now()) ?? false);
factory LicenseStatus.fromJson(Map<String, dynamic> json) => LicenseStatus(
active: json['active'] == true,
expiresAt: json['expiresAt'] != null
? DateTime.tryParse(json['expiresAt'] as String)
: null,
);
}
/// 支付结果
class PayResult {
final bool success;
final String? message;
final String? orderId;
const PayResult({required this.success, this.message, this.orderId});
}
+170
View File
@@ -0,0 +1,170 @@
import 'dart:convert';
import 'dart:io' show Platform;
import 'package:cupertino_http/cupertino_http.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:http/http.dart' as http;
import '../auth/session_store.dart';
import '../config/app_config.dart';
import 'models.dart';
/// 后端账号/支付/授权 API 客户端(契约见 docs/PaymentApi.md)。
/// 后端未部署或请求失败时抛出 [OrderApiException];登录失效抛出 [SessionExpiredException]
/// 由 UI 层回登录页。
class OrderApiException implements Exception {
final String message;
const OrderApiException(this.message);
@override
String toString() => message;
}
/// 登录已失效(后端返回 code 61):token 过期/被清,UI 应清除会话回登录页
class SessionExpiredException extends OrderApiException {
const SessionExpiredException() : super('登录已失效,请重新登录');
}
class OrderApi {
final String baseUrl;
final SessionStore sessionStore;
final http.Client _client;
// iOS 26 对 dart:io 原生 socket 访问本地网络存在拦截 bug(权限已允许仍
// 拒绝连接),改用 NSURLSession 网络栈(CupertinoClient)绕过;非 Apple
// 平台回退 IOClient。
OrderApi({String? baseUrl, required this.sessionStore, http.Client? client})
: baseUrl = baseUrl ?? AppConfig.apiBaseUrl,
_client = client ?? _defaultHttpClient();
static http.Client _defaultHttpClient() {
if (!kIsWeb && Platform.isIOS) {
return CupertinoClient.defaultSessionConfiguration();
}
return http.Client();
}
/// 注册账号;重复注册等业务错误由 [_decode] 抛出
Future<void> register({
required String phone,
required String password,
}) async {
final res = await _post('/api/v1/auth/register',
jsonEncode({'phone': phone, 'password': password}),
auth: false);
_decode(res);
}
/// 登录,成功返回 token(由调用方存入 SessionStore
Future<String> login({
required String phone,
required String password,
}) async {
final res = await _post('/api/v1/auth/login',
jsonEncode({'phone': phone, 'password': password}),
auth: false);
final json = _decode(res);
return json['token'] as String;
}
/// 创建订单,返回支付参数(微信 prepay 参数或支付宝 orderStr
Future<Order> createOrder({
required String planId,
required PayChannel channel,
}) async {
final body = jsonEncode({'planId': planId, 'channel': channel.name});
final res = await _post('/api/v1/orders', body);
final json = _decode(res);
final params = (json['payParams'] as Map?)?.cast<String, dynamic>() ?? {};
return Order(
orderId: json['orderId'] as String,
wechatParams: params,
alipayOrderStr: params['orderStr'] as String?,
);
}
/// 客户端支付完成后通知服务端(幂等),服务端据异步回调落授权
Future<void> confirmOrder(String orderId) async {
final res =
await _post('/api/v1/orders/$orderId/confirm', jsonEncode({}));
_decode(res);
}
/// 拉取套餐价格方案(config.yml 静态定价,客户端不硬编码)
Future<List<Plan>> fetchPlans() async {
final http.Response res;
try {
res = await _client.get(
Uri.parse('$baseUrl/api/v1/plans'),
headers: {'Accept': 'application/json', ...await _authHeaders()},
).timeout(const Duration(seconds: 15));
} catch (e) {
if (e is OrderApiException) rethrow;
throw OrderApiException('网络请求失败: $e');
}
final json = _decode(res);
return (json['list'] as List)
.map((e) => Plan.fromJson(e as Map<String, dynamic>))
.toList();
}
/// 查询授权状态(服务端为准)
Future<LicenseStatus> fetchLicense() async {
final http.Response res;
try {
res = await _client.get(
Uri.parse('$baseUrl/api/v1/license'),
headers: {'Accept': 'application/json', ...await _authHeaders()},
).timeout(const Duration(seconds: 15));
} catch (e) {
if (e is OrderApiException) rethrow;
throw OrderApiException('网络请求失败: $e');
}
final json = _decode(res);
return LicenseStatus.fromJson(json);
}
Future<http.Response> _post(String path, String body,
{bool auth = true}) async {
try {
return await _client
.post(
Uri.parse('$baseUrl$path'),
headers: {
'Content-Type': 'application/json',
...auth ? await _authHeaders() : const <String, String>{},
},
body: body,
)
.timeout(const Duration(seconds: 15));
} catch (e) {
if (e is OrderApiException) rethrow;
throw OrderApiException('网络请求失败: $e');
}
}
Future<Map<String, String>> _authHeaders() async {
final token = await sessionStore.readToken();
if (token == null || token.isEmpty) {
throw const SessionExpiredException();
}
return {'Authorization': 'Bearer $token'};
}
Map<String, dynamic> _decode(http.Response res) {
final Map<String, dynamic> json;
try {
json = jsonDecode(res.body) as Map<String, dynamic>;
} catch (_) {
throw OrderApiException('服务端响应异常 (${res.statusCode})');
}
if (res.statusCode != 200 || json['code'] != 0) {
final message = json['message'] as String? ?? '服务端错误 (${res.statusCode})';
if (json['code'] == 61) {
throw const SessionExpiredException();
}
throw OrderApiException(message);
}
return json['data'] as Map<String, dynamic>;
}
}
@@ -0,0 +1,108 @@
import 'dart:async';
import 'package:fluwx/fluwx.dart' as fluwx;
import 'package:tobias/tobias.dart' as tobias;
import '../config/app_config.dart';
import 'models.dart';
import 'order_api.dart';
/// 支付服务抽象:创建订单 + 拉起渠道支付 + 返回支付结果
abstract class PaymentService {
/// 支付完成后会通知服务端落授权;成功与否以 [PayResult.success] 为准
Future<PayResult> pay(Plan plan, PayChannel channel);
}
/// 微信支付(fluwx 实现)
class WechatPayService implements PaymentService {
WechatPayService({required this.orderApi});
final OrderApi orderApi;
@override
Future<PayResult> pay(Plan plan, PayChannel channel) async {
assert(channel == PayChannel.wechat);
final order =
await orderApi.createOrder(planId: plan.id, channel: channel);
final p = order.wechatParams;
final launched = await fluwx.Fluwx().pay(
which: fluwx.Payment(
appId: AppConfig.wechatAppId,
partnerId: p['partnerId'] as String? ?? '',
prepayId: p['prepayId'] as String? ?? '',
packageValue: p['packageValue'] as String? ?? 'Sign=WXPay',
nonceStr: p['nonceStr'] as String? ?? '',
timestamp: int.tryParse('${p['timeStamp'] ?? p['timestamp']}') ?? 0,
sign: p['sign'] as String? ?? '',
),
);
if (!launched) {
return const PayResult(
success: false, message: '未安装微信或拉起支付失败,请稍后再试');
}
// 等待微信回调(errCode == 0 为成功),10s 超时
final completer = Completer<PayResult>();
final cancel = fluwx.Fluwx().addSubscriber((response) {
if (response is! fluwx.WeChatPaymentResponse) return;
completer.complete(PayResult(
success: response.isSuccessful,
message: response.isSuccessful ? null : (response.errStr ?? '微信支付未完成'),
orderId: order.orderId,
));
});
try {
final result =
await completer.future.timeout(const Duration(seconds: 10));
if (result.success) await orderApi.confirmOrder(order.orderId);
return result;
} on TimeoutException {
return PayResult(
success: false,
message: '等待微信支付结果超时,请确认支付状态',
orderId: order.orderId);
} finally {
cancel.cancel();
}
}
}
/// 支付宝支付(tobias 实现,resultStatus 9000 为成功)
class AlipayService implements PaymentService {
AlipayService({required this.orderApi});
final OrderApi orderApi;
@override
Future<PayResult> pay(Plan plan, PayChannel channel) async {
assert(channel == PayChannel.alipay);
final order =
await orderApi.createOrder(planId: plan.id, channel: channel);
final orderStr = order.alipayOrderStr;
if (orderStr == null) {
return const PayResult(success: false, message: '订单缺少支付参数');
}
final map = await tobias.Tobias().pay(
orderStr,
universalLink: AppConfig.alipayUniversalLink,
);
final status = map['resultStatus']?.toString() ?? '';
final ok = status == '9000';
if (ok) await orderApi.confirmOrder(order.orderId);
return PayResult(
success: ok,
message: ok ? null : _alipayMessage(status),
orderId: order.orderId,
);
}
static String _alipayMessage(String status) => switch (status) {
'8000' => '支付结果确认中,请稍后查看',
'6001' => '用户取消支付',
'6002' => '网络异常,支付未完成',
'6004' => '支付结果未知,请查询订单状态',
_ => '支付宝支付未完成',
};
}
+221
View File
@@ -0,0 +1,221 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'models.dart';
import 'paywall_view_model.dart';
/// 付费墙:拉取套餐价格 → 选套餐 → 微信/支付宝支付 → 解锁进入相机
class PaywallScreen extends StatefulWidget {
const PaywallScreen({super.key});
@override
State<PaywallScreen> createState() => _PaywallScreenState();
}
class _PaywallScreenState extends State<PaywallScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<PaywallViewModel>().loadPlans();
});
}
@override
Widget build(BuildContext context) {
final vm = context.watch<PaywallViewModel>();
final loading = vm.state.state == PayState.loading;
return Scaffold(
appBar: AppBar(title: const Text('视野 · 会员'), centerTitle: true),
body: SafeArea(
child: Column(
children: [
const Padding(
padding: EdgeInsets.fromLTRB(24, 16, 24, 16),
child: Text(
'开通会员解锁完整功能,按自然日计费,到期自动失效',
style: TextStyle(color: Colors.grey, fontSize: 13),
),
),
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 24),
children: [
_PlansSection(vm: vm, loading: loading),
const SizedBox(height: 24),
_PayButton(
label: '微信支付',
icon: Icons.wechat,
color: const Color(0xFF07C160),
enabled: !loading && vm.state.selectedPlan != null,
onTap: loading
? null
: () async {
final license = await vm.pay(PayChannel.wechat);
if (license != null && context.mounted) {
_onPaid(context);
}
},
),
const SizedBox(height: 12),
_PayButton(
label: '支付宝支付',
icon: Icons.account_balance_wallet,
color: const Color(0xFF1677FF),
enabled: !loading && vm.state.selectedPlan != null,
onTap: loading
? null
: () async {
final license = await vm.pay(PayChannel.alipay);
if (license != null && context.mounted) {
_onPaid(context);
}
},
),
if (vm.state.state == PayState.failed)
Padding(
padding: const EdgeInsets.only(top: 16),
child: Text(
vm.state.message ?? '支付失败',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.red),
),
),
const SizedBox(height: 24),
],
),
),
],
),
),
);
}
/// 支付成功:返回主界面(主界面刷新展示新到期时间)
void _onPaid(BuildContext context) {
Navigator.of(context).pop();
}
}
/// 套餐区三态:加载中 / 失败可重试 / 套餐卡片列表
class _PlansSection extends StatelessWidget {
final PaywallViewModel vm;
final bool loading;
const _PlansSection({required this.vm, required this.loading});
@override
Widget build(BuildContext context) {
if (vm.plansLoading && vm.plans.isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 48),
child: Center(child: CircularProgressIndicator()),
);
}
if (vm.plansError != null && vm.plans.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Column(
children: [
Text(
vm.plansError!,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.red),
),
const SizedBox(height: 12),
OutlinedButton(
onPressed: () => context.read<PaywallViewModel>().loadPlans(),
child: const Text('重试'),
),
],
),
);
}
return Column(
children: [
...vm.plans.map((p) => _PlanCard(
plan: p,
selected: vm.state.selectedPlan?.id == p.id,
enabled: !loading,
onTap: () => vm.selectPlan(p),
)),
],
);
}
}
class _PlanCard extends StatelessWidget {
final Plan plan;
final bool selected;
final bool enabled;
final VoidCallback onTap;
const _PlanCard({
required this.plan,
required this.selected,
required this.enabled,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Card(
elevation: selected ? 3 : 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: selected ? Colors.orange : Colors.grey.shade300,
width: selected ? 2 : 1,
),
),
child: ListTile(
onTap: enabled ? onTap : null,
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
title: Text(
plan.label,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 17),
),
subtitle: Text('${plan.days} 天 · 自然日'),
trailing: Text(
'¥${plan.priceYuan}',
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
),
);
}
}
class _PayButton extends StatelessWidget {
final String label;
final IconData icon;
final Color color;
final bool enabled;
final VoidCallback? onTap;
const _PayButton({
required this.label,
required this.icon,
required this.color,
required this.enabled,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
height: 48,
child: FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: color,
disabledBackgroundColor: Colors.grey.shade300,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
onPressed: enabled ? onTap : null,
icon: Icon(icon, size: 20),
label: Text(label, style: const TextStyle(fontSize: 16)),
),
);
}
}
@@ -0,0 +1,120 @@
import 'package:flutter/foundation.dart';
import 'license_service.dart';
import 'models.dart';
import 'order_api.dart';
import 'payment_service.dart';
enum PayState { idle, loading, success, failed }
@immutable
class PaywallUiState {
final PayState state;
final String? message;
final Plan? selectedPlan;
const PaywallUiState({
this.state = PayState.idle,
this.message,
this.selectedPlan,
});
}
class PaywallViewModel extends ChangeNotifier {
final OrderApi orderApi;
final LicenseService licenseService;
final Map<PayChannel, PaymentService> services;
PaywallUiState _state = const PaywallUiState();
PaywallUiState get state => _state;
List<Plan> _plans = const [];
bool _plansLoading = false;
String? _plansError;
/// 套餐价格方案(来自后端,客户端不硬编码)
List<Plan> get plans => _plans;
bool get plansLoading => _plansLoading;
String? get plansError => _plansError;
PaywallViewModel({
required this.orderApi,
required this.licenseService,
required this.services,
});
/// 进充值页时拉取套餐;已有数据或加载中跳过,失败可重试
Future<void> loadPlans() async {
if (_plans.isNotEmpty || _plansLoading) return;
_plansLoading = true;
_plansError = null;
notifyListeners();
try {
_plans = await orderApi.fetchPlans();
} on SessionExpiredException {
_plansError = '登录已失效,请重新登录后再充值';
} on OrderApiException catch (e) {
_plansError = e.message;
} finally {
_plansLoading = false;
notifyListeners();
}
}
void selectPlan(Plan? plan) {
_state = PaywallUiState(selectedPlan: plan);
notifyListeners();
}
/// 发起支付;成功后刷新授权并通知页面进入相机
Future<LicenseStatus?> pay(PayChannel channel) async {
final plan = _state.selectedPlan;
if (plan == null) return null;
final service = services[channel];
if (service == null) return null;
_state = PaywallUiState(state: PayState.loading, selectedPlan: plan);
notifyListeners();
try {
final result = await service.pay(plan, channel);
if (!result.success) {
_state = PaywallUiState(
state: PayState.failed,
message: result.message ?? '支付失败,请重试',
selectedPlan: plan,
);
notifyListeners();
return null;
}
final license = await licenseService.refresh();
_state = PaywallUiState(
state: PayState.success,
selectedPlan: plan,
);
notifyListeners();
return license;
} on SessionExpiredException {
_state = PaywallUiState(
state: PayState.failed,
message: '登录已失效,请重新登录后再充值',
selectedPlan: plan,
);
notifyListeners();
return null;
} on OrderApiException catch (e) {
_state = PaywallUiState(
state: PayState.failed,
message: '支付不可用: ${e.message}',
selectedPlan: plan,
);
notifyListeners();
return null;
}
}
void reset() {
_state = const PaywallUiState();
notifyListeners();
}
}