From 179051a8fa0270ca8dbe193a49d7119e177bc123 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 10 Jul 2026 23:34:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(client):=20=E8=B4=AD=E4=B9=B0=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B/=E4=BB=93=E5=BA=93=E5=88=87=20pay=20v2=20=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=EF=BC=88render=5Ftype=20=E5=A4=9A=E6=80=81=20+=20?= =?UTF-8?q?=E5=88=86=E9=87=91=E9=A2=9D=20+=20=E8=AE=A2=E5=8D=95=E5=88=97?= =?UTF-8?q?=E8=A1=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- client/lib/core/utils/money.dart | 20 +++ client/lib/models/license.dart | 146 +++++++++++++++- .../lib/repositories/license_repository.dart | 40 +++++ client/test/license_purchase_v2_test.dart | 162 ++++++++++++++++++ client/test/license_repository_test.dart | 144 ++++++++++++++++ client/test/money_test.dart | 32 ++++ 6 files changed, 536 insertions(+), 8 deletions(-) create mode 100644 client/lib/core/utils/money.dart create mode 100644 client/test/license_purchase_v2_test.dart create mode 100644 client/test/license_repository_test.dart create mode 100644 client/test/money_test.dart diff --git a/client/lib/core/utils/money.dart b/client/lib/core/utils/money.dart new file mode 100644 index 0000000..bedccfa --- /dev/null +++ b/client/lib/core/utils/money.dart @@ -0,0 +1,20 @@ +/// 分转元字符串,千分位分组 + 两位小数,纯整数运算(金额禁 float)。 +/// +/// 例:299900 → "2,999.00";100 → "1.00";0 → "0.00";负数保留符号。 +String yuanFromMinor(int minor) { + final negative = minor < 0; + final abs = negative ? -minor : minor; + final yuan = abs ~/ 100; + final cents = abs % 100; + + final yuanDigits = yuan.toString(); + final len = yuanDigits.length; + final buffer = StringBuffer(); + for (var i = 0; i < len; i++) { + if (i > 0 && (len - i) % 3 == 0) buffer.write(','); + buffer.write(yuanDigits[i]); + } + + final centsStr = cents.toString().padLeft(2, '0'); + return '${negative ? '-' : ''}$buffer.$centsStr'; +} diff --git a/client/lib/models/license.dart b/client/lib/models/license.dart index bbe4933..a90fc6e 100644 --- a/client/lib/models/license.dart +++ b/client/lib/models/license.dart @@ -1,4 +1,5 @@ import '../core/config/app_constants.dart'; +import '../core/utils/money.dart'; class LicenseInfo { final int id; @@ -65,24 +66,41 @@ class LicenseInfo { phase == 'grace' || phase == 'readonly' || phase == 'locked'; } -/// POST /license/purchase 响应:pay 收银台跳转信息。 +/// POST /license/purchase 响应:pay v2 收银台会话信息(render_type 多态)。 class PurchaseOrder { - final String payUrl; final String outTradeNo; - final String amount; // 实付金额(pay 侧权威价,如 "2999.00") + final String renderType; // redirect | qr | ... + final Map payload; + final int amountMinor; + final String currency; final String subject; const PurchaseOrder({ - required this.payUrl, required this.outTradeNo, - required this.amount, + required this.renderType, + required this.payload, + required this.amountMinor, + required this.currency, required this.subject, }); + /// render_type == redirect 时的跳转链接(payload['url'])。 + String get redirectUrl => payload['url'] as String? ?? ''; + + /// render_type == qr 时的二维码内容(payload['qr_content'])。 + String get qrContent => payload['qr_content'] as String? ?? ''; + + /// 兼容旧字段:purchase_card.dart(Task 7 改造前)仍读 payUrl/amount。 + String get payUrl => redirectUrl; + String get amount => yuanFromMinor(amountMinor); + factory PurchaseOrder.fromJson(Map json) => PurchaseOrder( - payUrl: json['pay_url'] as String? ?? '', outTradeNo: json['out_trade_no'] as String? ?? '', - amount: json['amount'] as String? ?? '', + renderType: json['render_type'] as String? ?? 'redirect', + payload: + (json['payload'] as Map?)?.cast() ?? const {}, + amountMinor: (json['amount_minor'] as num?)?.toInt() ?? 0, + currency: json['currency'] as String? ?? 'CNY', subject: json['subject'] as String? ?? '', ); } @@ -91,13 +109,17 @@ class PurchaseOrder { class PurchaseStatusInfo { final String outTradeNo; final String status; // pending | paid | failed - final String amount; + final int amountMinor; + final String currency; + final String amount; // Deprecated:后端 formatMinor 串回退,串失败时用 amountMinor final DateTime? paidAt; final DateTime? expiresAt; // 续期后的门店授权到期时间(仅 paid 时有值) const PurchaseStatusInfo({ required this.outTradeNo, required this.status, + required this.amountMinor, + required this.currency, required this.amount, this.paidAt, this.expiresAt, @@ -105,10 +127,16 @@ class PurchaseStatusInfo { bool get isPaid => status == 'paid'; + /// 展示用金额:优先按 amount_minor 换算,退回旧 amount 串兜底。 + String get displayAmount => + amountMinor > 0 ? yuanFromMinor(amountMinor) : amount; + factory PurchaseStatusInfo.fromJson(Map json) => PurchaseStatusInfo( outTradeNo: json['out_trade_no'] as String? ?? '', status: json['status'] as String? ?? 'pending', + amountMinor: (json['amount_minor'] as num?)?.toInt() ?? 0, + currency: json['currency'] as String? ?? 'CNY', amount: json['amount'] as String? ?? '', paidAt: json['paid_at'] != null ? DateTime.tryParse(json['paid_at'] as String) @@ -118,3 +146,105 @@ class PurchaseStatusInfo { : null, ); } + +/// GET /license/purchases 列表项。 +class PurchaseRecord { + final String outTradeNo; + final String productBizCode; + final int amountMinor; + final String currency; + final String amount; // Deprecated:后端 formatMinor 串回退 + final String status; // pending | paid | failed + final String payUrl; + final String userName; + final DateTime? createdAt; + final DateTime? paidAt; + final DateTime? renewedTo; + + const PurchaseRecord({ + required this.outTradeNo, + required this.productBizCode, + required this.amountMinor, + required this.currency, + required this.amount, + required this.status, + required this.payUrl, + required this.userName, + this.createdAt, + this.paidAt, + this.renewedTo, + }); + + bool get isPaid => status == 'paid'; + + /// 展示用金额:优先按 amount_minor 换算,退回旧 amount 串兜底。 + String get displayAmount => + amountMinor > 0 ? yuanFromMinor(amountMinor) : amount; + + factory PurchaseRecord.fromJson(Map json) => + PurchaseRecord( + outTradeNo: json['out_trade_no'] as String? ?? '', + productBizCode: json['product_biz_code'] as String? ?? '', + amountMinor: (json['amount_minor'] as num?)?.toInt() ?? 0, + currency: json['currency'] as String? ?? 'CNY', + amount: json['amount'] as String? ?? '', + status: json['status'] as String? ?? 'pending', + payUrl: json['pay_url'] as String? ?? '', + userName: json['user_name'] as String? ?? '', + createdAt: json['created_at'] != null + ? DateTime.tryParse(json['created_at'] as String) + : null, + paidAt: json['paid_at'] != null + ? DateTime.tryParse(json['paid_at'] as String) + : null, + renewedTo: json['renewed_to'] != null + ? DateTime.tryParse(json['renewed_to'] as String) + : null, + ); +} + +/// GET /license/purchases 汇总(订单管理 tab KPI)。 +class PurchaseSummary { + final int paidTotalMinor; + final int paidCount; + final int pendingCount; + final int totalCount; + + const PurchaseSummary({ + required this.paidTotalMinor, + required this.paidCount, + required this.pendingCount, + required this.totalCount, + }); + + factory PurchaseSummary.fromJson(Map json) => + PurchaseSummary( + paidTotalMinor: (json['paid_total_minor'] as num?)?.toInt() ?? 0, + paidCount: (json['paid_count'] as num?)?.toInt() ?? 0, + pendingCount: (json['pending_count'] as num?)?.toInt() ?? 0, + totalCount: (json['total_count'] as num?)?.toInt() ?? 0, + ); +} + +/// GET /license/purchases 响应整体。 +class PurchaseListResult { + final List items; + final int total; + final PurchaseSummary summary; + + const PurchaseListResult({ + required this.items, + required this.total, + required this.summary, + }); + + factory PurchaseListResult.fromJson(Map json) => + PurchaseListResult( + items: (json['items'] as List? ?? const []) + .map((e) => PurchaseRecord.fromJson(e as Map)) + .toList(), + total: (json['total'] as num?)?.toInt() ?? 0, + summary: PurchaseSummary.fromJson( + (json['summary'] as Map?) ?? const {}), + ); +} diff --git a/client/lib/repositories/license_repository.dart b/client/lib/repositories/license_repository.dart index c98fb31..0f2ee33 100644 --- a/client/lib/repositories/license_repository.dart +++ b/client/lib/repositories/license_repository.dart @@ -74,6 +74,46 @@ class LicenseRepository { } } + /// 取消一笔未支付订单(防 pending 单堆积)。仅管理员可用。 + /// 返回 true=已取消;订单不存在(404)或不可取消(409,如已支付/已取消) + /// 视为业务性「取消不了」,返回 false 而非抛异常。 + Future cancelPurchase(String outTradeNo) async { + try { + final resp = await _client.post('/license/purchase/$outTradeNo/cancel'); + final data = resp.data['data'] as Map?; + return data?['canceled'] as bool? ?? false; + } on DioException catch (e) { + final status = e.response?.statusCode; + if (status == 404 || status == 409) return false; + throw AppException( + e.response?.data?['error'] as String? ?? '取消订单失败,请稍后重试', + statusCode: status, + ); + } + } + + /// 购买订单列表(订单管理 tab,分页 + 状态筛选)。仅管理员可用。 + Future listPurchases({ + int page = 1, + int pageSize = 20, + String? status, + }) async { + try { + final resp = await _client.get('/license/purchases', params: { + 'page': page, + 'page_size': pageSize, + if (status != null) 'status': status, + }); + return PurchaseListResult.fromJson( + resp.data['data'] as Map); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '获取订单列表失败', + statusCode: e.response?.statusCode, + ); + } + } + /// 本店首月特惠是否已享用(购买弹窗据此置灰特惠档)。 Future promoUsed() async { try { diff --git a/client/test/license_purchase_v2_test.dart b/client/test/license_purchase_v2_test.dart new file mode 100644 index 0000000..b9533cf --- /dev/null +++ b/client/test/license_purchase_v2_test.dart @@ -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'); + // 兼容 getter:Task 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); + }); + }); +} diff --git a/client/test/license_repository_test.dart b/client/test/license_repository_test.dart new file mode 100644 index 0000000..2960ffc --- /dev/null +++ b/client/test/license_repository_test.dart @@ -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 get(String path, {Map? params}) => + _testDio.get(path, queryParameters: params); + + @override + Future 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()), + ); + }); + }); + + 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()), + ); + }); + }); +} diff --git a/client/test/money_test.dart b/client/test/money_test.dart new file mode 100644 index 0000000..82669d2 --- /dev/null +++ b/client/test/money_test.dart @@ -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'); + }); + }); +}