171 lines
5.6 KiB
Dart
171 lines
5.6 KiB
Dart
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>;
|
||
}
|
||
}
|