Files
observer/flutter_app/lib/payment/order_api.dart
T
2026-09-01 17:58:09 +08:00

171 lines
5.6 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: 30));
} 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: 30));
} 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: 30));
} 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>;
}
}