feat(client): 购买模型/仓库切 pay v2 契约(render_type 多态 + 分金额 + 订单列表)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-10 23:34:21 +08:00
parent 45a43c20a4
commit 179051a8fa
6 changed files with 536 additions and 8 deletions
+162
View File
@@ -0,0 +1,162 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:jiu_client/models/license.dart';
void main() {
group('PurchaseOrder.fromJson() — pay v2 契约', () {
test('解析 redirect 形态(render_type/payload/amount_minor', () {
final order = PurchaseOrder.fromJson({
'order_no': 'pay-abc123',
'out_trade_no': 'pay-abc123',
'render_type': 'redirect',
'payload': {'url': 'https://pay.example.com/checkout/abc123'},
'amount_minor': 299900,
'currency': 'CNY',
'subject': '酒库管理系统 - 年度授权',
'pay_url': 'https://pay.example.com/checkout/abc123',
'amount': '2999.00',
});
expect(order.outTradeNo, 'pay-abc123');
expect(order.renderType, 'redirect');
expect(order.amountMinor, 299900);
expect(order.currency, 'CNY');
expect(order.subject, '酒库管理系统 - 年度授权');
expect(order.redirectUrl, 'https://pay.example.com/checkout/abc123');
// 兼容 getterTask 7 前 purchase_card.dart 仍读 payUrl/amount
expect(order.payUrl, order.redirectUrl);
expect(order.amount, '2,999.00');
});
test('解析 qr 形态 payload', () {
final order = PurchaseOrder.fromJson({
'out_trade_no': 'pay-qr1',
'render_type': 'qr',
'payload': {'qr_content': 'weixin://wxpay/bizpayurl?pr=xxx'},
'amount_minor': 100,
'currency': 'CNY',
'subject': '月度授权',
});
expect(order.renderType, 'qr');
expect(order.qrContent, 'weixin://wxpay/bizpayurl?pr=xxx');
expect(order.redirectUrl, '');
});
});
group('PurchaseStatusInfo.fromJson()', () {
test('解析 paid 状态,amount_minor 优先于 amount 串', () {
final status = PurchaseStatusInfo.fromJson({
'out_trade_no': 'pay-abc123',
'status': 'paid',
'product_biz_code': 'annual',
'amount_minor': 299900,
'currency': 'CNY',
'amount': '2999.00',
'paid_at': '2026-07-10T10:00:00Z',
'expires_at': '2027-07-10T00:00:00Z',
});
expect(status.isPaid, isTrue);
expect(status.amountMinor, 299900);
expect(status.currency, 'CNY');
expect(status.displayAmount, '2,999.00');
expect(status.paidAt, isNotNull);
expect(status.expiresAt, isNotNull);
});
test('amount_minor 缺失时回退旧 amount 串', () {
final status = PurchaseStatusInfo.fromJson({
'out_trade_no': 'pay-legacy',
'status': 'pending',
'amount': '99.00',
});
expect(status.amountMinor, 0);
expect(status.displayAmount, '99.00');
});
});
group('PurchaseRecord.fromJson()(订单列表项)', () {
test('解析完整订单列表项', () {
final record = PurchaseRecord.fromJson({
'out_trade_no': 'pay-abc123',
'product_biz_code': 'annual',
'amount_minor': 299900,
'currency': 'CNY',
'amount': '2999.00',
'status': 'paid',
'pay_url': 'https://pay.example.com/checkout/abc123',
'user_name': '张三',
'created_at': '2026-07-01T10:00:00Z',
'paid_at': '2026-07-01T10:05:00Z',
'renewed_to': '2027-07-01T00:00:00Z',
});
expect(record.outTradeNo, 'pay-abc123');
expect(record.productBizCode, 'annual');
expect(record.amountMinor, 299900);
expect(record.displayAmount, '2,999.00');
expect(record.isPaid, isTrue);
expect(record.userName, '张三');
expect(record.createdAt, isNotNull);
expect(record.paidAt, isNotNull);
expect(record.renewedTo, isNotNull);
});
test('缺省可选字段不报错', () {
final record = PurchaseRecord.fromJson({
'out_trade_no': 'pay-pending1',
'product_biz_code': 'monthly',
'amount_minor': 9900,
'currency': 'CNY',
'amount': '99.00',
'status': 'pending',
'pay_url': '',
'user_name': '',
});
expect(record.isPaid, isFalse);
expect(record.paidAt, isNull);
expect(record.renewedTo, isNull);
});
});
group('PurchaseListResult.fromJson()', () {
test('解析 items + total + summary', () {
final result = PurchaseListResult.fromJson({
'items': [
{
'out_trade_no': 'pay-1',
'product_biz_code': 'annual',
'amount_minor': 299900,
'currency': 'CNY',
'amount': '2999.00',
'status': 'paid',
'pay_url': '',
'user_name': '张三',
},
],
'total': 1,
'summary': {
'paid_total_minor': 299900,
'paid_count': 1,
'pending_count': 0,
'total_count': 1,
},
});
expect(result.items.length, 1);
expect(result.total, 1);
expect(result.summary.paidTotalMinor, 299900);
expect(result.summary.paidCount, 1);
expect(result.summary.pendingCount, 0);
expect(result.summary.totalCount, 1);
});
test('空列表不报错', () {
final result = PurchaseListResult.fromJson({'items': [], 'total': 0});
expect(result.items, isEmpty);
expect(result.summary.totalCount, 0);
});
});
}
+144
View File
@@ -0,0 +1,144 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http_mock_adapter/http_mock_adapter.dart';
import 'package:jiu_client/core/api/api_client.dart';
import 'package:jiu_client/core/exceptions.dart';
import 'package:jiu_client/repositories/license_repository.dart';
const _baseUrl = 'http://localhost:8080/api/v1';
class _TestApiClient extends ApiClient {
final Dio _testDio;
_TestApiClient(this._testDio) : super(token: 'test-token');
@override
Future<Response> get(String path, {Map<String, dynamic>? params}) =>
_testDio.get(path, queryParameters: params);
@override
Future<Response> post(String path, {dynamic data}) =>
_testDio.post(path, data: data);
}
void main() {
late Dio dio;
late DioAdapter adapter;
late LicenseRepository repo;
setUp(() {
dio = Dio(BaseOptions(baseUrl: _baseUrl));
adapter = DioAdapter(dio: dio, matcher: const FullHttpRequestMatcher());
repo = LicenseRepository(_TestApiClient(dio));
});
group('LicenseRepository.cancelPurchase()', () {
test('200 canceled=true → 返回 true', () async {
adapter.onPost(
'/license/purchase/pay-abc123/cancel',
(server) => server.reply(200, {
'data': {'canceled': true}
}),
);
final ok = await repo.cancelPurchase('pay-abc123');
expect(ok, isTrue);
});
test('404 订单不存在 → 返回 false(不抛异常)', () async {
adapter.onPost(
'/license/purchase/pay-missing/cancel',
(server) => server.reply(404, {'error': '订单不存在'}),
);
final ok = await repo.cancelPurchase('pay-missing');
expect(ok, isFalse);
});
test('409 已支付不可取消 → 返回 false(不抛异常)', () async {
adapter.onPost(
'/license/purchase/pay-paid1/cancel',
(server) => server.reply(409, {'error': '订单已支付,无法取消'}),
);
final ok = await repo.cancelPurchase('pay-paid1');
expect(ok, isFalse);
});
test('其它错误(如 5xx)→ 抛 AppException', () async {
adapter.onPost(
'/license/purchase/pay-err/cancel',
(server) => server.reply(502, {'error': '取消失败,请稍后重试'}),
);
await expectLater(
repo.cancelPurchase('pay-err'),
throwsA(isA<AppException>()),
);
});
});
group('LicenseRepository.listPurchases()', () {
test('返回订单列表 + 汇总', () async {
adapter.onGet(
'/license/purchases',
(server) => server.reply(200, {
'data': {
'items': [
{
'out_trade_no': 'pay-1',
'product_biz_code': 'annual',
'amount_minor': 299900,
'currency': 'CNY',
'amount': '2999.00',
'status': 'paid',
'pay_url': '',
'user_name': '张三',
'created_at': '2026-07-01T10:00:00Z',
'paid_at': '2026-07-01T10:05:00Z',
},
],
'total': 1,
'summary': {
'paid_total_minor': 299900,
'paid_count': 1,
'pending_count': 0,
'total_count': 1,
},
},
}),
queryParameters: {'page': 1, 'page_size': 20},
);
final result = await repo.listPurchases();
expect(result.total, 1);
expect(result.items.single.outTradeNo, 'pay-1');
expect(result.summary.paidCount, 1);
});
test('status 筛选透传查询参数', () async {
adapter.onGet(
'/license/purchases',
(server) => server.reply(200, {
'data': {'items': [], 'total': 0},
}),
queryParameters: {'page': 1, 'page_size': 20, 'status': 'pending'},
);
final result = await repo.listPurchases(status: 'pending');
expect(result.items, isEmpty);
});
test('失败 → 抛 AppException', () async {
adapter.onGet(
'/license/purchases',
(server) => server.reply(500, {'error': '获取订单列表失败'}),
queryParameters: {'page': 1, 'page_size': 20},
);
await expectLater(
repo.listPurchases(),
throwsA(isA<AppException>()),
);
});
});
}
+32
View File
@@ -0,0 +1,32 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:jiu_client/core/utils/money.dart';
void main() {
group('yuanFromMinor()', () {
test('299900 分 → "2,999.00"(千分位 + 两位小数)', () {
expect(yuanFromMinor(299900), '2,999.00');
});
test('100 分 → "1.00"', () {
expect(yuanFromMinor(100), '1.00');
});
test('0 分 → "0.00"', () {
expect(yuanFromMinor(0), '0.00');
});
test('个位/十位/百位不加逗号', () {
expect(yuanFromMinor(5), '0.05');
expect(yuanFromMinor(99), '0.99');
expect(yuanFromMinor(12300), '123.00');
});
test('7 位数金额分组正确', () {
expect(yuanFromMinor(123456789), '1,234,567.89');
});
test('负数保留符号', () {
expect(yuanFromMinor(-299900), '-2,999.00');
});
});
}