From 82a2106a97314400a4efa23aaa4d6109bedc4f10 Mon Sep 17 00:00:00 2001 From: yanweidong Date: Sun, 2 Aug 2026 23:24:32 +0800 Subject: [PATCH] feat: integrate unified payment and wallet refunds --- .../data/repositories/client_repository.dart | 38 +++ .../lib/data/services/payment_jsapi.dart | 2 + .../lib/data/services/payment_jsapi_stub.dart | 2 + .../lib/data/services/payment_jsapi_web.dart | 25 ++ .../lib/data/services/payment_launcher.dart | 56 +++++ .../lib/ui/features/orders/orders_page.dart | 183 +++++++++++--- .../ui/features/shared/record_list_page.dart | 8 +- apps/user_app/pubspec.lock | 232 ++++++++++++------ apps/user_app/pubspec.yaml | 3 + backend/api/README.md | 2 +- backend/api/cmd/cli/main.go | 31 ++- backend/api/cmd/main/main.go | 2 +- .../etc/{platform_dev.yaml => heqi_dev.yaml} | 27 +- backend/api/go.mod | 5 + backend/api/go.sum | 12 + backend/api/internal/config/config.go | 43 +++- backend/api/internal/impl/new.go | 2 +- .../internal/logic/client/user/gasorder.go | 44 +++- .../api/internal/logic/client/user/refund.go | 48 ++++ .../api/internal/logic/client/user/shop.go | 34 ++- .../internal/logic/common/client_wallet.go | 13 + backend/api/internal/logic/common/resource.go | 4 +- .../api/internal/logic/delivery/finance.go | 8 +- backend/api/internal/logic/delivery/menu.go | 4 +- backend/api/internal/logic/delivery/order.go | 4 +- .../logic/delivery/resource_contract.go | 4 +- backend/api/internal/logic/gas/finance.go | 10 +- backend/api/internal/logic/gas/menu.go | 6 +- .../internal/logic/gas/resource_contract.go | 6 +- .../api/internal/logic/payment/callback.go | 129 ++++++++++ backend/api/internal/logic/payment/close.go | 68 +++++ .../api/internal/logic/payment/provider.go | 139 +++++++++++ backend/api/internal/logic/payment/refund.go | 166 +++++++++++++ backend/api/internal/logic/payment/service.go | 61 +++++ .../logic/platform/dashboard/statistics.go | 8 +- backend/api/internal/logic/platform/menu.go | 5 +- .../internal/logic/platform/payment/refund.go | 33 +++ .../logic/platform/resource_contract.go | 4 +- .../internal/logic/platform/wallet/wallet.go | 10 +- .../api/internal/models/gasorder_payment.go | 2 +- backend/api/internal/models/payment_order.go | 34 +++ backend/api/internal/models/payment_refund.go | 45 ++++ .../internal/models/status_separation_test.go | 4 +- backend/api/internal/models/wallet_payment.go | 23 -- backend/api/internal/models/wallet_refund.go | 27 -- backend/api/internal/routers/client.go | 4 + backend/api/internal/routers/delivery.go | 8 +- backend/api/internal/routers/gas_business.go | 4 +- backend/api/internal/routers/payment.go | 15 ++ backend/api/internal/routers/platform.go | 16 +- backend/api/internal/routers/platform_test.go | 2 +- backend/api/internal/routers/register.go | 1 + backend/api/internal/seed/mock.go | 24 +- backend/go.work.sum | 2 + backend/worker/cmd/main/main.go | 19 +- backend/worker/etc/platform_worker_dev.yaml | 5 + backend/worker/internal/config/config.go | 21 +- docs/02-核心业务流程.md | 4 +- docs/03-用户端App需求.md | 3 +- docs/11-数据接口与安全.md | 3 +- .../scripts/check-backend-contract.mjs | 6 +- frontend/platform_admin/src/api/resources.ts | 17 +- .../src/contracts/platform-resources.json | 2 +- .../src/router/routes/modules/platform.ts | 1 + 64 files changed, 1519 insertions(+), 254 deletions(-) create mode 100644 apps/user_app/lib/data/services/payment_jsapi.dart create mode 100644 apps/user_app/lib/data/services/payment_jsapi_stub.dart create mode 100644 apps/user_app/lib/data/services/payment_jsapi_web.dart create mode 100644 apps/user_app/lib/data/services/payment_launcher.dart rename backend/api/etc/{platform_dev.yaml => heqi_dev.yaml} (52%) create mode 100644 backend/api/internal/logic/client/user/refund.go create mode 100644 backend/api/internal/logic/payment/callback.go create mode 100644 backend/api/internal/logic/payment/close.go create mode 100644 backend/api/internal/logic/payment/provider.go create mode 100644 backend/api/internal/logic/payment/refund.go create mode 100644 backend/api/internal/logic/payment/service.go create mode 100644 backend/api/internal/logic/platform/payment/refund.go create mode 100644 backend/api/internal/models/payment_order.go create mode 100644 backend/api/internal/models/payment_refund.go delete mode 100644 backend/api/internal/models/wallet_payment.go delete mode 100644 backend/api/internal/models/wallet_refund.go create mode 100644 backend/api/internal/routers/payment.go diff --git a/apps/user_app/lib/data/repositories/client_repository.dart b/apps/user_app/lib/data/repositories/client_repository.dart index 92a34f0..5e71af7 100644 --- a/apps/user_app/lib/data/repositories/client_repository.dart +++ b/apps/user_app/lib/data/repositories/client_repository.dart @@ -79,6 +79,44 @@ class ClientRepository { subtitleKeys: const ['amount', 'direction'], ); + Future> refunds() => _records( + '$root/refunds', + titleKeys: const ['refund_no'], + subtitleKeys: const ['reason', 'amount'], + statusKey: 'refund_status', + ); + + Future> payOrder({ + required String business, + required String identity, + required String requestNo, + required String channel, + required String payType, + String openid = '', + }) async => jsonMap(await _api.post( + '$root/$business/orders/$identity/pay', + body: { + 'request_no': requestNo, + 'channel': channel, + 'pay_type': payType, + if (openid.isNotEmpty) 'openid': openid, + }, + )); + + Future createRefund({ + required String business, + required String identity, + required String requestNo, + required String reason, + required List> items, + }) async { + await _api.post('$root/$business/orders/$identity/refunds', body: { + 'request_no': requestNo, + 'reason': reason, + 'items': items, + }); + } + Future?> serviceRelation() async { final value = await _api.get('$root/service-relation'); if (value == null || value == '') return null; diff --git a/apps/user_app/lib/data/services/payment_jsapi.dart b/apps/user_app/lib/data/services/payment_jsapi.dart new file mode 100644 index 0000000..5a2eaef --- /dev/null +++ b/apps/user_app/lib/data/services/payment_jsapi.dart @@ -0,0 +1,2 @@ +export 'payment_jsapi_stub.dart' + if (dart.library.js_interop) 'payment_jsapi_web.dart'; diff --git a/apps/user_app/lib/data/services/payment_jsapi_stub.dart b/apps/user_app/lib/data/services/payment_jsapi_stub.dart new file mode 100644 index 0000000..1877716 --- /dev/null +++ b/apps/user_app/lib/data/services/payment_jsapi_stub.dart @@ -0,0 +1,2 @@ +Future invokeWechatJsapi(String clientArgs) => + throw UnsupportedError('微信 JSAPI 仅支持 Web'); diff --git a/apps/user_app/lib/data/services/payment_jsapi_web.dart b/apps/user_app/lib/data/services/payment_jsapi_web.dart new file mode 100644 index 0000000..8e1343e --- /dev/null +++ b/apps/user_app/lib/data/services/payment_jsapi_web.dart @@ -0,0 +1,25 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:js_interop'; + +@JS('WeixinJSBridge.invoke') +external void _invoke(String name, JSAny? arguments, JSFunction callback); + +Future invokeWechatJsapi(String clientArgs) { + final completer = Completer(); + final arguments = jsonDecode(clientArgs).jsify(); + _invoke( + 'getBrandWCPayRequest', + arguments, + (JSAny? rawResult) { + final result = rawResult.dartify(); + final message = result is Map ? result['err_msg']?.toString() ?? '' : ''; + if (message == 'get_brand_wcpay_request:ok') { + completer.complete(); + } else { + completer.completeError(StateError('微信支付未完成:$message')); + } + }.toJS, + ); + return completer.future; +} diff --git a/apps/user_app/lib/data/services/payment_launcher.dart b/apps/user_app/lib/data/services/payment_launcher.dart new file mode 100644 index 0000000..0d46db2 --- /dev/null +++ b/apps/user_app/lib/data/services/payment_launcher.dart @@ -0,0 +1,56 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:fluwx/fluwx.dart'; +import 'package:tobias/tobias.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'payment_jsapi.dart'; + +/// 只负责调起支付渠道;支付结果必须重新向服务端查询确认。 +class PaymentLauncher { + PaymentLauncher({Fluwx? wechat, Tobias? alipay}) + : _wechat = wechat ?? Fluwx(), + _alipay = alipay ?? Tobias(); + + final Fluwx _wechat; + final Tobias _alipay; + + Future launch(Map payment) async { + final channel = payment['channel']; + final payType = payment['pay_type']; + final argsText = payment['client_args'] as String? ?? ''; + + // 支付宝手机网站支付返回可直接打开的收银台 URL。 + if (payType == 'wap') { + final uri = Uri.tryParse(argsText); + if (uri == null || + !await launchUrl(uri, mode: LaunchMode.externalApplication)) { + throw StateError('无法打开支付页面'); + } + return; + } + if (kIsWeb && payType == 'jsapi') { + await invokeWechatJsapi(argsText); + return; + } + if (channel == 'alipay') { + await _alipay.pay(argsText); + return; + } + + final args = (jsonDecode(argsText) as Map).cast(); + final accepted = await _wechat.pay( + which: Payment( + appId: payment['app_id'] as String? ?? '', + partnerId: args['partnerId'] as String, + prepayId: args['prepayId'] as String, + packageValue: args['package'] as String, + nonceStr: args['nonceStr'] as String, + timestamp: int.parse(args['timeStamp'] as String), + sign: args['sign'] as String, + ), + ); + if (!accepted) throw StateError('微信未接受支付请求'); + } +} diff --git a/apps/user_app/lib/ui/features/orders/orders_page.dart b/apps/user_app/lib/ui/features/orders/orders_page.dart index 5911ce8..6b5954e 100644 --- a/apps/user_app/lib/ui/features/orders/orders_page.dart +++ b/apps/user_app/lib/ui/features/orders/orders_page.dart @@ -1,47 +1,172 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:uuid/uuid.dart'; import '../../../data/repositories/client_repository.dart'; +import '../../../data/services/payment_launcher.dart'; +import '../../../domain/models/client_models.dart'; import '../shared/record_list_page.dart'; import '../shared/record_list_view_model.dart'; class OrdersPage extends StatelessWidget { - const OrdersPage({required this.repository, super.key}); + OrdersPage({required this.repository, super.key}); final ClientRepository repository; + final PaymentLauncher _launcher = PaymentLauncher(); - @override - Widget build(BuildContext context) => DefaultTabController( - length: 3, - child: Scaffold( - appBar: AppBar( - title: const Text('我的订单'), - bottom: const TabBar( - tabs: [ - Tab(text: '商城'), - Tab(text: '供气'), - Tab(text: '服务工单'), + Future _openActions( + BuildContext context, + ClientRecord record, + String business, + ) async { + final action = await showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (record.status == 16 || record.status == 18) ...[ + ListTile( + title: const Text('支付宝支付'), + onTap: () => Navigator.pop(context, 'alipay'), + ), + ListTile( + title: const Text('微信支付'), + onTap: () => Navigator.pop(context, 'wechat'), + ), + ], + if (record.status == 18 || record.status == 35) + ListTile( + title: const Text('申请退款'), + onTap: () => Navigator.pop(context, 'refund'), + ), ], ), ), - body: TabBarView( - children: [ - RecordListPage( - title: '商城订单', - eyebrow: '交易', - viewModel: RecordListViewModel(repository.shopOrders), + ); + if (action == null || !context.mounted) return; + try { + if (action == 'refund') { + final reason = await _reason(context); + if (reason == null || !context.mounted) return; + final rawItems = record.raw['items'] as List? ?? const []; + await repository.createRefund( + business: business, + identity: record.identity, + requestNo: const Uuid().v7(), + reason: reason, + items: rawItems.map((item) { + final value = (item as Map).cast(); + return { + 'identity': value['identity'], + 'quantity': value['quantity'] ?? 1, + }; + }).toList(), + ); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('退款申请已提交')), + ); + } + return; + } + final payment = await repository.payOrder( + business: business, + identity: record.identity, + requestNo: const Uuid().v7(), + channel: action, + payType: kIsWeb + ? (action == 'alipay' ? 'wap' : 'jsapi') + : 'app', + openid: kIsWeb && action == 'wechat' + ? Uri.base.queryParameters['openid'] ?? '' + : '', + ); + await _launcher.launch(payment); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('支付结果确认中,请稍后刷新')), + ); + } + } catch (error) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(error.toString())), + ); + } + } + } + + Future _reason(BuildContext context) async { + final controller = TextEditingController(); + final value = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('申请退款'), + content: TextField( + controller: controller, + maxLines: 3, + decoration: const InputDecoration(labelText: '退款原因'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消'), ), - RecordListPage( - title: '供气订单', - eyebrow: '履约', - viewModel: RecordListViewModel(repository.gasOrders), - ), - RecordListPage( - title: '服务工单', - eyebrow: '服务', - viewModel: RecordListViewModel(repository.tickets), + FilledButton( + onPressed: () => Navigator.pop(context, controller.text.trim()), + child: const Text('提交'), ), ], ), - ), - ); + ); + controller.dispose(); + return value?.isEmpty == true ? null : value; + } + + @override + Widget build(BuildContext context) => DefaultTabController( + length: 4, + child: Scaffold( + appBar: AppBar( + title: const Text('我的订单'), + bottom: const TabBar( + tabs: [ + Tab(text: '商城'), + Tab(text: '供气'), + Tab(text: '退款'), + Tab(text: '工单'), + ], + ), + ), + body: TabBarView( + children: [ + RecordListPage( + title: '商城订单', + eyebrow: '交易', + viewModel: RecordListViewModel(repository.shopOrders), + onRecordTap: (record) => + _openActions(context, record, 'shop'), + ), + RecordListPage( + title: '供气订单', + eyebrow: '履约', + viewModel: RecordListViewModel(repository.gasOrders), + onRecordTap: (record) => + _openActions(context, record, 'gas'), + ), + RecordListPage( + title: '退款记录', + eyebrow: '资金', + viewModel: RecordListViewModel(repository.refunds), + ), + RecordListPage( + title: '服务工单', + eyebrow: '服务', + viewModel: RecordListViewModel(repository.tickets), + ), + ], + ), + ), + ); } diff --git a/apps/user_app/lib/ui/features/shared/record_list_page.dart b/apps/user_app/lib/ui/features/shared/record_list_page.dart index 7ce5cfd..5aa5fed 100644 --- a/apps/user_app/lib/ui/features/shared/record_list_page.dart +++ b/apps/user_app/lib/ui/features/shared/record_list_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../core/widgets.dart'; +import '../../../domain/models/client_models.dart'; import 'record_list_view_model.dart'; class RecordListPage extends StatefulWidget { @@ -10,6 +11,7 @@ class RecordListPage extends StatefulWidget { required this.viewModel, this.description, this.floatingActionButton, + this.onRecordTap, super.key, }); @@ -18,6 +20,7 @@ class RecordListPage extends StatefulWidget { final String? description; final RecordListViewModel viewModel; final Widget? floatingActionButton; + final ValueChanged? onRecordTap; @override State createState() => _RecordListPageState(); @@ -74,7 +77,10 @@ class _RecordListPageState extends State { else SliverList.builder( itemCount: state.records.length, - itemBuilder: (context, index) => RecordCard(record: state.records[index]), + itemBuilder: (context, index) => RecordCard( + record: state.records[index], + onTap: widget.onRecordTap == null ? null : () => widget.onRecordTap!(state.records[index]), + ), ), const SliverPadding(padding: EdgeInsets.only(bottom: 24)), ], diff --git a/apps/user_app/pubspec.lock b/apps/user_app/pubspec.lock index 479461f..da17542 100644 --- a/apps/user_app/pubspec.lock +++ b/apps/user_app/pubspec.lock @@ -6,7 +6,7 @@ packages: description: name: args sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.7.0" async: @@ -14,7 +14,7 @@ packages: description: name: async sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.13.1" boolean_selector: @@ -22,7 +22,7 @@ packages: description: name: boolean_selector sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.1.2" characters: @@ -30,7 +30,7 @@ packages: description: name: characters sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.4.1" clock: @@ -38,7 +38,7 @@ packages: description: name: clock sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.1.2" code_assets: @@ -46,7 +46,7 @@ packages: description: name: code_assets sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.2.1" collection: @@ -54,7 +54,7 @@ packages: description: name: collection sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.19.1" crypto: @@ -62,7 +62,7 @@ packages: description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "3.0.7" cupertino_icons: @@ -70,7 +70,7 @@ packages: description: name: cupertino_icons sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.0.9" fake_async: @@ -78,7 +78,7 @@ packages: description: name: fake_async sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.3.3" ffi: @@ -86,7 +86,7 @@ packages: description: name: ffi sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.2.0" ffi_leak_tracker: @@ -94,7 +94,7 @@ packages: description: name: ffi_leak_tracker sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "0.1.2" fixnum: @@ -102,7 +102,7 @@ packages: description: name: fixnum sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.1.1" flutter: @@ -115,7 +115,7 @@ packages: description: name: flutter_lints sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "6.0.0" flutter_secure_storage: @@ -123,7 +123,7 @@ packages: description: name: flutter_secure_storage sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "10.3.1" flutter_secure_storage_darwin: @@ -131,7 +131,7 @@ packages: description: name: flutter_secure_storage_darwin sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "0.3.2" flutter_secure_storage_linux: @@ -139,23 +139,23 @@ packages: description: name: flutter_secure_storage_linux sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "3.0.1" flutter_secure_storage_platform_interface: dependency: transitive description: name: flutter_secure_storage_platform_interface - sha256: "06686df417f34fe9f963ed217b7fdce250e2f637ddd6c374d7eb054549770633" - url: "https://pub.dev" + sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" + url: "https://pub.flutter-io.cn" source: hosted - version: "2.0.2" + version: "2.0.1" flutter_secure_storage_web: dependency: transitive description: name: flutter_secure_storage_web sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.1.1" flutter_secure_storage_windows: @@ -163,7 +163,7 @@ packages: description: name: flutter_secure_storage_windows sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "4.2.2" flutter_test: @@ -176,12 +176,20 @@ packages: description: flutter source: sdk version: "0.0.0" + fluwx: + dependency: "direct main" + description: + name: fluwx + sha256: "4b526d281be8560a490bd1b945373a23ab2a12088c8fc5997ffb31cc6fb41082" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.0.2" go_router: dependency: "direct main" description: name: go_router sha256: "5922b2861e2235a3504896f0d6fa07d84141b480cf52eecd2f42cd25585a9e8a" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "17.3.0" hooks: @@ -189,7 +197,7 @@ packages: description: name: hooks sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.0.2" http: @@ -197,7 +205,7 @@ packages: description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.6.0" http_parser: @@ -205,39 +213,31 @@ packages: description: name: http_parser sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "4.1.2" jni: dependency: transitive description: name: jni - sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 - url: "https://pub.dev" + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.flutter-io.cn" source: hosted - version: "1.0.3" + version: "1.0.0" jni_flutter: dependency: transitive description: name: jni_flutter - sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5" - url: "https://pub.dev" + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.flutter-io.cn" source: hosted - version: "1.0.2" - jni_util: - dependency: transitive - description: - name: jni_util - sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" - url: "https://pub.dev" - source: hosted - version: "1.0.0" + version: "1.0.1" leak_tracker: dependency: transitive description: name: leak_tracker sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "11.0.2" leak_tracker_flutter_testing: @@ -245,7 +245,7 @@ packages: description: name: leak_tracker_flutter_testing sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "3.0.10" leak_tracker_testing: @@ -253,7 +253,7 @@ packages: description: name: leak_tracker_testing sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "3.0.2" lints: @@ -261,7 +261,7 @@ packages: description: name: lints sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "6.1.0" logging: @@ -269,7 +269,7 @@ packages: description: name: logging sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.3.0" matcher: @@ -277,7 +277,7 @@ packages: description: name: matcher sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "0.12.19" material_color_utilities: @@ -285,7 +285,7 @@ packages: description: name: material_color_utilities sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "0.13.0" meta: @@ -293,31 +293,31 @@ packages: description: name: meta sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.18.0" objective_c: dependency: transitive description: name: objective_c - sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e - url: "https://pub.dev" + sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + url: "https://pub.flutter-io.cn" source: hosted - version: "9.5.0" + version: "9.4.1" package_config: dependency: transitive description: name: package_config - sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d - url: "https://pub.dev" + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.flutter-io.cn" source: hosted - version: "3.0.0" + version: "2.2.0" path: dependency: transitive description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.9.1" path_provider: @@ -325,7 +325,7 @@ packages: description: name: path_provider sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.1.6" path_provider_android: @@ -333,7 +333,7 @@ packages: description: name: path_provider_android sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.3.1" path_provider_foundation: @@ -341,7 +341,7 @@ packages: description: name: path_provider_foundation sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.6.0" path_provider_linux: @@ -349,7 +349,7 @@ packages: description: name: path_provider_linux sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.2.2" path_provider_platform_interface: @@ -357,7 +357,7 @@ packages: description: name: path_provider_platform_interface sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.1.3" path_provider_windows: @@ -365,7 +365,7 @@ packages: description: name: path_provider_windows sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.3.0" platform: @@ -373,7 +373,7 @@ packages: description: name: platform sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "3.1.6" plugin_platform_interface: @@ -381,7 +381,7 @@ packages: description: name: plugin_platform_interface sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.1.8" pub_semver: @@ -389,7 +389,7 @@ packages: description: name: pub_semver sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.2.0" record_use: @@ -397,7 +397,7 @@ packages: description: name: record_use sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "0.6.0" sky_engine: @@ -410,7 +410,7 @@ packages: description: name: source_span sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.10.2" stack_trace: @@ -418,7 +418,7 @@ packages: description: name: stack_trace sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.12.1" stream_channel: @@ -426,7 +426,7 @@ packages: description: name: stream_channel sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.1.4" string_scanner: @@ -434,7 +434,7 @@ packages: description: name: string_scanner sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.4.1" term_glyph: @@ -442,7 +442,7 @@ packages: description: name: term_glyph sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.2.2" test_api: @@ -450,23 +450,95 @@ packages: description: name: test_api sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "0.7.11" + tobias: + dependency: "direct main" + description: + name: tobias + sha256: "1cfd203bf57f8d4daa3e734d24ef97568eb5265af62d1498c32da4e69905c70f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.3.4" typed_data: dependency: transitive description: name: typed_data sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.3.32" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.5" uuid: dependency: "direct main" description: name: uuid sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "4.6.0" vector_math: @@ -474,7 +546,7 @@ packages: description: name: vector_math sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.2.0" vm_service: @@ -482,7 +554,7 @@ packages: description: name: vm_service sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "15.2.0" web: @@ -490,7 +562,7 @@ packages: description: name: web sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.1.1" win32: @@ -498,7 +570,7 @@ packages: description: name: win32 sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "6.3.0" xdg_directories: @@ -506,7 +578,7 @@ packages: description: name: xdg_directories sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.1.0" yaml: @@ -514,9 +586,9 @@ packages: description: name: yaml sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "3.1.3" sdks: dart: ">=3.12.2 <4.0.0" - flutter: ">=3.38.4" + flutter: ">=3.44.0" diff --git a/apps/user_app/pubspec.yaml b/apps/user_app/pubspec.yaml index ab1d753..32162c8 100644 --- a/apps/user_app/pubspec.yaml +++ b/apps/user_app/pubspec.yaml @@ -38,6 +38,9 @@ dependencies: http: ^1.6.0 flutter_secure_storage: ^10.3.1 uuid: ^4.6.0 + fluwx: ^6.0.2 + tobias: ^5.3.4 + url_launcher: ^6.3.2 dev_dependencies: flutter_test: diff --git a/backend/api/README.md b/backend/api/README.md index 54a7b0d..3c6c7ae 100644 --- a/backend/api/README.md +++ b/backend/api/README.md @@ -1,6 +1,6 @@ # Platform API -平台总后台 API 严格沿用 `sample/server` 的 BSM-SDK Core 分层和运行方式。 +平台总后台 API 采用 BSM-SDK Core 分层和运行方式。 启动前请设置运行配置;JWT 密钥必须为 16、24 或 32 个字符。 diff --git a/backend/api/cmd/cli/main.go b/backend/api/cmd/cli/main.go index ff44e5b..3100b03 100644 --- a/backend/api/cmd/cli/main.go +++ b/backend/api/cmd/cli/main.go @@ -153,7 +153,7 @@ func writeDeliveryResourceContract(output io.Writer) error { } func writeMockData() error { - config.New("platform") + config.New("heqi") if config.Spec.Databases == nil { return fmt.Errorf("database configuration is required") } @@ -175,7 +175,7 @@ func writeMockData() error { } func migrateDatabase() error { - config.New("platform") + config.New("heqi") if config.Spec.Databases == nil { return fmt.Errorf("database configuration is required") } @@ -196,6 +196,9 @@ func migrateDatabase() error { if err := prepareAdditiveMigrations(migrationDatabase, driver); err != nil { return err } + if err := resetLegacyPaymentSchema(migrationDatabase, driver); err != nil { + return err + } const legacyPhoneIndex = "idx_platform_account_phone" if migrationDatabase.Migrator().HasIndex(&models.PlatformAccount{}, legacyPhoneIndex) { if err := migrationDatabase.Migrator().DropIndex(&models.PlatformAccount{}, legacyPhoneIndex); err != nil { @@ -213,6 +216,30 @@ func migrateDatabase() error { return initdb.New(databaseService) } +// resetLegacyPaymentSchema 执行经业务明确授权的开发期破坏性支付模型重置,不迁移旧支付或退款历史。 +func resetLegacyPaymentSchema(databaseService *gorm.DB, driver string) error { + statements := []string{} + if driver == "postgres" { + statements = append(statements, + `DROP TABLE IF EXISTS "wallet_refund" CASCADE`, + `DROP TABLE IF EXISTS "wallet_payment" CASCADE`, + `DROP TABLE IF EXISTS "gasorder_payment" CASCADE`, + ) + } else { + statements = append(statements, + `DROP TABLE IF EXISTS wallet_refund`, + `DROP TABLE IF EXISTS wallet_payment`, + `DROP TABLE IF EXISTS gasorder_payment`, + ) + } + for _, statement := range statements { + if err := databaseService.Exec(statement).Error; err != nil { + return fmt.Errorf("reset legacy payment schema: %w", err) + } + } + return nil +} + // prepareAdditiveMigrations 先处理无法由 GORM AutoMigrate 安全完成的新增非空字段。 // 旧轨迹没有服务端接收时间时,以定位发生时间(再退化到创建时间)回填, // 避免直接 ADD NOT NULL 因历史行存在而中断整库迁移。 diff --git a/backend/api/cmd/main/main.go b/backend/api/cmd/main/main.go index b6d6927..51c44d7 100644 --- a/backend/api/cmd/main/main.go +++ b/backend/api/cmd/main/main.go @@ -1,4 +1,4 @@ -// 平台 HTTP API 进程入口,沿用 sample/server 的 BSM 启动规范。 +// 平台 HTTP API 进程入口,沿用仓库统一的 BSM 启动规范。 package main import ( diff --git a/backend/api/etc/platform_dev.yaml b/backend/api/etc/heqi_dev.yaml similarity index 52% rename from backend/api/etc/platform_dev.yaml rename to backend/api/etc/heqi_dev.yaml index 8660886..f80ff09 100644 --- a/backend/api/etc/platform_dev.yaml +++ b/backend/api/etc/heqi_dev.yaml @@ -1,4 +1,4 @@ -Service: platform +Service: heqi Port: 12426 Databases: @@ -22,3 +22,28 @@ Global: DeliveryArrivalRadiusMeters: 200 UploadVideoMaxSize: 104857600 FieldEncryptionKey: change-me-32-byte-development-key + +Payment: + ExpireMinutes: 30 + RefundWindowDays: 3 + InternalServiceToken: change-me-payment-worker-token + Alipay: + Enabled: false + Production: false + AppID: "" + PrivateKeyPath: "" + AppPublicCertPath: "" + AlipayPublicCertPath: "" + AlipayRootCertPath: "" + NotifyURL: "https://example.invalid/heqi/payment-return/v1/alipay/notify" + ReturnURL: "https://example.invalid/payment/result" + Wechat: + Enabled: false + MerchantID: "" + MerchantCertificateSerial: "" + MerchantPrivateKeyPath: "" + APIv3Key: "" + AppAppID: "" + OfficialAccountAppID: "" + MiniProgramAppID: "" + NotifyURL: "https://example.invalid/heqi/payment-return/v1/wechat/notify" diff --git a/backend/api/go.mod b/backend/api/go.mod index b490d2a..d4bf893 100644 --- a/backend/api/go.mod +++ b/backend/api/go.mod @@ -54,8 +54,13 @@ require ( github.com/quic-go/quic-go v0.59.0 // indirect github.com/redis/go-redis/v9 v9.19.0 // indirect github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect + github.com/smartwalle/alipay/v3 v3.2.31 // indirect + github.com/smartwalle/ncrypto v1.0.4 // indirect + github.com/smartwalle/ngx v1.1.2 // indirect + github.com/smartwalle/nsign v1.0.9 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect + github.com/wechatpay-apiv3/wechatpay-go v0.2.21 // indirect go.etcd.io/etcd/api/v3 v3.6.11 // indirect go.etcd.io/etcd/client/pkg/v3 v3.6.11 // indirect go.etcd.io/etcd/client/v3 v3.6.11 // indirect diff --git a/backend/api/go.sum b/backend/api/go.sum index 7f272ad..b11470f 100644 --- a/backend/api/go.sum +++ b/backend/api/go.sum @@ -4,6 +4,7 @@ git.apinb.com/bsm-sdk/core v0.2.0 h1:/e9yqpsbKBrRgMiGpS3KX2O4qDLxo5V5GpPSLNGxEKw git.apinb.com/bsm-sdk/core v0.2.0/go.mod h1:E9T6Eboo/0Zb36BjkKbIgvFzq4fQ2Q8P/7y5zmYTI6Y= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/agiledragon/gomonkey v2.0.2+incompatible/go.mod h1:2NGfXu1a80LLr2cmWXGBDaHEjb1idR6+FVlX5T3D9hw= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -116,6 +117,14 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= +github.com/smartwalle/alipay/v3 v3.2.31 h1:KVwWJZ5JvNxPridHrcpTwgJeMzi1IqZopid0sDJQewU= +github.com/smartwalle/alipay/v3 v3.2.31/go.mod h1:0G9wqvo1719hxo6ZWntujuPNyVQFWikS52LF62rRYDg= +github.com/smartwalle/ncrypto v1.0.4 h1:P2rqQxDepJwgeO5ShoC+wGcK2wNJDmcdBOWAksuIgx8= +github.com/smartwalle/ncrypto v1.0.4/go.mod h1:Dwlp6sfeNaPMnOxMNayMTacvC5JGEVln3CVdiVDgbBk= +github.com/smartwalle/ngx v1.1.2 h1:W+K262lHUvdfJ/2e762dIFwN543kxC/4qLkuCQLRS8o= +github.com/smartwalle/ngx v1.1.2/go.mod h1:mx/nz2Pk5j+RBs7t6u6k22MPiBG/8CtOMpCnALIG8Y0= +github.com/smartwalle/nsign v1.0.9 h1:8poAgG7zBd8HkZy9RQDwasC6XZvJpDGQWSjzL2FZL6E= +github.com/smartwalle/nsign v1.0.9/go.mod h1:eY6I4CJlyNdVMP+t6z1H6Jpd4m5/V+8xi44ufSTxXgc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -124,6 +133,7 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= @@ -132,6 +142,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/wechatpay-apiv3/wechatpay-go v0.2.21 h1:uIyMpzvcaHA33W/QPtHstccw+X52HO1gFdvVL9O6Lfs= +github.com/wechatpay-apiv3/wechatpay-go v0.2.21/go.mod h1:A254AUBVB6R+EqQFo3yTgeh7HtyqRRtN2w9hQSOrd4Q= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= diff --git a/backend/api/internal/config/config.go b/backend/api/internal/config/config.go index 10eef0e..6c850bc 100644 --- a/backend/api/internal/config/config.go +++ b/backend/api/internal/config/config.go @@ -1,4 +1,4 @@ -// Package config 沿用 sample/server 的 BSM 配置加载与校验方式。 +// Package config 使用仓库统一的 BSM 配置加载与校验方式。 package config import ( @@ -31,7 +31,42 @@ type WalletConfig struct { ManualRechargeMaxAmount int64 `yaml:"-"` } -// SrvConfig 与 sample/server 配置结构保持一致。 +// AlipayConfig 保存支付宝证书模式商户配置。私钥和证书只允许服务端读取。 +type AlipayConfig struct { + Enabled bool `yaml:"Enabled"` + Production bool `yaml:"Production"` + AppID string `yaml:"AppID"` + PrivateKeyPath string `yaml:"PrivateKeyPath"` + AppPublicCertPath string `yaml:"AppPublicCertPath"` + AlipayPublicCertPath string `yaml:"AlipayPublicCertPath"` + AlipayRootCertPath string `yaml:"AlipayRootCertPath"` + NotifyURL string `yaml:"NotifyURL"` + ReturnURL string `yaml:"ReturnURL"` +} + +// WechatPayConfig 保存微信支付 API v3 平台单商户配置。 +type WechatPayConfig struct { + Enabled bool `yaml:"Enabled"` + MerchantID string `yaml:"MerchantID"` + MerchantCertificateSerial string `yaml:"MerchantCertificateSerial"` + MerchantPrivateKeyPath string `yaml:"MerchantPrivateKeyPath"` + APIv3Key string `yaml:"APIv3Key"` + AppAppID string `yaml:"AppAppID"` + OfficialAccountAppID string `yaml:"OfficialAccountAppID"` + MiniProgramAppID string `yaml:"MiniProgramAppID"` + NotifyURL string `yaml:"NotifyURL"` +} + +// PaymentConfig 保存统一支付、退款和 Worker 内部调用参数。 +type PaymentConfig struct { + ExpireMinutes int `yaml:"ExpireMinutes"` + RefundWindowDays int `yaml:"RefundWindowDays"` + InternalServiceToken string `yaml:"InternalServiceToken"` + Alipay AlipayConfig `yaml:"Alipay"` + Wechat WechatPayConfig `yaml:"Wechat"` +} + +// SrvConfig 与仓库现有进程的配置结构保持一致。 type SrvConfig struct { conf.Base `yaml:",inline"` Databases *conf.DBConf `yaml:"Databases"` @@ -39,6 +74,7 @@ type SrvConfig struct { Apm *conf.ApmConf `yaml:"APM"` Global GlobalConfig `yaml:"Global"` Wallet WalletConfig `yaml:"-"` + Payment PaymentConfig `yaml:"Payment"` } // New 初始化 BSM 配置并校验服务监听地址。 @@ -64,6 +100,9 @@ func New(srvKey string) { panic("Global.FieldEncryptionKey must contain at least 32 characters") } Spec.Wallet.ManualRechargeMaxAmount = Spec.Global.ManualRechargeMaxAmount + if Spec.Payment.ExpireMinutes <= 0 || Spec.Payment.RefundWindowDays <= 0 { + panic("Payment expiration and refund window must be greater than zero") + } registerURL, err := url.ParseRequestURI(Spec.Global.UserRegisterURL) if err != nil || (registerURL.Scheme != "http" && registerURL.Scheme != "https") || registerURL.Host == "" { panic("Global.UserRegisterURL must be a valid HTTP or HTTPS URL") diff --git a/backend/api/internal/impl/new.go b/backend/api/internal/impl/new.go index 4112ec6..2ef3084 100644 --- a/backend/api/internal/impl/new.go +++ b/backend/api/internal/impl/new.go @@ -17,7 +17,7 @@ var ( MemoryService *cache.Cache ) -// NewImpl 与 sample/server 保持一致,所有进程通过此处创建共享基础设施。 +// NewImpl 遵循仓库统一约定,所有进程通过此处创建共享基础设施。 func NewImpl() { MemoryService = with.Memory(nil) RedisService = with.RedisCache(config.Spec.Cache) diff --git a/backend/api/internal/logic/client/user/gasorder.go b/backend/api/internal/logic/client/user/gasorder.go index e60cbc0..bb2d94a 100644 --- a/backend/api/internal/logic/client/user/gasorder.go +++ b/backend/api/internal/logic/client/user/gasorder.go @@ -5,6 +5,7 @@ import ( "git.apinb.com/bsm-sdk/core/infra" "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" common "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/payment" "git.apinb.com/heqiapp/platforms/backend/api/internal/models" "github.com/gin-gonic/gin" ) @@ -36,6 +37,36 @@ func ServiceRelation(ctx *gin.Context) { infra.Response.Success(ctx, response) } +// PayGasOrder 为本人未履约供气订单创建统一第三方支付单。 +func PayGasOrder(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + var request struct { + RequestNo string `json:"request_no" binding:"required"` + Channel string `json:"channel" binding:"required,oneof=alipay wechat"` + PayType string `json:"pay_type" binding:"required"` + OpenID string `json:"openid"` + } + if ctx.ShouldBindJSON(&request) != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + var order models.GasorderBasic + if err := impl.DBService.Where("identity = ? AND user_account_id = ? AND order_status IN ?", ctx.Param("identity"), account.ID, []int{common.StatusCreated, common.StatusAssigned}).First(&order).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + payOrder, err := payment.Create(ctx, payment.CreateInput{RequestNo: request.RequestNo, BusinessType: "gasorder", BusinessIdentity: order.Identity, + UserIdentity: account.Identity, Channel: request.Channel, PayType: request.PayType, Subject: "和气供气订单 " + order.OrderNo, OpenID: request.OpenID, Amount: order.PayableAmount}) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, payment.PublicResponse(payOrder)) +} + // ListGasContracts 返回用户自己的供气合同。 func ListGasContracts(ctx *gin.Context) { account, ok := common.UserAccount(ctx) @@ -47,7 +78,18 @@ func ListGasContracts(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, common.ResourceResponse(list)) + response := make([]gin.H, 0, len(list)) + for _, order := range list { + var items []models.GasorderItem + if err := impl.DBService.Where("gasorder_basic_id = ?", order.ID).Find(&items).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + value := common.ResourceResponse(order).(map[string]any) + value["items"] = common.ResourceResponse(items) + response = append(response, value) + } + infra.Response.Success(ctx, response) } // ListGasOrders 返回用户自己的供气订单。 diff --git a/backend/api/internal/logic/client/user/refund.go b/backend/api/internal/logic/client/user/refund.go new file mode 100644 index 0000000..06e12d6 --- /dev/null +++ b/backend/api/internal/logic/client/user/refund.go @@ -0,0 +1,48 @@ +package user + +import ( + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/payment" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" +) + +func CreateRefund(businessType string) gin.HandlerFunc { + return func(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + var request struct { + RequestNo string `json:"request_no" binding:"required"` + Reason string `json:"reason" binding:"required"` + Description string `json:"description"` + Items []payment.RefundItemInput `json:"items" binding:"required,min=1"` + } + if ctx.ShouldBindJSON(&request) != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + refund, err := payment.CreateRefund(account.ID, account.Identity, businessType, ctx.Param("identity"), payment.RefundInput{RequestNo: request.RequestNo, Reason: request.Reason, Description: request.Description, Items: request.Items}) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, common.ResourceResponse(refund)) + } +} +func ListRefunds(ctx *gin.Context) { + account, ok := common.UserAccount(ctx) + if !ok { + return + } + var list []models.PaymentRefund + if err := impl.DBService.Where("user_identity = ?", account.Identity).Order("created_at desc").Find(&list).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, common.ResourceResponse(list)) +} diff --git a/backend/api/internal/logic/client/user/shop.go b/backend/api/internal/logic/client/user/shop.go index 72057d1..48a75f3 100644 --- a/backend/api/internal/logic/client/user/shop.go +++ b/backend/api/internal/logic/client/user/shop.go @@ -8,6 +8,7 @@ import ( "git.apinb.com/bsm-sdk/core/infra" "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" common "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/payment" "git.apinb.com/heqiapp/platforms/backend/api/internal/models" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -21,7 +22,18 @@ func PublicProducts(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, common.ResourceResponse(list)) + response := make([]gin.H, 0, len(list)) + for _, order := range list { + var items []models.EcOrderItem + if err := impl.DBService.Where("ec_order_id = ?", order.ID).Find(&items).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + value := common.ResourceResponse(order).(map[string]any) + value["items"] = common.ResourceResponse(items) + response = append(response, value) + } + infra.Response.Success(ctx, response) } // CreateShopOrder 按服务端价格创建订单并原子扣减库存。 @@ -150,13 +162,31 @@ func PayShopOrder(ctx *gin.Context) { return } var request struct { - PaymentPassword string `json:"payment_password" binding:"required"` + PaymentPassword string `json:"payment_password"` RequestNo string `json:"request_no" binding:"required"` + Channel string `json:"channel" binding:"required,oneof=wallet alipay wechat"` + PayType string `json:"pay_type"` + OpenID string `json:"openid"` } if ctx.ShouldBindJSON(&request) != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } + if request.Channel != "wallet" { + var order models.EcOrder + if err := impl.DBService.Where("identity = ? AND user_account_id = ? AND order_status = ?", ctx.Param("identity"), account.ID, 16).First(&order).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + payOrder, err := payment.Create(ctx, payment.CreateInput{RequestNo: request.RequestNo, BusinessType: "ec_order", BusinessIdentity: order.Identity, + UserIdentity: account.Identity, Channel: request.Channel, PayType: request.PayType, Subject: "和气商城订单 " + order.OrderNo, OpenID: request.OpenID, Amount: order.PayableAmount}) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, payment.PublicResponse(payOrder)) + return + } err := impl.DBService.Transaction(func(tx *gorm.DB) error { var order models.EcOrder if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). diff --git a/backend/api/internal/logic/common/client_wallet.go b/backend/api/internal/logic/common/client_wallet.go index 94d7593..a34d88a 100644 --- a/backend/api/internal/logic/common/client_wallet.go +++ b/backend/api/internal/logic/common/client_wallet.go @@ -16,6 +16,7 @@ import ( "git.apinb.com/bsm-sdk/core/infra" "git.apinb.com/heqiapp/platforms/backend/api/internal/config" "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/payment" "git.apinb.com/heqiapp/platforms/backend/api/internal/models" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" @@ -126,6 +127,8 @@ func CreateRecharge(client string) gin.HandlerFunc { Amount int64 `json:"amount" binding:"required,gt=0"` Channel string `json:"channel" binding:"required,oneof=mock wechat alipay"` RequestNo string `json:"request_no" binding:"required"` + PayType string `json:"pay_type"` + OpenID string `json:"openid"` } if ctx.ShouldBindJSON(&request) != nil || request.Amount > config.Spec.Global.ManualRechargeMaxAmount || (request.Channel == "mock" && !config.Spec.Global.MockPaymentEnabled) { @@ -150,6 +153,16 @@ func CreateRecharge(client string) gin.HandlerFunc { } order = existing } + if request.Channel != "mock" { + payOrder, payErr := payment.Create(ctx, payment.CreateInput{RequestNo: request.RequestNo, BusinessType: "recharge", BusinessIdentity: order.Identity, + UserIdentity: owner.Identity, Channel: request.Channel, PayType: request.PayType, Subject: "和气钱包充值 " + order.RechargeNo, OpenID: request.OpenID, Amount: order.Amount}) + if payErr != nil { + infra.Response.Error(ctx, payErr) + return + } + infra.Response.Success(ctx, payment.PublicResponse(payOrder)) + return + } infra.Response.Success(ctx, ResourceResponse(order)) } } diff --git a/backend/api/internal/logic/common/resource.go b/backend/api/internal/logic/common/resource.go index 531c545..d1aae0d 100644 --- a/backend/api/internal/logic/common/resource.go +++ b/backend/api/internal/logic/common/resource.go @@ -1,4 +1,4 @@ -package common +package common import ( "encoding/json" @@ -419,7 +419,7 @@ var relationIdentityModels = map[string]any{ "gasorder_track_id": &models.GasorderTrack{}, "platform_role_id": &models.PlatformRole{}, "wallet_basic_id": &models.WalletBasic{}, - "wallet_payment_id": &models.WalletPayment{}, + "payment_order_id": &models.PaymentOrder{}, "wallet_bank_id": &models.WalletBank{}, "related_record_id": &models.WalletRecord{}, } diff --git a/backend/api/internal/logic/delivery/finance.go b/backend/api/internal/logic/delivery/finance.go index 697ba29..b4345a9 100644 --- a/backend/api/internal/logic/delivery/finance.go +++ b/backend/api/internal/logic/delivery/finance.go @@ -56,9 +56,9 @@ func listWalletChild(ctx *gin.Context, model any, table string) { } func ListBank(ctx *gin.Context) { listWalletChild(ctx, &models.WalletBank{}, "wallet_bank") } -func ListPayment(ctx *gin.Context) { listWalletChild(ctx, &models.WalletPayment{}, "wallet_payment") } +func ListPayment(ctx *gin.Context) { listWalletChild(ctx, &models.PaymentOrder{}, "payment_order") } func ListRecord(ctx *gin.Context) { listWalletChild(ctx, &models.WalletRecord{}, "wallet_record") } -func ListRefund(ctx *gin.Context) { listWalletChild(ctx, &models.WalletRefund{}, "wallet_refund") } +func ListRefund(ctx *gin.Context) { listWalletChild(ctx, &models.PaymentRefund{}, "payment_refund") } func ListRecharge(ctx *gin.Context) { point, _, ok := currentScope(ctx) if !ok { @@ -87,9 +87,9 @@ func getWalletChild(ctx *gin.Context, model any, table string) { } func GetBank(ctx *gin.Context) { getWalletChild(ctx, &models.WalletBank{}, "wallet_bank") } -func GetPayment(ctx *gin.Context) { getWalletChild(ctx, &models.WalletPayment{}, "wallet_payment") } +func GetPayment(ctx *gin.Context) { getWalletChild(ctx, &models.PaymentOrder{}, "payment_order") } func GetRecord(ctx *gin.Context) { getWalletChild(ctx, &models.WalletRecord{}, "wallet_record") } -func GetRefund(ctx *gin.Context) { getWalletChild(ctx, &models.WalletRefund{}, "wallet_refund") } +func GetRefund(ctx *gin.Context) { getWalletChild(ctx, &models.PaymentRefund{}, "payment_refund") } func GetRecharge(ctx *gin.Context) { point, _, ok := currentScope(ctx) if !ok { diff --git a/backend/api/internal/logic/delivery/menu.go b/backend/api/internal/logic/delivery/menu.go index 6a52256..6f75aa0 100644 --- a/backend/api/internal/logic/delivery/menu.go +++ b/backend/api/internal/logic/delivery/menu.go @@ -32,9 +32,9 @@ var adminMenus = []Menu{ {Identity: "finance", GroupCode: "finance", Name: "财务管理", Icon: "icon-bar-chart", Path: "/finance", SortNo: 70, Status: common.StatusEnable}, {Identity: "wallet_basic", ParentIdentity: "finance", GroupCode: "finance", Name: "钱包", Path: "/finance/wallet", SortNo: 1, Status: common.StatusEnable}, {Identity: "wallet_bank", ParentIdentity: "finance", GroupCode: "finance", Name: "银行卡", Path: "/finance/banks", SortNo: 2, Status: common.StatusEnable}, - {Identity: "wallet_payment", ParentIdentity: "finance", GroupCode: "finance", Name: "支付记录", Path: "/finance/payments", SortNo: 3, Status: common.StatusEnable}, + {Identity: "payment_order", ParentIdentity: "finance", GroupCode: "finance", Name: "支付记录", Path: "/finance/payments", SortNo: 3, Status: common.StatusEnable}, {Identity: "wallet_record", ParentIdentity: "finance", GroupCode: "finance", Name: "钱包流水", Path: "/finance/records", SortNo: 4, Status: common.StatusEnable}, - {Identity: "wallet_refund", ParentIdentity: "finance", GroupCode: "finance", Name: "退款记录", Path: "/finance/refunds", SortNo: 5, Status: common.StatusEnable}, + {Identity: "payment_refund", ParentIdentity: "finance", GroupCode: "finance", Name: "退款记录", Path: "/finance/refunds", SortNo: 5, Status: common.StatusEnable}, {Identity: "wallet_recharge", ParentIdentity: "finance", GroupCode: "finance", Name: "钱包充值", Path: "/finance/recharge", SortNo: 6, Status: common.StatusEnable}, {Identity: "wallet_apply_cash", ParentIdentity: "finance", GroupCode: "finance", Name: "提现申请", Path: "/finance/withdrawals", SortNo: 7, Status: common.StatusEnable}, {Identity: "fin_settlement", ParentIdentity: "finance", GroupCode: "finance", Name: "结算结果", Path: "/finance/settlements", SortNo: 8, Status: common.StatusEnable}, diff --git a/backend/api/internal/logic/delivery/order.go b/backend/api/internal/logic/delivery/order.go index e7a5351..acb7fe1 100644 --- a/backend/api/internal/logic/delivery/order.go +++ b/backend/api/internal/logic/delivery/order.go @@ -371,8 +371,8 @@ func AdjustOrderAmount(ctx *gin.Context) { } var paymentCount int64 if err := tx.Model(&models.GasorderPayment{}). - Joins("JOIN wallet_payment ON wallet_payment.id = gasorder_payment.wallet_payment_id"). - Where("gasorder_payment.gasorder_basic_id = ? AND wallet_payment.payment_status = ?", order.ID, common.StatusPaid). + Joins("JOIN payment_order ON payment_order.id = gasorder_payment.payment_order_id"). + Where("gasorder_payment.gasorder_basic_id = ? AND payment_order.payment_status = ?", order.ID, 23). Count(&paymentCount).Error; err != nil || paymentCount > 0 { return errors.New("paid order cannot be adjusted") } diff --git a/backend/api/internal/logic/delivery/resource_contract.go b/backend/api/internal/logic/delivery/resource_contract.go index 16c9d42..83a302d 100644 --- a/backend/api/internal/logic/delivery/resource_contract.go +++ b/backend/api/internal/logic/delivery/resource_contract.go @@ -22,9 +22,9 @@ func ExpectedResources() []ResourceContract { {"gasorder", "gasorder_basic", "/gasorder_basic", "list", "append_only"}, {"finance", "wallet_basic", "/wallet_basic", "list", "readonly"}, {"finance", "wallet_bank", "/wallet_bank", "list", "readonly"}, - {"finance", "wallet_payment", "/wallet_payment", "list", "readonly"}, + {"finance", "payment_order", "/payment_order", "list", "readonly"}, {"finance", "wallet_record", "/wallet_record", "list", "readonly"}, - {"finance", "wallet_refund", "/wallet_refund", "list", "readonly"}, + {"finance", "payment_refund", "/payment_refund", "list", "readonly"}, {"finance", "wallet_recharge", "/wallet_recharge", "list", "append_only"}, {"finance", "wallet_apply_cash", "/wallet_apply_cash", "list", "append_only"}, {"finance", "fin_settlement", "/fin_settlement", "list", "readonly"}, diff --git a/backend/api/internal/logic/gas/finance.go b/backend/api/internal/logic/gas/finance.go index 16c7b11..dff9b01 100644 --- a/backend/api/internal/logic/gas/finance.go +++ b/backend/api/internal/logic/gas/finance.go @@ -1,4 +1,4 @@ -package gas +package gas import ( "strings" @@ -45,14 +45,14 @@ func listWalletChild(ctx *gin.Context, model any, table string) { } func ListWalletBank(ctx *gin.Context) { listWalletChild(ctx, &models.WalletBank{}, "wallet_bank") } -func ListWalletPayment(ctx *gin.Context) { - listWalletChild(ctx, &models.WalletPayment{}, "wallet_payment") +func ListPaymentOrder(ctx *gin.Context) { + listWalletChild(ctx, &models.PaymentOrder{}, "payment_order") } func ListWalletRecord(ctx *gin.Context) { listWalletChild(ctx, &models.WalletRecord{}, "wallet_record") } -func ListWalletRefund(ctx *gin.Context) { - listWalletChild(ctx, &models.WalletRefund{}, "wallet_refund") +func ListPaymentRefund(ctx *gin.Context) { + listWalletChild(ctx, &models.PaymentRefund{}, "payment_refund") } func ListWalletApplyCash(ctx *gin.Context) { listWalletChild(ctx, &models.WalletApplyCash{}, "wallet_apply_cash") diff --git a/backend/api/internal/logic/gas/menu.go b/backend/api/internal/logic/gas/menu.go index 426d128..c9f944c 100644 --- a/backend/api/internal/logic/gas/menu.go +++ b/backend/api/internal/logic/gas/menu.go @@ -1,4 +1,4 @@ -package gas +package gas import "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" @@ -35,9 +35,9 @@ var adminMenus = []Menu{ {Identity: "finance", GroupCode: "finance", Name: "财务管理", Icon: "icon-bar-chart", Path: "/finance", SortNo: 70, Status: common.StatusEnable}, {Identity: "wallet_basic", ParentIdentity: "finance", GroupCode: "finance", Name: "钱包", Path: "/finance/wallet", SortNo: 1, Status: common.StatusEnable}, {Identity: "wallet_bank", ParentIdentity: "finance", GroupCode: "finance", Name: "银行卡", Path: "/finance/banks", SortNo: 2, Status: common.StatusEnable}, - {Identity: "wallet_payment", ParentIdentity: "finance", GroupCode: "finance", Name: "支付记录", Path: "/finance/payments", SortNo: 3, Status: common.StatusEnable}, + {Identity: "payment_order", ParentIdentity: "finance", GroupCode: "finance", Name: "支付记录", Path: "/finance/payments", SortNo: 3, Status: common.StatusEnable}, {Identity: "wallet_record", ParentIdentity: "finance", GroupCode: "finance", Name: "钱包流水", Path: "/finance/records", SortNo: 4, Status: common.StatusEnable}, - {Identity: "wallet_refund", ParentIdentity: "finance", GroupCode: "finance", Name: "退款记录", Path: "/finance/refunds", SortNo: 5, Status: common.StatusEnable}, + {Identity: "payment_refund", ParentIdentity: "finance", GroupCode: "finance", Name: "退款记录", Path: "/finance/refunds", SortNo: 5, Status: common.StatusEnable}, {Identity: "wallet_apply_cash", ParentIdentity: "finance", GroupCode: "finance", Name: "提现申请", Path: "/finance/withdrawals", SortNo: 6, Status: common.StatusEnable}, {Identity: "fin_settlement", ParentIdentity: "finance", GroupCode: "finance", Name: "财务结算", Path: "/finance/settlements", SortNo: 7, Status: common.StatusEnable}, {Identity: "fin_reconciliation", ParentIdentity: "finance", GroupCode: "finance", Name: "财务对账", Path: "/finance/reconciliations", SortNo: 8, Status: common.StatusEnable}, diff --git a/backend/api/internal/logic/gas/resource_contract.go b/backend/api/internal/logic/gas/resource_contract.go index 7c153b6..0de0099 100644 --- a/backend/api/internal/logic/gas/resource_contract.go +++ b/backend/api/internal/logic/gas/resource_contract.go @@ -1,4 +1,4 @@ -package gas +package gas type ResourceMode string @@ -30,8 +30,8 @@ func ExpectedResources() []ResourceContract { {"contract", "gasorder_contract_revision", ReadOnly}, {"gasorder", "gasorder_basic", AppendOnly}, {"contract", "product_info", ReadOnly}, {"finance", "wallet_basic", ReadOnly}, {"finance", "wallet_bank", ReadOnly}, - {"finance", "wallet_payment", ReadOnly}, {"finance", "wallet_record", ReadOnly}, - {"finance", "wallet_refund", ReadOnly}, {"finance", "wallet_apply_cash", AppendOnly}, + {"finance", "payment_order", ReadOnly}, {"finance", "wallet_record", ReadOnly}, + {"finance", "payment_refund", ReadOnly}, {"finance", "wallet_apply_cash", AppendOnly}, {"finance", "fin_settlement", ReadOnly}, {"finance", "fin_reconciliation", ReadOnly}, {"ticket", "cs_ticket", Writable}, } diff --git a/backend/api/internal/logic/payment/callback.go b/backend/api/internal/logic/payment/callback.go new file mode 100644 index 0000000..10ca1ee --- /dev/null +++ b/backend/api/internal/logic/payment/callback.go @@ -0,0 +1,129 @@ +package payment + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "net/http" + "strconv" + "time" + + "git.apinb.com/heqiapp/platforms/backend/api/internal/config" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" + "github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers" + "github.com/wechatpay-apiv3/wechatpay-go/core/downloader" + "github.com/wechatpay-apiv3/wechatpay-go/core/notify" + "github.com/wechatpay-apiv3/wechatpay-go/services/payments" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +func digest(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} + +// AlipayNotify 验证支付宝证书签名、商户身份、金额和状态后幂等入账。 +func AlipayNotify(ctx *gin.Context) { + client, err := alipayClient() + if err != nil { + ctx.String(http.StatusServiceUnavailable, "failure") + return + } + if err = ctx.Request.ParseForm(); err != nil || client.VerifySign(ctx, ctx.Request.PostForm) != nil { + ctx.String(http.StatusBadRequest, "failure") + return + } + values := ctx.Request.PostForm + amount, amountErr := strconv.ParseFloat(values.Get("total_amount"), 64) + if amountErr != nil || values.Get("app_id") != config.Spec.Payment.Alipay.AppID || (values.Get("trade_status") != "TRADE_SUCCESS" && values.Get("trade_status") != "TRADE_FINISHED") { + ctx.String(http.StatusBadRequest, "failure") + return + } + err = complete(values.Get("out_trade_no"), values.Get("trade_no"), int64(amount*100+0.5), "alipay", digest(values.Encode())) + if err != nil { + ctx.String(http.StatusConflict, "failure") + return + } + ctx.String(http.StatusOK, "success") +} + +// WechatNotify 使用微信平台证书验签并解密 API v3 通知后幂等入账。 +func WechatNotify(ctx *gin.Context) { + if _, err := wechatClient(ctx); err != nil { + ctx.JSON(http.StatusServiceUnavailable, gin.H{"code": "FAIL", "message": "channel unavailable"}) + return + } + visitor := downloader.MgrInstance().GetCertificateVisitor(config.Spec.Payment.Wechat.MerchantID) + handler := notify.NewNotifyHandler(config.Spec.Payment.Wechat.APIv3Key, verifiers.NewSHA256WithRSAVerifier(visitor)) + transaction := new(payments.Transaction) + if _, err := handler.ParseNotifyRequest(ctx, ctx.Request, transaction); err != nil || transaction.OutTradeNo == nil || transaction.Amount == nil || transaction.Amount.Total == nil || transaction.TradeState == nil || *transaction.TradeState != "SUCCESS" { + ctx.JSON(http.StatusBadRequest, gin.H{"code": "FAIL", "message": "invalid notification"}) + return + } + tradeNo := "" + if transaction.TransactionId != nil { + tradeNo = *transaction.TransactionId + } + if err := complete(*transaction.OutTradeNo, tradeNo, *transaction.Amount.Total, "wechat", digest(transaction.String())); err != nil { + ctx.JSON(http.StatusConflict, gin.H{"code": "FAIL", "message": "payment conflict"}) + return + } + ctx.JSON(http.StatusOK, gin.H{"code": "SUCCESS", "message": "成功"}) +} + +func complete(paymentNo, tradeNo string, amount int64, channel, callbackDigest string) error { + return impl.DBService.Transaction(func(tx *gorm.DB) error { + var order models.PaymentOrder + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("payment_no = ?", paymentNo).First(&order).Error; err != nil { + return err + } + if order.PaymentStatus == StatusPaid { + return nil + } + if order.Channel != channel || order.Amount != amount { + return errors.New("payment identity or amount mismatch") + } + if time.Now().After(order.ExpiresAt) { + return tx.Model(&order).Updates(map[string]any{"payment_status": 50, "channel_trade_no": tradeNo, "callback_digest": callbackDigest, "failure_code": "PAID_AFTER_EXPIRED"}).Error + } + now := time.Now() + if err := tx.Model(&order).Updates(map[string]any{"payment_status": StatusPaid, "channel_trade_no": tradeNo, "callback_digest": callbackDigest, "paid_at": &now}).Error; err != nil { + return err + } + switch order.BusinessType { + case "ec_order": + return tx.Model(&models.EcOrder{}).Where("identity = ? AND order_status = ?", order.BusinessIdentity, 16).Updates(map[string]any{"order_status": 18, "paid_at": &now}).Error + case "gasorder": + return tx.Model(&models.GasorderBasic{}).Where("identity = ? AND order_status IN ?", order.BusinessIdentity, []int{16, 18}).Update("order_status", 35).Error + case "recharge": + return completeRecharge(tx, order, now) + } + return errors.New("unsupported payment business") + }) +} + +func completeRecharge(tx *gorm.DB, payment models.PaymentOrder, now time.Time) error { + var recharge models.WalletRechargeOrder + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ? AND recharge_status = ?", payment.BusinessIdentity, 10).First(&recharge).Error; err != nil { + return err + } + var wallet models.WalletBasic + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&wallet, recharge.WalletBasicID).Error; err != nil { + return err + } + wallet.Balance += payment.Amount + if err := tx.Model(&wallet).Update("balance", wallet.Balance).Error; err != nil { + return err + } + if err := tx.Model(&recharge).Updates(map[string]any{"recharge_status": 23, "completed_at": &now}).Error; err != nil { + return err + } + date := now.In(time.Local) + return tx.Create(&models.WalletRecord{Entity: models.Entity{Identity: models.NewIdentity(), Status: 1}, WalletBasicID: wallet.ID, + RecordNo: "WR" + now.Format("20060102150405.000000"), RequestNo: "recharge:" + recharge.Identity, Direction: "income", TradeType: "recharge", + Amount: payment.Amount, BalanceAfter: wallet.Balance, WithdrawalBalanceAfter: wallet.WithdrawalBalance, InTradeNo: recharge.RechargeNo, + PayChannel: payment.Channel, OperatorIdentity: payment.UserIdentity, Ymd: int32(date.Year()*10000 + int(date.Month())*100 + date.Day()), Ym: int32(date.Year()*100 + int(date.Month()))}).Error +} diff --git a/backend/api/internal/logic/payment/close.go b/backend/api/internal/logic/payment/close.go new file mode 100644 index 0000000..52a8155 --- /dev/null +++ b/backend/api/internal/logic/payment/close.go @@ -0,0 +1,68 @@ +package payment + +import ( + "context" + "fmt" + "net/http" + "time" + + "git.apinb.com/heqiapp/platforms/backend/api/internal/config" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" + "github.com/smartwalle/alipay/v3" +) + +// CloseExpired 批量关闭已过期的渠道支付单;单笔失败留待下一轮安全重试。 +func CloseExpired(ctx context.Context, limit int) (int, error) { + var orders []models.PaymentOrder + if err := impl.DBService.Where("payment_status = ? AND expires_at <= ?", StatusPending, time.Now()).Order("expires_at asc").Limit(limit).Find(&orders).Error; err != nil { + return 0, err + } + closed := 0 + for _, order := range orders { + if err := closeChannelOrder(ctx, order); err != nil { + continue + } + now := time.Now() + result := impl.DBService.Model(&models.PaymentOrder{}).Where("id = ? AND payment_status = ?", order.ID, StatusPending).Updates(map[string]any{"payment_status": StatusClosed, "closed_at": &now}) + if result.Error == nil && result.RowsAffected == 1 { + closed++ + } + } + return closed, nil +} + +func closeChannelOrder(ctx context.Context, order models.PaymentOrder) error { + if order.Channel == "alipay" { + client, err := alipayClient() + if err != nil { + return err + } + _, err = client.TradeClose(ctx, alipay.TradeClose{OutTradeNo: order.PaymentNo}) + return err + } + if order.Channel == "wechat" { + client, err := wechatClient(ctx) + if err != nil { + return err + } + _, err = client.Post(ctx, fmt.Sprintf("https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/%s/close", order.PaymentNo), map[string]string{"mchid": config.Spec.Payment.Wechat.MerchantID}) + return err + } + return nil +} + +// CloseExpiredHandler 只接受 Worker 共享凭证,不暴露为平台用户动作。 +func CloseExpiredHandler(ctx *gin.Context) { + if config.Spec.Payment.InternalServiceToken == "" || ctx.GetHeader("X-Heqi-Worker-Token") != config.Spec.Payment.InternalServiceToken { + ctx.AbortWithStatus(http.StatusUnauthorized) + return + } + count, err := CloseExpired(ctx, 100) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "close expired payments failed"}) + return + } + ctx.JSON(http.StatusOK, gin.H{"closed": count}) +} diff --git a/backend/api/internal/logic/payment/provider.go b/backend/api/internal/logic/payment/provider.go new file mode 100644 index 0000000..27545f8 --- /dev/null +++ b/backend/api/internal/logic/payment/provider.go @@ -0,0 +1,139 @@ +// Package payment 统一承载支付宝、微信和钱包支付请求及渠道回调。 +package payment + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "strings" + + "git.apinb.com/heqiapp/platforms/backend/api/internal/config" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/smartwalle/alipay/v3" + wechatcore "github.com/wechatpay-apiv3/wechatpay-go/core" + "github.com/wechatpay-apiv3/wechatpay-go/core/option" + wechatapp "github.com/wechatpay-apiv3/wechatpay-go/services/payments/app" + wechatjsapi "github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi" + wechatnative "github.com/wechatpay-apiv3/wechatpay-go/services/payments/native" + "github.com/wechatpay-apiv3/wechatpay-go/utils" +) + +var ErrChannelUnavailable = errors.New("payment channel is not configured") + +func money(amount int64) string { return fmt.Sprintf("%d.%02d", amount/100, amount%100) } + +func createChannelOrder(ctx context.Context, order models.PaymentOrder, openID string) (string, error) { + switch order.Channel { + case "alipay": + return createAlipayOrder(order) + case "wechat": + return createWechatOrder(ctx, order, openID) + default: + return "", errors.New("unsupported payment channel") + } +} + +func alipayClient() (*alipay.Client, error) { + cfg := config.Spec.Payment.Alipay + if !cfg.Enabled || cfg.AppID == "" || cfg.PrivateKeyPath == "" { + return nil, ErrChannelUnavailable + } + privateKey, err := os.ReadFile(cfg.PrivateKeyPath) + if err != nil { + return nil, err + } + client, err := alipay.New(cfg.AppID, string(privateKey), cfg.Production) + if err != nil { + return nil, err + } + if err = client.LoadAppCertPublicKeyFromFile(cfg.AppPublicCertPath); err != nil { + return nil, err + } + if err = client.LoadAliPayRootCertFromFile(cfg.AlipayRootCertPath); err != nil { + return nil, err + } + if err = client.LoadAlipayCertPublicKeyFromFile(cfg.AlipayPublicCertPath); err != nil { + return nil, err + } + return client, nil +} + +func createAlipayOrder(order models.PaymentOrder) (string, error) { + client, err := alipayClient() + if err != nil { + return "", err + } + trade := alipay.Trade{NotifyURL: config.Spec.Payment.Alipay.NotifyURL, ReturnURL: config.Spec.Payment.Alipay.ReturnURL, + Subject: order.Subject, OutTradeNo: order.PaymentNo, TotalAmount: money(order.Amount), TimeoutExpress: fmt.Sprintf("%dm", config.Spec.Payment.ExpireMinutes)} + if order.PayType == "app" { + trade.ProductCode = "QUICK_MSECURITY_PAY" + return client.TradeAppPay(alipay.TradeAppPay{Trade: trade}) + } + if order.PayType == "wap" { + trade.ProductCode = "QUICK_WAP_WAY" + value, err := client.TradeWapPay(alipay.TradeWapPay{Trade: trade}) + if err != nil { + return "", err + } + return value.String(), nil + } + return "", errors.New("unsupported alipay product") +} + +func wechatClient(ctx context.Context) (*wechatcore.Client, error) { + cfg := config.Spec.Payment.Wechat + if !cfg.Enabled || cfg.MerchantID == "" || cfg.MerchantPrivateKeyPath == "" || len(cfg.APIv3Key) != 32 { + return nil, ErrChannelUnavailable + } + key, err := utils.LoadPrivateKeyWithPath(cfg.MerchantPrivateKeyPath) + if err != nil { + return nil, err + } + return wechatcore.NewClient(ctx, option.WithWechatPayAutoAuthCipher(cfg.MerchantID, cfg.MerchantCertificateSerial, key, cfg.APIv3Key)) +} + +func createWechatOrder(ctx context.Context, order models.PaymentOrder, openID string) (string, error) { + cfg := config.Spec.Payment.Wechat + client, err := wechatClient(ctx) + if err != nil { + return "", err + } + marshal := func(value any) (string, error) { raw, err := json.Marshal(value); return string(raw), err } + switch order.PayType { + case "app": + resp, _, err := (&wechatapp.AppApiService{Client: client}).PrepayWithRequestPayment(ctx, wechatapp.PrepayRequest{ + Appid: wechatcore.String(cfg.AppAppID), Mchid: wechatcore.String(cfg.MerchantID), Description: wechatcore.String(order.Subject), + OutTradeNo: wechatcore.String(order.PaymentNo), TimeExpire: &order.ExpiresAt, NotifyUrl: wechatcore.String(cfg.NotifyURL), Amount: &wechatapp.Amount{Total: wechatcore.Int64(order.Amount)}}) + if err != nil { + return "", err + } + return marshal(resp) + case "jsapi", "mini": + if strings.TrimSpace(openID) == "" { + return "", errors.New("openid is required") + } + appid := cfg.OfficialAccountAppID + if order.PayType == "mini" { + appid = cfg.MiniProgramAppID + } + resp, _, err := (&wechatjsapi.JsapiApiService{Client: client}).PrepayWithRequestPayment(ctx, wechatjsapi.PrepayRequest{ + Appid: wechatcore.String(appid), Mchid: wechatcore.String(cfg.MerchantID), Description: wechatcore.String(order.Subject), + OutTradeNo: wechatcore.String(order.PaymentNo), TimeExpire: &order.ExpiresAt, NotifyUrl: wechatcore.String(cfg.NotifyURL), + Amount: &wechatjsapi.Amount{Total: wechatcore.Int64(order.Amount)}, Payer: &wechatjsapi.Payer{Openid: wechatcore.String(openID)}}) + if err != nil { + return "", err + } + return marshal(resp) + case "native": + resp, _, err := (&wechatnative.NativeApiService{Client: client}).Prepay(ctx, wechatnative.PrepayRequest{ + Appid: wechatcore.String(cfg.AppAppID), Mchid: wechatcore.String(cfg.MerchantID), Description: wechatcore.String(order.Subject), + OutTradeNo: wechatcore.String(order.PaymentNo), TimeExpire: &order.ExpiresAt, NotifyUrl: wechatcore.String(cfg.NotifyURL), Amount: &wechatnative.Amount{Total: wechatcore.Int64(order.Amount)}}) + if err != nil { + return "", err + } + return marshal(resp) + } + return "", errors.New("unsupported wechat product") +} diff --git a/backend/api/internal/logic/payment/refund.go b/backend/api/internal/logic/payment/refund.go new file mode 100644 index 0000000..7e0baea --- /dev/null +++ b/backend/api/internal/logic/payment/refund.go @@ -0,0 +1,166 @@ +package payment + +import ( + "errors" + "time" + + "git.apinb.com/heqiapp/platforms/backend/api/internal/config" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type RefundItemInput struct { + Identity string `json:"identity"` + Quantity int `json:"quantity"` +} +type RefundInput struct { + RequestNo, Reason, Description string + Items []RefundItemInput +} + +// CreateRefund 校验本人订单、退款窗口、履约状态、数量和累计金额后创建待审核退款。 +func CreateRefund(userID uint64, userIdentity, businessType, businessIdentity string, input RefundInput) (models.PaymentRefund, error) { + var result models.PaymentRefund + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + var pay models.PaymentOrder + if err := tx.Where("business_type = ? AND business_identity = ? AND user_identity = ? AND payment_status = ?", businessType, businessIdentity, userIdentity, StatusPaid).Order("paid_at desc").First(&pay).Error; err != nil { + return err + } + if pay.PaidAt == nil || time.Now().After(pay.PaidAt.AddDate(0, 0, config.Spec.Payment.RefundWindowDays)) { + return errors.New("refund window expired") + } + var wallet models.WalletBasic + if err := tx.Where("owner_type = ? AND owner_identity = ? AND owner_id = ?", "user", userIdentity, userID).First(&wallet).Error; err != nil { + return err + } + items, whole, err := refundItems(tx, businessType, businessIdentity, userID, input.Items) + if err != nil { + return err + } + var used int64 + if err := tx.Model(&models.PaymentRefund{}).Where("payment_order_id = ? AND refund_status IN ?", pay.ID, []int{10, 20}).Select("COALESCE(SUM(amount),0)").Scan(&used).Error; err != nil { + return err + } + var amount int64 + for _, item := range items { + amount += item.Amount + } + if whole { + amount = pay.Amount - used + } + if amount <= 0 || used+amount > pay.Amount { + return errors.New("refund amount exceeds payment") + } + result = models.PaymentRefund{Entity: models.Entity{Identity: models.NewIdentity(), Status: 1}, RefundStatus: 10, PaymentOrderID: pay.ID, WalletBasicID: wallet.ID, + RefundNo: "RF" + time.Now().Format("20060102150405.000000"), RequestNo: input.RequestNo, BusinessType: businessType, BusinessIdentity: businessIdentity, + UserIdentity: userIdentity, Amount: amount, Reason: input.Reason, Description: input.Description} + if err := tx.Create(&result).Error; err != nil { + return err + } + for index := range items { + items[index].PaymentRefundID = result.ID + items[index].Identity = models.NewIdentity() + if err := tx.Create(&items[index]).Error; err != nil { + return err + } + } + return nil + }) + return result, err +} + +func refundItems(tx *gorm.DB, businessType, identity string, userID uint64, requested []RefundItemInput) ([]models.PaymentRefundItem, bool, error) { + if len(requested) == 0 { + return nil, false, errors.New("refund items are required") + } + result := make([]models.PaymentRefundItem, 0, len(requested)) + whole := true + switch businessType { + case "ec_order": + var order models.EcOrder + if err := tx.Where("identity = ? AND user_account_id = ? AND order_status = ? AND logistics_status < ?", identity, userID, 18, 30).First(&order).Error; err != nil { + return nil, false, err + } + var all []models.EcOrderItem + if err := tx.Where("ec_order_id = ?", order.ID).Find(&all).Error; err != nil { + return nil, false, err + } + by := map[string]models.EcOrderItem{} + for _, value := range all { + by[value.Identity] = value + } + for _, request := range requested { + value, ok := by[request.Identity] + if !ok || request.Quantity <= 0 || request.Quantity > value.Quantity { + return nil, false, gorm.ErrInvalidData + } + if request.Quantity != value.Quantity { + whole = false + } + result = append(result, models.PaymentRefundItem{OrderItemIdentity: value.Identity, Quantity: request.Quantity, Amount: value.SaleAmount * int64(request.Quantity)}) + } + if len(requested) != len(all) { + whole = false + } + case "gasorder": + var order models.GasorderBasic + if err := tx.Where("identity = ? AND user_account_id = ? AND order_status = ?", identity, userID, 35).First(&order).Error; err != nil { + return nil, false, err + } + var all []models.GasorderItem + if err := tx.Where("gasorder_basic_id = ?", order.ID).Find(&all).Error; err != nil { + return nil, false, err + } + by := map[string]models.GasorderItem{} + for _, value := range all { + by[value.Identity] = value + } + for _, request := range requested { + value, ok := by[request.Identity] + if !ok || request.Quantity != 1 { + return nil, false, gorm.ErrInvalidData + } + result = append(result, models.PaymentRefundItem{OrderItemIdentity: value.Identity, Quantity: 1, Amount: value.UnitPrice}) + } + if len(requested) != len(all) { + whole = false + } + default: + return nil, false, errors.New("business does not support refund") + } + return result, whole, nil +} + +// ReviewRefund 驳回时记录原因;通过时在同一事务中立即增加钱包与不可变流水。 +func ReviewRefund(identity, reviewer, remark string, approve bool) error { + return impl.DBService.Transaction(func(tx *gorm.DB) error { + var refund models.PaymentRefund + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ? AND refund_status = ?", identity, 10).First(&refund).Error; err != nil { + return err + } + now := time.Now() + if !approve { + if remark == "" { + return errors.New("reject reason required") + } + return tx.Model(&refund).Updates(map[string]any{"refund_status": 30, "review_remark": remark, "reviewer_identity": reviewer, "reviewed_at": &now}).Error + } + var wallet models.WalletBasic + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&wallet, refund.WalletBasicID).Error; err != nil { + return err + } + wallet.Balance += refund.Amount + if err := tx.Model(&wallet).Update("balance", wallet.Balance).Error; err != nil { + return err + } + if err := tx.Model(&refund).Updates(map[string]any{"refund_status": 20, "review_remark": remark, "reviewer_identity": reviewer, "reviewed_at": &now, "completed_at": &now}).Error; err != nil { + return err + } + date := now.In(time.Local) + return tx.Create(&models.WalletRecord{Entity: models.Entity{Identity: models.NewIdentity(), Status: 1}, WalletBasicID: wallet.ID, RecordNo: models.NewIdentity(), RequestNo: "refund:" + refund.Identity, + Direction: "income", TradeType: "refund", Amount: refund.Amount, BalanceAfter: wallet.Balance, WithdrawalBalanceAfter: wallet.WithdrawalBalance, InTradeNo: refund.RefundNo, PayChannel: "wallet", OperatorIdentity: reviewer, + Ymd: int32(date.Year()*10000 + int(date.Month())*100 + date.Day()), Ym: int32(date.Year()*100 + int(date.Month())), Remark: "退款审核:" + remark}).Error + }) +} diff --git a/backend/api/internal/logic/payment/service.go b/backend/api/internal/logic/payment/service.go new file mode 100644 index 0000000..b72980d --- /dev/null +++ b/backend/api/internal/logic/payment/service.go @@ -0,0 +1,61 @@ +package payment + +import ( + "context" + "errors" + "time" + + "git.apinb.com/heqiapp/platforms/backend/api/internal/config" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "gorm.io/gorm" +) + +const ( + StatusPending = 10 + StatusPaid = 23 + StatusClosed = 30 +) + +type CreateInput struct { + RequestNo, BusinessType, BusinessIdentity, UserIdentity string + Channel, PayType, Subject, OpenID string + Amount int64 +} + +// Create 创建幂等支付单并向渠道请求客户端调起参数。 +func Create(ctx context.Context, input CreateInput) (models.PaymentOrder, error) { + if input.Amount <= 0 || input.RequestNo == "" || input.BusinessIdentity == "" || input.UserIdentity == "" { + return models.PaymentOrder{}, gorm.ErrInvalidData + } + var existing models.PaymentOrder + if err := impl.DBService.Where("business_type = ? AND request_no = ?", input.BusinessType, input.RequestNo).First(&existing).Error; err == nil { + if existing.BusinessIdentity != input.BusinessIdentity || existing.Amount != input.Amount || existing.Channel != input.Channel || existing.PayType != input.PayType { + return existing, errors.New("idempotency conflict") + } + return existing, nil + } + order := models.PaymentOrder{Entity: models.Entity{Identity: models.NewIdentity(), Status: 1}, PaymentStatus: StatusPending, + PaymentNo: "PAY" + time.Now().Format("20060102150405.000000"), RequestNo: input.RequestNo, BusinessType: input.BusinessType, + BusinessIdentity: input.BusinessIdentity, UserIdentity: input.UserIdentity, MerchantIdentity: "platform", Channel: input.Channel, + PayType: input.PayType, Amount: input.Amount, Subject: input.Subject, ExpiresAt: time.Now().Add(time.Duration(config.Spec.Payment.ExpireMinutes) * time.Minute)} + args, err := createChannelOrder(ctx, order, input.OpenID) + if err != nil { + return order, err + } + order.ClientArgs = args + if err = impl.DBService.Create(&order).Error; err != nil { + return order, err + } + return order, nil +} + +// PublicResponse 仅返回客户端调起支付所需的非密钥参数。 +func PublicResponse(order models.PaymentOrder) map[string]any { + response := map[string]any{"identity": order.Identity, "payment_no": order.PaymentNo, "payment_status": order.PaymentStatus, + "channel": order.Channel, "pay_type": order.PayType, "amount": order.Amount, "client_args": order.ClientArgs, "expires_at": order.ExpiresAt} + if order.Channel == "wechat" && order.PayType == "app" { + response["app_id"] = config.Spec.Payment.Wechat.AppAppID + } + return response +} diff --git a/backend/api/internal/logic/platform/dashboard/statistics.go b/backend/api/internal/logic/platform/dashboard/statistics.go index 0abf1ca..9fcdea1 100644 --- a/backend/api/internal/logic/platform/dashboard/statistics.go +++ b/backend/api/internal/logic/platform/dashboard/statistics.go @@ -103,9 +103,9 @@ func GetDashboardOverview() (DashboardStatistics, error) { Scan(&result.TodayOrderAmount).Error; err != nil { return DashboardStatistics{}, err } - if err := impl.DBService.Model(&models.WalletPayment{}). + if err := impl.DBService.Model(&models.PaymentOrder{}). Select("COALESCE(SUM(amount), 0)"). - Where("status = ? AND payment_status = ?", common.StatusEnable, common.StatusPaid). + Where("status = ? AND payment_status = ?", common.StatusEnable, 23). Scan(&result.PaidAmount).Error; err != nil { return DashboardStatistics{}, err } @@ -126,9 +126,9 @@ func GetDashboardOverview() (DashboardStatistics, error) { } result.ProductStatuses = namedStatuses(productStatuses, productStatusNames) - if err := impl.DBService.Model(&models.WalletPayment{}). + if err := impl.DBService.Model(&models.PaymentOrder{}). Select("pay_channel AS name, COALESCE(SUM(amount), 0) AS value"). - Where("status = ? AND payment_status = ?", common.StatusEnable, common.StatusPaid). + Where("status = ? AND payment_status = ?", common.StatusEnable, 23). Group("pay_channel").Order("value DESC").Scan(&result.PaymentChannels).Error; err != nil { return DashboardStatistics{}, err } diff --git a/backend/api/internal/logic/platform/menu.go b/backend/api/internal/logic/platform/menu.go index e7b9998..360f785 100644 --- a/backend/api/internal/logic/platform/menu.go +++ b/backend/api/internal/logic/platform/menu.go @@ -68,8 +68,9 @@ var PlatformMenus = [][]Menu{ {Identity: "finance", GroupCode: "finance", Name: "财务管理", Icon: "icon-bar-chart", Path: "/finance", SortNo: 100, Status: common.StatusEnable}, {Identity: "wallet_apply_cash", ParentIdentity: "finance", GroupCode: "finance", Name: "提现记录", Path: "/finance/withdrawals", SortNo: 1, Status: common.StatusEnable}, {Identity: "fin_payment", ParentIdentity: "finance", GroupCode: "finance", Name: "支付记录", Path: "/finance/payments", SortNo: 2, Status: common.StatusEnable}, - {Identity: "fin_settlement", ParentIdentity: "finance", GroupCode: "finance", Name: "财务结算", Path: "/finance/settlements", SortNo: 3, Status: common.StatusEnable}, - {Identity: "fin_reconciliation", ParentIdentity: "finance", GroupCode: "finance", Name: "财务对账", Path: "/finance/reconciliations", SortNo: 4, Status: common.StatusEnable}, + {Identity: "payment_refund", ParentIdentity: "finance", GroupCode: "finance", Name: "退款审核", Path: "/finance/refunds", SortNo: 3, Status: common.StatusEnable}, + {Identity: "fin_settlement", ParentIdentity: "finance", GroupCode: "finance", Name: "财务结算", Path: "/finance/settlements", SortNo: 4, Status: common.StatusEnable}, + {Identity: "fin_reconciliation", ParentIdentity: "finance", GroupCode: "finance", Name: "财务对账", Path: "/finance/reconciliations", SortNo: 5, Status: common.StatusEnable}, }, { {Identity: "content", GroupCode: "content", Name: "内容管理", Icon: "icon-file", Path: "/content", SortNo: 110, Status: common.StatusEnable}, diff --git a/backend/api/internal/logic/platform/payment/refund.go b/backend/api/internal/logic/platform/payment/refund.go new file mode 100644 index 0000000..d48464d --- /dev/null +++ b/backend/api/internal/logic/platform/payment/refund.go @@ -0,0 +1,33 @@ +package payment + +import ( + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + paylogic "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/payment" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" +) + +func ListRefund(ctx *gin.Context) { common.ListResource(ctx, &models.PaymentRefund{}) } +func GetRefund(ctx *gin.Context) { common.GetResource(ctx, &models.PaymentRefund{}) } +func review(approve bool) gin.HandlerFunc { + return func(ctx *gin.Context) { + var request struct { + Remark string `json:"remark"` + } + if ctx.ShouldBindJSON(&request) != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + operator, _ := common.PlatformOperator(ctx) + if err := paylogic.ReviewRefund(ctx.Param("identity"), operator, request.Remark, approve); err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"reviewed": true}) + } +} + +var ApproveRefund = review(true) +var RejectRefund = review(false) diff --git a/backend/api/internal/logic/platform/resource_contract.go b/backend/api/internal/logic/platform/resource_contract.go index 4df8c2c..24f7b11 100644 --- a/backend/api/internal/logic/platform/resource_contract.go +++ b/backend/api/internal/logic/platform/resource_contract.go @@ -1,4 +1,4 @@ -package platform +package platform import ( "net/http" @@ -89,7 +89,7 @@ func ExpectedResources() []ResourceContract { resourceContract("finance", "fin_payment", ReadOnly, "list"), resourceContract("finance", "fin_settlement", Writable, "list"), resourceContract("finance", "fin_reconciliation", ReadOnly, "list"), resourceContract("content", "cms_content", Writable, "list"), resourceContract("customer_service", "cs_ticket", Writable, "list"), resourceContract("platform", "platform_account", Writable, "list"), resourceContract("platform", "platform_role", Writable, "list"), resourceContract("platform", "platform_menu", ReadOnly, "tree"), - resourceContract("wallet", "wallet_basic", ReadOnly, "list"), resourceContract("wallet", "wallet_bank", ReadOnly, "list"), resourceContract("wallet", "wallet_payment", ReadOnly, "list"), resourceContract("wallet", "wallet_record", ReadOnly, "list"), resourceContract("wallet", "wallet_refund", ReadOnly, "list"), resourceContract("wallet", "wallet_apply_cash", ReadOnly, "list"), + resourceContract("wallet", "wallet_basic", ReadOnly, "list"), resourceContract("wallet", "wallet_bank", ReadOnly, "list"), resourceContract("wallet", "payment_order", ReadOnly, "list"), resourceContract("wallet", "wallet_record", ReadOnly, "list"), resourceContract("wallet", "payment_refund", ReadOnly, "list"), resourceContract("wallet", "wallet_apply_cash", ReadOnly, "list"), } } diff --git a/backend/api/internal/logic/platform/wallet/wallet.go b/backend/api/internal/logic/platform/wallet/wallet.go index ff581b5..4157801 100644 --- a/backend/api/internal/logic/platform/wallet/wallet.go +++ b/backend/api/internal/logic/platform/wallet/wallet.go @@ -1,4 +1,4 @@ -package wallet +package wallet import ( "errors" @@ -44,12 +44,12 @@ func ListWalletBasic(ctx *gin.Context) { func GetWalletBasic(ctx *gin.Context) { getWalletByIdentity[models.WalletBasic](ctx) } func ListWalletBank(ctx *gin.Context) { listWalletPage[models.WalletBank](ctx, "", nil) } func GetWalletBank(ctx *gin.Context) { getWalletByIdentity[models.WalletBank](ctx) } -func ListWalletPayment(ctx *gin.Context) { listWalletPage[models.WalletPayment](ctx, "", nil) } -func GetWalletPayment(ctx *gin.Context) { getWalletByIdentity[models.WalletPayment](ctx) } +func ListPaymentOrder(ctx *gin.Context) { listWalletPage[models.PaymentOrder](ctx, "", nil) } +func GetPaymentOrder(ctx *gin.Context) { getWalletByIdentity[models.PaymentOrder](ctx) } func ListWalletRecord(ctx *gin.Context) { listWalletPage[models.WalletRecord](ctx, "", nil) } func GetWalletRecord(ctx *gin.Context) { getWalletByIdentity[models.WalletRecord](ctx) } -func ListWalletRefund(ctx *gin.Context) { listWalletPage[models.WalletRefund](ctx, "", nil) } -func GetWalletRefund(ctx *gin.Context) { getWalletByIdentity[models.WalletRefund](ctx) } +func ListPaymentRefund(ctx *gin.Context) { listWalletPage[models.PaymentRefund](ctx, "", nil) } +func GetPaymentRefund(ctx *gin.Context) { getWalletByIdentity[models.PaymentRefund](ctx) } func ListWalletApplyCash(ctx *gin.Context) { listWalletPage[models.WalletApplyCash](ctx, "", nil) } func GetWalletApplyCash(ctx *gin.Context) { getWalletByIdentity[models.WalletApplyCash](ctx) } diff --git a/backend/api/internal/models/gasorder_payment.go b/backend/api/internal/models/gasorder_payment.go index d435f71..a31b8e8 100644 --- a/backend/api/internal/models/gasorder_payment.go +++ b/backend/api/internal/models/gasorder_payment.go @@ -6,7 +6,7 @@ import "git.apinb.com/bsm-sdk/core/database" type GasorderPayment struct { Entity // 公共实体字段,Status 保存支付尝试状态 GasorderBasicID uint64 `gorm:"column:gasorder_basic_id;not null;index;uniqueIndex:idx_gasorder_payment_attempt" json:"gasorder_basic_id"` // 订单自增主键 - WalletPaymentID uint64 `gorm:"column:wallet_payment_id;not null;uniqueIndex" json:"wallet_payment_id"` // 钱包支付记录自增主键 + PaymentOrderID uint64 `gorm:"column:payment_order_id;not null;uniqueIndex" json:"payment_order_id"` // 钱包支付记录自增主键 AttemptNo int `gorm:"column:attempt_no;not null;uniqueIndex:idx_gasorder_payment_attempt" json:"attempt_no"` // 支付尝试序号 Amount int64 `gorm:"column:amount;not null;check:amount > 0" json:"amount"` // 支付金额,单位分 } diff --git a/backend/api/internal/models/payment_order.go b/backend/api/internal/models/payment_order.go new file mode 100644 index 0000000..18ed526 --- /dev/null +++ b/backend/api/internal/models/payment_order.go @@ -0,0 +1,34 @@ +package models + +import ( + "time" + + "git.apinb.com/bsm-sdk/core/database" +) + +// PaymentOrder 对应 payment_order,是全项目唯一的支付尝试事实。 +type PaymentOrder struct { + Entity // 公共实体字段 + PaymentStatus int `gorm:"column:payment_status;not null;default:10;index" json:"payment_status"` // 10待支付、20确认中、23成功、30关闭、40失败、50异常 + PaymentNo string `gorm:"column:payment_no;type:varchar(64);not null;uniqueIndex" json:"payment_no"` // 平台支付单号 + RequestNo string `gorm:"column:request_no;type:varchar(128);not null;uniqueIndex:idx_payment_request" json:"request_no"` // 客户端幂等请求号 + BusinessType string `gorm:"column:business_type;type:varchar(32);not null;index;uniqueIndex:idx_payment_request" json:"business_type"` // 业务类型 + BusinessIdentity string `gorm:"column:business_identity;type:varchar(36);not null;index" json:"business_identity"` // 业务对象标识 + UserIdentity string `gorm:"column:user_identity;type:varchar(36);not null;index" json:"user_identity"` // 付款用户标识 + MerchantIdentity string `gorm:"column:merchant_identity;type:varchar(36);not null;default:'platform';index" json:"merchant_identity"` // 商户配置标识 + Channel string `gorm:"column:channel;type:varchar(32);not null;index" json:"channel"` // 支付渠道 + PayType string `gorm:"column:pay_type;type:varchar(32);not null" json:"pay_type"` // 渠道支付产品 + ChannelTradeNo string `gorm:"column:channel_trade_no;type:varchar(128);not null;default:'';uniqueIndex:idx_payment_channel_trade,where:channel_trade_no <> ''" json:"channel_trade_no"` // 渠道交易号 + Amount int64 `gorm:"column:amount;not null;check:amount > 0" json:"amount"` // 实付金额,单位分 + Subject string `gorm:"column:subject;type:varchar(256);not null" json:"subject"` // 渠道订单标题 + ClientArgs string `gorm:"column:client_args;type:text;not null;default:''" json:"-"` // 客户端调起参数 + CallbackDigest string `gorm:"column:callback_digest;type:varchar(64);not null;default:''" json:"-"` // 回调原文摘要 + FailureCode string `gorm:"column:failure_code;type:varchar(64);not null;default:''" json:"failure_code"` // 稳定失败码 + FailureMessage string `gorm:"column:failure_message;type:varchar(512);not null;default:''" json:"failure_message"` // 脱敏失败说明 + ExpiresAt time.Time `gorm:"column:expires_at;type:timestamptz;not null;index" json:"expires_at"` // 支付过期时间 + PaidAt *time.Time `gorm:"column:paid_at;type:timestamptz" json:"paid_at"` // 渠道确认支付时间 + ClosedAt *time.Time `gorm:"column:closed_at;type:timestamptz" json:"closed_at"` // 渠道关单时间 +} + +func init() { database.AppendMigrate(&PaymentOrder{}) } +func (*PaymentOrder) TableName() string { return "payment_order" } diff --git a/backend/api/internal/models/payment_refund.go b/backend/api/internal/models/payment_refund.go new file mode 100644 index 0000000..54507f2 --- /dev/null +++ b/backend/api/internal/models/payment_refund.go @@ -0,0 +1,45 @@ +package models + +import ( + "time" + + "git.apinb.com/bsm-sdk/core/database" +) + +// PaymentRefund 对应 payment_refund,保存用户发起、财务审核并退入钱包的退款申请。 +type PaymentRefund struct { + Entity // 公共实体字段 + RefundStatus int `gorm:"column:refund_status;not null;default:10;index" json:"refund_status"` // 10待审核、20已通过、30已驳回 + PaymentOrderID uint64 `gorm:"column:payment_order_id;not null;index" json:"payment_order_id"` // 原支付单内部主键 + WalletBasicID uint64 `gorm:"column:wallet_basic_id;not null;index" json:"wallet_basic_id"` // 入账钱包内部主键 + RefundNo string `gorm:"column:refund_no;type:varchar(64);not null;uniqueIndex" json:"refund_no"` // 平台退款单号 + RequestNo string `gorm:"column:request_no;type:varchar(128);not null;uniqueIndex:idx_refund_request" json:"request_no"` // 用户端幂等号 + BusinessType string `gorm:"column:business_type;type:varchar(32);not null;index" json:"business_type"` // 原业务类型 + BusinessIdentity string `gorm:"column:business_identity;type:varchar(36);not null;index" json:"business_identity"` // 原业务标识 + UserIdentity string `gorm:"column:user_identity;type:varchar(36);not null;index;uniqueIndex:idx_refund_request" json:"user_identity"` // 申请用户标识 + Amount int64 `gorm:"column:amount;not null;check:amount > 0" json:"amount"` // 核定退款金额,单位分 + Reason string `gorm:"column:reason;type:varchar(256);not null" json:"reason"` // 用户退款原因 + Description string `gorm:"column:description;type:text;not null;default:''" json:"description"` // 用户补充说明 + ReviewRemark string `gorm:"column:review_remark;type:text;not null;default:''" json:"review_remark"` // 财务审核说明 + ReviewerIdentity string `gorm:"column:reviewer_identity;type:varchar(36);not null;default:''" json:"reviewer_identity"` // 审核人标识 + ReviewedAt *time.Time `gorm:"column:reviewed_at;type:timestamptz" json:"reviewed_at"` // 审核时间 + CompletedAt *time.Time `gorm:"column:completed_at;type:timestamptz" json:"completed_at"` // 钱包入账完成时间 +} + +func init() { database.AppendMigrate(&PaymentRefund{}) } +func (*PaymentRefund) TableName() string { return "payment_refund" } + +// PaymentRefundItem 对应 payment_refund_item,保存退款订单项、数量和服务端核定金额。 +type PaymentRefundItem struct { + ID uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` // 数据库自增主键 + Identity string `gorm:"column:identity;type:varchar(36);not null;uniqueIndex" json:"identity"` // 对外退款明细标识 + PaymentRefundID uint64 `gorm:"column:payment_refund_id;not null;index" json:"payment_refund_id"` // 退款申请内部主键 + OrderItemIdentity string `gorm:"column:order_item_identity;type:varchar(36);not null;index" json:"order_item_identity"` // 原订单项标识 + Quantity int `gorm:"column:quantity;not null;check:quantity > 0" json:"quantity"` // 退款数量 + Amount int64 `gorm:"column:amount;not null;check:amount >= 0" json:"amount"` // 分摊退款金额,单位分 + CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null" json:"created_at"` // 创建时间 + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null" json:"updated_at"` // 更新时间 +} + +func init() { database.AppendMigrate(&PaymentRefundItem{}) } +func (*PaymentRefundItem) TableName() string { return "payment_refund_item" } diff --git a/backend/api/internal/models/status_separation_test.go b/backend/api/internal/models/status_separation_test.go index 750d47c..7bbcc27 100644 --- a/backend/api/internal/models/status_separation_test.go +++ b/backend/api/internal/models/status_separation_test.go @@ -14,8 +14,8 @@ func TestBusinessModelsUseDedicatedStatusFields(t *testing.T) { {GasorderBasic{}, "OrderStatus"}, {ProductInfo{}, "ProductStatus"}, {EcOrder{}, "OrderStatus"}, - {WalletPayment{}, "PaymentStatus"}, - {WalletRefund{}, "RefundStatus"}, + {PaymentOrder{}, "PaymentStatus"}, + {PaymentRefund{}, "RefundStatus"}, {WalletApplyCash{}, "ApplyStatus"}, {FinPayment{}, "PaymentStatus"}, {FinReconciliation{}, "ReconciliationStatus"}, diff --git a/backend/api/internal/models/wallet_payment.go b/backend/api/internal/models/wallet_payment.go deleted file mode 100644 index e0a9048..0000000 --- a/backend/api/internal/models/wallet_payment.go +++ /dev/null @@ -1,23 +0,0 @@ -package models - -import "git.apinb.com/bsm-sdk/core/database" - -// WalletPayment 对应 wallet_payment,保存第三方或余额支付单。 -type WalletPayment struct { - Entity // 公共实体字段 - PaymentStatus int `gorm:"column:payment_status;not null;default:10;index" json:"payment_status"` // 支付业务状态 - WalletBasicID uint64 `gorm:"column:wallet_basic_id;not null;index" json:"wallet_basic_id"` // 钱包自增主键 - PaymentNo string `gorm:"column:payment_no;type:varchar(64);not null;uniqueIndex" json:"payment_no"` // 内部支付单号 - OrderNo string `gorm:"column:order_no;type:varchar(128);not null;index" json:"order_no"` // 业务订单号 - TradeNo string `gorm:"column:trade_no;type:varchar(128);not null;default:'';uniqueIndex:idx_wallet_payment_trade,where:trade_no <> ''" json:"trade_no"` // 第三方交易流水号 - PaymentType string `gorm:"column:payment_type;type:varchar(32);not null" json:"payment_type"` // 支付业务类型 - PayChannel string `gorm:"column:pay_channel;type:varchar(32);not null;uniqueIndex:idx_wallet_payment_trade,where:trade_no <> ''" json:"pay_channel"` // 支付渠道 - PayType string `gorm:"column:pay_type;type:varchar(64);not null;default:''" json:"pay_type"` // 渠道支付类型 - Amount int64 `gorm:"column:amount;not null;check:amount > 0" json:"amount"` // 支付金额,单位分 - Args string `gorm:"column:args;type:text;not null;default:''" json:"args"` // 支付参数 - Remark string `gorm:"column:remark;type:text;not null;default:''" json:"remark"` // 备注 - CallbackMsg string `gorm:"column:callback_msg;type:text;not null;default:''" json:"callback_msg"` // 支付回调信息 -} - -func init() { database.AppendMigrate(&WalletPayment{}) } -func (table *WalletPayment) TableName() string { return "wallet_payment" } diff --git a/backend/api/internal/models/wallet_refund.go b/backend/api/internal/models/wallet_refund.go deleted file mode 100644 index e8363c8..0000000 --- a/backend/api/internal/models/wallet_refund.go +++ /dev/null @@ -1,27 +0,0 @@ -package models - -import ( - "time" - - "git.apinb.com/bsm-sdk/core/database" -) - -// WalletRefund 对应 wallet_refund,保存支付退款结果。 -type WalletRefund struct { - Entity // 公共实体字段 - RefundStatus int `gorm:"column:refund_status;not null;default:10;index" json:"refund_status"` // 退款业务状态 - WalletBasicID uint64 `gorm:"column:wallet_basic_id;not null;index" json:"wallet_basic_id"` // 钱包自增主键 - WalletPaymentID uint64 `gorm:"column:wallet_payment_id;not null;index" json:"wallet_payment_id"` // 原支付记录自增主键 - RefundNo string `gorm:"column:refund_no;type:varchar(64);not null;uniqueIndex" json:"refund_no"` // 内部退款单号 - OrderIdentity string `gorm:"column:order_identity;type:varchar(64);not null;index" json:"order_identity"` // 订单业务标识 - Amount int64 `gorm:"column:amount;not null;check:amount > 0" json:"amount"` // 退款金额,单位分 - Fee int64 `gorm:"column:fee;not null;default:0;check:fee >= 0" json:"fee"` // 退款手续费,单位分 - Reason string `gorm:"column:reason;type:text;not null;default:''" json:"reason"` // 退款原因 - OrderInfo string `gorm:"column:order_info;type:text;not null;default:''" json:"order_info"` // 订单信息快照 - Result string `gorm:"column:result;type:text;not null;default:''" json:"result"` // 第三方处理结果 - TradeNo string `gorm:"column:trade_no;type:varchar(128);not null;default:'';index" json:"trade_no"` // 第三方退款流水号 - CompletedAt *time.Time `gorm:"column:completed_at;type:timestamptz" json:"completed_at"` // 完成时间 -} - -func init() { database.AppendMigrate(&WalletRefund{}) } -func (table *WalletRefund) TableName() string { return "wallet_refund" } diff --git a/backend/api/internal/routers/client.go b/backend/api/internal/routers/client.go index 7ed9703..a8c883a 100644 --- a/backend/api/internal/routers/client.go +++ b/backend/api/internal/routers/client.go @@ -40,6 +40,8 @@ func registerUserClient(serviceKey string, engine *gin.Engine) { protected.GET("/gas/contracts", userlogic.ListGasContracts) protected.GET("/gas/orders", userlogic.ListGasOrders) protected.POST("/gas/orders/:identity/cancel", userlogic.CancelGasOrder) + protected.POST("/gas/orders/:identity/pay", userlogic.PayGasOrder) + protected.POST("/gas/orders/:identity/refunds", userlogic.CreateRefund("gasorder")) protected.GET("/tickets", userlogic.ListTickets) protected.POST("/tickets", userlogic.CreateTicket) protected.POST("/tickets/:identity/confirm", userlogic.ConfirmTicket) @@ -48,7 +50,9 @@ func registerUserClient(serviceKey string, engine *gin.Engine) { protected.POST("/shop/orders", userlogic.CreateShopOrder) protected.POST("/shop/orders/:identity/cancel", userlogic.CancelShopOrder) protected.POST("/shop/orders/:identity/pay", userlogic.PayShopOrder) + protected.POST("/shop/orders/:identity/refunds", userlogic.CreateRefund("ec_order")) protected.POST("/shop/orders/:identity/confirm-receipt", userlogic.ConfirmShopReceipt) + protected.GET("/refunds", userlogic.ListRefunds) registerClientWalletRoutes(protected, "user_app") } diff --git a/backend/api/internal/routers/delivery.go b/backend/api/internal/routers/delivery.go index fbd3135..046dfeb 100644 --- a/backend/api/internal/routers/delivery.go +++ b/backend/api/internal/routers/delivery.go @@ -93,12 +93,12 @@ func RegisterDelivery(serviceKey string, engine *gin.Engine) { protected.POST("/wallet_recharge", deliverylogic.Recharge) protected.GET("/wallet_bank", deliverylogic.ListBank) protected.GET("/wallet_bank/:identity", deliverylogic.GetBank) - protected.GET("/wallet_payment", deliverylogic.ListPayment) - protected.GET("/wallet_payment/:identity", deliverylogic.GetPayment) + protected.GET("/payment_order", deliverylogic.ListPayment) + protected.GET("/payment_order/:identity", deliverylogic.GetPayment) protected.GET("/wallet_record", deliverylogic.ListRecord) protected.GET("/wallet_record/:identity", deliverylogic.GetRecord) - protected.GET("/wallet_refund", deliverylogic.ListRefund) - protected.GET("/wallet_refund/:identity", deliverylogic.GetRefund) + protected.GET("/payment_refund", deliverylogic.ListRefund) + protected.GET("/payment_refund/:identity", deliverylogic.GetRefund) protected.GET("/wallet_apply_cash", deliverylogic.ListApplyCash) protected.POST("/wallet_apply_cash", deliverylogic.CreateApplyCash) protected.GET("/wallet_apply_cash/:identity", deliverylogic.GetApplyCash) diff --git a/backend/api/internal/routers/gas_business.go b/backend/api/internal/routers/gas_business.go index 5ceea9d..e06ebf0 100644 --- a/backend/api/internal/routers/gas_business.go +++ b/backend/api/internal/routers/gas_business.go @@ -89,9 +89,9 @@ func registerGasBusinessRoutes(group *gin.RouterGroup) { group.GET("/wallet_basic", gaslogic.ListWalletBasic) group.GET("/wallet_bank", gaslogic.ListWalletBank) - group.GET("/wallet_payment", gaslogic.ListWalletPayment) + group.GET("/payment_order", gaslogic.ListPaymentOrder) group.GET("/wallet_record", gaslogic.ListWalletRecord) - group.GET("/wallet_refund", gaslogic.ListWalletRefund) + group.GET("/payment_refund", gaslogic.ListPaymentRefund) group.GET("/wallet_apply_cash", gaslogic.ListWalletApplyCash) group.POST("/wallet_apply_cash", gaslogic.CreateWalletApplyCash) group.GET("/fin_settlement", gaslogic.ListFinSettlement) diff --git a/backend/api/internal/routers/payment.go b/backend/api/internal/routers/payment.go new file mode 100644 index 0000000..8873d66 --- /dev/null +++ b/backend/api/internal/routers/payment.go @@ -0,0 +1,15 @@ +package routers + +import ( + "fmt" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/payment" + "github.com/gin-gonic/gin" +) + +// RegisterPaymentReturn 注册无 JWT 的渠道回调;安全性由渠道签名、商户、金额和支付单校验保证。 +func RegisterPaymentReturn(serviceKey string, engine *gin.Engine) { + group := engine.Group(fmt.Sprintf("/%s/payment-return/v1", serviceKey)) + group.POST("/alipay/notify", payment.AlipayNotify) + group.POST("/wechat/notify", payment.WechatNotify) + engine.POST(fmt.Sprintf("/%s/internal/v1/payment/close-expired", serviceKey), payment.CloseExpiredHandler) +} diff --git a/backend/api/internal/routers/platform.go b/backend/api/internal/routers/platform.go index 47affc4..13ef9c4 100644 --- a/backend/api/internal/routers/platform.go +++ b/backend/api/internal/routers/platform.go @@ -12,6 +12,7 @@ import ( "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/platform/fin" "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/platform/gas" "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/platform/gasorder" + paymentlogic "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/platform/payment" platformlogic "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/platform/platform" "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/platform/product" "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/platform/staff" @@ -148,18 +149,14 @@ func registerWalletRoute(group *gin.RouterGroup) { bank.GET("", wallet.ListWalletBank) bank.GET("/:identity", wallet.GetWalletBank) - payment := group.Group("/wallet_payment") - payment.GET("", wallet.ListWalletPayment) - payment.GET("/:identity", wallet.GetWalletPayment) + payment := group.Group("/payment_order") + payment.GET("", wallet.ListPaymentOrder) + payment.GET("/:identity", wallet.GetPaymentOrder) record := group.Group("/wallet_record") record.GET("", wallet.ListWalletRecord) record.GET("/:identity", wallet.GetWalletRecord) - refund := group.Group("/wallet_refund") - refund.GET("", wallet.ListWalletRefund) - refund.GET("/:identity", wallet.GetWalletRefund) - applyCash := group.Group("/wallet_apply_cash") applyCash.GET("", wallet.ListWalletApplyCash) applyCash.GET("/:identity", wallet.GetWalletApplyCash) @@ -213,6 +210,11 @@ func registerPlatformRoute(group *gin.RouterGroup) { } func registerFinanceRoute(group *gin.RouterGroup) { + refund := group.Group("/payment_refund") + refund.GET("", paymentlogic.ListRefund) + refund.GET("/:identity", paymentlogic.GetRefund) + refund.POST("/:identity/approve", paymentlogic.ApproveRefund) + refund.POST("/:identity/reject", paymentlogic.RejectRefund) registerReadOnlyResource(group, "/fin_payment", &models.FinPayment{}) settlementList, settlementCreate, settlementGet, settlementUpdate := fin.FinSettlementHandlers() registerWritableResource(group, "/fin_settlement", settlementList, settlementCreate, settlementGet, settlementUpdate, &models.FinSettlement{}) diff --git a/backend/api/internal/routers/platform_test.go b/backend/api/internal/routers/platform_test.go index f357575..de01548 100644 --- a/backend/api/internal/routers/platform_test.go +++ b/backend/api/internal/routers/platform_test.go @@ -211,7 +211,7 @@ func TestPlatformFinanceContentRoutesFollowTheirContracts(t *testing.T) { } for _, resource := range []string{ - "/wallet_basic", "/wallet_bank", "/wallet_payment", "/wallet_record", "/wallet_refund", "/wallet_apply_cash", + "/wallet_basic", "/wallet_bank", "/payment_order", "/wallet_record", "/payment_refund", "/wallet_apply_cash", } { path := "/heqi/platform/v1" + resource assertRouteMethods(t, routes, path, http.MethodGet) diff --git a/backend/api/internal/routers/register.go b/backend/api/internal/routers/register.go index e3fb61e..443606b 100644 --- a/backend/api/internal/routers/register.go +++ b/backend/api/internal/routers/register.go @@ -9,5 +9,6 @@ func Register(serviceKey string, engine *gin.Engine) { RegisterGas(serviceKey, engine) RegisterDelivery(serviceKey, engine) RegisterClient(serviceKey, engine) + RegisterPaymentReturn(serviceKey, engine) registerUploadRoute(serviceKey, engine) } diff --git a/backend/api/internal/seed/mock.go b/backend/api/internal/seed/mock.go index 9798732..d01ce5f 100644 --- a/backend/api/internal/seed/mock.go +++ b/backend/api/internal/seed/mock.go @@ -341,12 +341,11 @@ func MockData(database *gorm.DB) error { return err } - walletPayment := models.WalletPayment{ - Entity: entity(35, common.StatusEnable), PaymentStatus: common.StatusSuccess, WalletBasicID: wallet.ID, - PaymentNo: "MOCK-PAYMENT-001", OrderNo: gasOrder.OrderNo, - TradeNo: "MOCK-TRADE-001", PaymentType: "gasorder", - PayChannel: "balance", PayType: "wallet", Amount: gasOrder.PayableAmount, - Args: `{}`, CallbackMsg: `{"status":"success"}`, + walletPayment := models.PaymentOrder{ + Entity: entity(35, common.StatusEnable), PaymentStatus: 23, + PaymentNo: "MOCK-PAYMENT-001", RequestNo: "MOCK-REQ-PAYMENT-001", BusinessType: "gasorder", BusinessIdentity: gasOrder.Identity, + UserIdentity: user.Identity, MerchantIdentity: "platform", Channel: "wallet", PayType: "wallet", ChannelTradeNo: "MOCK-TRADE-001", + Amount: gasOrder.PayableAmount, Subject: "模拟供气订单", ClientArgs: `{}`, ExpiresAt: now.Add(30 * time.Minute), PaidAt: &now, } if err := put(tx, &walletPayment); err != nil { return err @@ -366,12 +365,11 @@ func MockData(database *gorm.DB) error { } refundCompletedAt := now - refund := models.WalletRefund{ - Entity: entity(37, common.StatusEnable), RefundStatus: common.StatusCompleted, WalletBasicID: wallet.ID, - WalletPaymentID: walletPayment.ID, RefundNo: "MOCK-REFUND-001", - OrderIdentity: gasOrder.Identity, Amount: 1000, Reason: "模拟部分退款", - OrderInfo: `{"order_no":"MOCK-GASORDER-001"}`, Result: `{"status":"success"}`, - TradeNo: "MOCK-REFUND-TRADE-001", CompletedAt: &refundCompletedAt, + refund := models.PaymentRefund{ + Entity: entity(37, common.StatusEnable), RefundStatus: 20, WalletBasicID: wallet.ID, + PaymentOrderID: walletPayment.ID, RefundNo: "MOCK-REFUND-001", RequestNo: "MOCK-REQ-REFUND-001", + BusinessType: "gasorder", BusinessIdentity: gasOrder.Identity, UserIdentity: user.Identity, + Amount: 1000, Reason: "模拟部分退款", ReviewerIdentity: gasAccount.Identity, ReviewedAt: &refundCompletedAt, CompletedAt: &refundCompletedAt, } if err := put(tx, &refund); err != nil { return err @@ -390,7 +388,7 @@ func MockData(database *gorm.DB) error { gasOrderPayment := models.GasorderPayment{ Entity: entity(39, common.StatusEnable), GasorderBasicID: gasOrder.ID, - WalletPaymentID: walletPayment.ID, AttemptNo: 1, Amount: gasOrder.PayableAmount, + PaymentOrderID: walletPayment.ID, AttemptNo: 1, Amount: gasOrder.PayableAmount, } if err := put(tx, &gasOrderPayment); err != nil { return err diff --git a/backend/go.work.sum b/backend/go.work.sum index 847b08b..233d4c5 100644 --- a/backend/go.work.sum +++ b/backend/go.work.sum @@ -4,6 +4,7 @@ cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdB cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= +github.com/agiledragon/gomonkey v2.0.2+incompatible h1:eXKi9/piiC3cjJD1658mEE2o3NjkJ5vDLgYjCQu0Xlw= github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -40,6 +41,7 @@ github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e h1:a+PGEeXb+e github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk= github.com/kisielk/errcheck v1.5.0 h1:e8esj/e4R+SAOwFwN+n3zr0nYeCyeweozKfO23MvHzY= github.com/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46 h1:veS9QfglfvqAw2e+eeNT/SbGySq8ajECXJ9e4fPoLhY= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= diff --git a/backend/worker/cmd/main/main.go b/backend/worker/cmd/main/main.go index 5cc64aa..1e792f2 100644 --- a/backend/worker/cmd/main/main.go +++ b/backend/worker/cmd/main/main.go @@ -2,9 +2,13 @@ package main import ( + "bytes" + "context" + "net/http" "os" "os/signal" "syscall" + "time" "git.apinb.com/bsm-sdk/core/printer" "git.apinb.com/heqiapp/platforms/backend/worker/internal/config" @@ -14,8 +18,21 @@ import ( func main() { config.New("PlatformWorker") impl.NewImpl() - printer.Info("[BSM - PlatformWorker] Mock worker started; Redis Streams consumer is not enabled") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go closeExpiredPayments(ctx) + printer.Info("[BSM - PlatformWorker] payment timeout scheduler started") quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit + cancel() +} + +func closeExpiredPayments(ctx context.Context) { + ticker := time.NewTicker(time.Duration(config.Spec.PaymentAPI.IntervalSeconds) * time.Second); defer ticker.Stop() + for { select { case <-ctx.Done(): return; case <-ticker.C: + request, err := http.NewRequestWithContext(ctx, http.MethodPost, config.Spec.PaymentAPI.BaseURL+"/heqi/internal/v1/payment/close-expired", bytes.NewReader(nil)); if err != nil { continue } + request.Header.Set("X-Heqi-Worker-Token", config.Spec.PaymentAPI.Token) + response, err := http.DefaultClient.Do(request); if err == nil { _ = response.Body.Close() } + } } } diff --git a/backend/worker/etc/platform_worker_dev.yaml b/backend/worker/etc/platform_worker_dev.yaml index 0524b7a..cbd227a 100644 --- a/backend/worker/etc/platform_worker_dev.yaml +++ b/backend/worker/etc/platform_worker_dev.yaml @@ -6,4 +6,9 @@ Databases: - host=127.0.0.1 user=postgres password=change-me dbname=agent_dev port=5432 sslmode=disable TimeZone=Asia/Shanghai Cache: redis://default:change-me@127.0.0.1:6379/0 OnMicroService: false + +PaymentAPI: + BaseURL: http://localhost:12426 + Token: change-me-payment-worker-token + IntervalSeconds: 60 SecretKey: change-me-to-a-random-string diff --git a/backend/worker/internal/config/config.go b/backend/worker/internal/config/config.go index 4123026..323e6db 100644 --- a/backend/worker/internal/config/config.go +++ b/backend/worker/internal/config/config.go @@ -1,4 +1,4 @@ -// Package config 沿用 sample/server 的 BSM 运行配置与地址校验。 +// Package config 使用仓库统一的 BSM 运行配置与地址校验。 package config import ( @@ -12,10 +12,18 @@ var Spec SrvConfig // SrvConfig 保持与 API 进程相同的 BSM 配置结构。 type SrvConfig struct { - conf.Base `yaml:",inline"` - Databases *conf.DBConf `yaml:"Databases"` - Rpc map[string]conf.RpcConf `yaml:"Rpc"` - Apm *conf.ApmConf `yaml:"APM"` + conf.Base `yaml:",inline"` + Databases *conf.DBConf `yaml:"Databases"` + Rpc map[string]conf.RpcConf `yaml:"Rpc"` + Apm *conf.ApmConf `yaml:"APM"` + PaymentAPI PaymentAPIConfig `yaml:"PaymentAPI"` +} + +// PaymentAPIConfig 保存 Worker 调用支付内部动作所需的最小配置。 +type PaymentAPIConfig struct { + BaseURL string `yaml:"BaseURL"` + Token string `yaml:"Token"` + IntervalSeconds int `yaml:"IntervalSeconds"` } // New 初始化 Worker 配置。 @@ -25,5 +33,8 @@ func New(srvKey string) { Spec.BindIP = conf.CheckIP(Spec.BindIP) Spec.Addr = net.JoinHostPort(Spec.BindIP, Spec.Port) conf.NotNil(Spec.Service, Spec.Cache) + if Spec.PaymentAPI.BaseURL == "" || Spec.PaymentAPI.Token == "" || Spec.PaymentAPI.IntervalSeconds <= 0 { + panic("PaymentAPI configuration is required") + } conf.PrintInfo(Spec.Addr) } diff --git a/docs/02-核心业务流程.md b/docs/02-核心业务流程.md index af8d50c..bcf9876 100644 --- a/docs/02-核心业务流程.md +++ b/docs/02-核心业务流程.md @@ -59,7 +59,7 @@ SLA 从事件创建还是用户确认开始计时须由业务方确认;系统 ## 3. 商城下单至完成 1. 用户浏览分类和商品,加入购物车或立即购买,选择地址、优惠券、预约时段和支付方式。 -2. 系统冻结库存、核算优惠和运费/服务费,创建待支付订单;支付回调成功后转为待履约。 +2. 系统冻结库存、核算优惠和运费/服务费,创建待支付订单;支付回调成功后转为待履约。支付统一创建 `payment_order`,客户端返回不作为成功依据,必须以验签回调或主动查单结果为准。支付单默认 30 分钟过期,由 Worker 触发 API 关单,过期后成功回调进入支付异常待人工确认。 3. 若有合同,创建第三方签署任务。需合同的商品应在签约成功后才允许派单;签约失败/超时可取消并按规则退款。 4. 按商品履约属性生成配送、安装或维修任务。系统按区域、技能、上班状态、距离、负载和分账规则分配人员;无法派单时进入人工调度队列。 5. 配送任务从接单开始记录配送轨迹:接单、出发、位置上报、到达、签收/异常等节点按时间顺序关联订单。用户查看简化进度与预计到达,运营人员查看完整轨迹和定位质量。 @@ -97,7 +97,7 @@ SLA 从事件创建还是用户确认开始计时须由业务方确认;系统 ## 7. 资金与提现 -- 支付、充值、退款、佣金、提现均以不可变资金流水记录,余额是流水聚合结果而非唯一事实来源。 +- 支付、充值、退款、佣金、提现均以不可变资金流水记录,余额是流水聚合结果而非唯一事实来源。首期退款只能由用户端在支付成功后配置天数内发起,已履约订单禁止退款;支持订单项和多次部分退款,累计不超过实付金额。财务审核通过后在同一事务内退入用户钱包并写不可变流水,不调用渠道原路退款。 - 提现流程:服务人员/气站提交申请 -> 系统校验可提现余额、实名与风控 -> 财务审核 -> 线下打款/三方打款 -> 回填凭证和结果。 - 附件规定“审核通过,线下打款”;本期应支持审核通过、审核拒绝(必填原因)、打款中、已打款、打款失败、撤回。 - 固定时间对账应对比平台订单、支付渠道、退款和佣金流水;差异项不得自动提现。 diff --git a/docs/03-用户端App需求.md b/docs/03-用户端App需求.md index fcac87d..a388f2b 100644 --- a/docs/03-用户端App需求.md +++ b/docs/03-用户端App需求.md @@ -95,7 +95,8 @@ - 用户端 API 固定为 `/heqi/client/v1/user`,令牌客户端为 `user_app`,不得与任何后台或工作人员令牌互用。 - 首期实现手机号密码/验证码登录、普通注册、邀请注册、资料、地址、服务归属、已发布内容与阅读确认、商城下单和余额支付、物流查询/确认收货、供气合同及订单查询、工单、钱包充值/提现/银行卡。邀请注册可携带气站 `identity` 和可选配送点 `identity`,服务端在事务内校验组织关系并建立唯一服务归属。 -- 充值先创建待支付订单;仅开发配置允许 Mock 支付确认,确认后才写余额及不可变流水。微信和支付宝未配置渠道时必须明确返回不可用,不得模拟成功。 +- 充值先创建待支付订单;仅开发配置允许 Mock 支付确认,确认后才写余额及不可变流水。微信和支付宝未配置渠道时必须明确返回不可用,不得模拟成功。Android/iOS 通过渠道 SDK 调起支付宝 App 支付和微信 App 支付;Web 使用支付宝手机网站支付或微信 JSAPI。客户端回传仅用于提示并轮询服务端支付状态,不能直接推进订单。 +- 用户可在未履约订单的退款窗口内选择订单项和数量提交退款原因;金额由服务端按实付分摊核定。退款申请、审核结果和钱包入账状态均可在订单页查看。 - 商城订单交易状态与物流状态分离;物流单号、公司、发货和收货时间由服务端保存,用户只能查看本人订单并确认收货。 - 首期不伪造设备控制、安全事件、押金、消息、发票、收藏、紧急联系人、账户注销和完整售后能力;文档中这些能力保留为后续迭代,不得以静态成功响应冒充已实现。 diff --git a/docs/11-数据接口与安全.md b/docs/11-数据接口与安全.md index a0657ff..9a8f223 100644 --- a/docs/11-数据接口与安全.md +++ b/docs/11-数据接口与安全.md @@ -90,7 +90,8 @@ - 登录令牌短期有效,刷新令牌可撤销;后台高权限账号启用 MFA、IP/设备策略。平台后台管理的平台、气站、配送、员工和业主账号密码按当前实施口径仅要求不少于 6 个字符,不附加复杂度校验。 - 权限校验在服务端执行,前端菜单隐藏不构成权限控制。按角色、站点、区域、对象归属联合鉴权。 - 手机号、地址、身份证明、收款账户、定位、视频为敏感数据:传输 TLS、存储加密/字段加密、显示脱敏、访问留痕、最小化留存。 -- 所有支付回调验证签名与金额、订单、商户号一致性;合同文件使用可信第三方原文与哈希存证。 +- 所有支付回调验证签名与金额、订单、商户号一致性;合同文件使用可信第三方原文与哈希存证。渠道回调入口为 `/heqi/payment-return/v1/{alipay|wechat}/notify`,不使用用户 JWT;必须完成渠道证书验签、商户/appid、平台支付单号、金额、币种和状态校验后才可在数据库事务中推进业务。重复通知必须幂等,原始敏感报文只保存摘要。 +- `payment_order` 是统一支付尝试事实;`payment_refund` 与 `payment_refund_item` 保存用户退款申请及明细。审批通过与钱包入账必须同事务完成。 - 图片/视频上传做文件类型、大小、病毒/恶意内容检测;访问采用短期授权,不使用公开桶。 - 设备控制、告警等级调整、资金审核、数据导出、账号注销等高风险操作要求二次确认和审计。 diff --git a/frontend/platform_admin/scripts/check-backend-contract.mjs b/frontend/platform_admin/scripts/check-backend-contract.mjs index d7df63f..978d2ba 100644 --- a/frontend/platform_admin/scripts/check-backend-contract.mjs +++ b/frontend/platform_admin/scripts/check-backend-contract.mjs @@ -1,4 +1,4 @@ -import { readFileSync } from 'node:fs'; +import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -14,8 +14,8 @@ const frontendDefinitions = [ ].map((match) => ({ name: match[1], mode: match[2] })); const frontendNames = frontendDefinitions.map((item) => item.name); const embeddedResources = new Set([ - 'wallet_basic', 'wallet_bank', 'wallet_payment', 'wallet_record', - 'wallet_refund', 'wallet_apply_cash', + 'wallet_basic', 'wallet_bank', 'payment_order', 'wallet_record', + 'payment_refund', 'wallet_apply_cash', ]); for (const name of frontendNames) { diff --git a/frontend/platform_admin/src/api/resources.ts b/frontend/platform_admin/src/api/resources.ts index bd09621..c793aec 100644 --- a/frontend/platform_admin/src/api/resources.ts +++ b/frontend/platform_admin/src/api/resources.ts @@ -1,4 +1,4 @@ -export type ResourceMode = +export type ResourceMode = | 'writable' | 'readonly' | 'append_only' @@ -140,6 +140,10 @@ const fieldLabels: Record = { reviewed_at: '审核时间', reviewer_name: '审核人', reviewer_identity: '审核人标识', + review_remark: '审核说明', + business_type: '业务类型', + business_identity: '业务标识', + user_identity: '用户标识', ymd: '日期', ym: '月份', contract_no: '合同编号', @@ -345,9 +349,16 @@ export const resources: ResourceUiDefinition[] = [ { name: '修改钱包状态', resource: '/wallet_basic/:identity/status', method: 'PATCH', fields: [f('status', { required: true, type: 'select', options: [{ label: '启用', value: 1 }, { label: '停用', value: 2 }, { label: '冻结', value: 4 }] })] }, ]), define('wallet_bank', '银行卡', 'readonly', []), - define('wallet_payment', '钱包支付记录', 'readonly', []), + define('payment_order', '钱包支付记录', 'readonly', []), define('wallet_record', '钱包流水', 'readonly', []), - define('wallet_refund', '退款记录', 'readonly', []), + define('payment_refund', '退款审核', 'readonly', [ + f('refund_no'), f('business_type'), f('business_identity'), f('user_identity'), + f('amount'), f('reason'), f('description'), f('refund_status'), + f('reviewer_identity'), f('review_remark'), f('reviewed_at'), f('completed_at'), + ], 'list', [ + { name: '审核通过并退入钱包', resource: '/payment_refund/:identity/approve', fields: [f('remark')], visibleFor: { field: 'refund_status', values: [10] } }, + { name: '驳回退款', resource: '/payment_refund/:identity/reject', danger: true, fields: [f('remark', { required: true })], visibleFor: { field: 'refund_status', values: [10] } }, + ], { canCreate: false, canEdit: false, canChangeStatus: false, canArchive: false }), define('wallet_apply_cash', '提现记录', 'readonly', [ f('cash_no'), f('wallet_basic_identity', { type: 'identity' }), diff --git a/frontend/platform_admin/src/contracts/platform-resources.json b/frontend/platform_admin/src/contracts/platform-resources.json index ff0d5aa..e8cf01a 100644 --- a/frontend/platform_admin/src/contracts/platform-resources.json +++ b/frontend/platform_admin/src/contracts/platform-resources.json @@ -1 +1 @@ -{"resources":[{"domain":"gas","name":"gas_basic","path":"/gas_basic","pageKind":"list","mode":"writable"},{"domain":"gas","name":"gas_account","path":"/gas_account","pageKind":"list","mode":"writable"},{"domain":"delivery","name":"delivery_basic","path":"/delivery_basic","pageKind":"list","mode":"writable"},{"domain":"delivery","name":"delivery_account","path":"/delivery_account","pageKind":"list","mode":"writable"},{"domain":"staff","name":"staff_account","path":"/staff_account","pageKind":"list","mode":"writable"},{"domain":"staff","name":"staff_credential","path":"/staff_credential","pageKind":"list","mode":"writable"},{"domain":"user","name":"user_account","path":"/user_account","pageKind":"list","mode":"writable"},{"domain":"user","name":"user_address","path":"/user_address","pageKind":"list","mode":"writable"},{"domain":"user","name":"user_service_relation","path":"/user_service_relation","pageKind":"list","mode":"writable"},{"domain":"product","name":"product_type","path":"/product_type","pageKind":"list","mode":"editable"},{"domain":"product","name":"product_warehouse","path":"/product_warehouse","pageKind":"list","mode":"editable"},{"domain":"product","name":"product_info","path":"/product_info","pageKind":"list","mode":"editable"},{"domain":"product","name":"product_repair","path":"/product_repair","pageKind":"list","mode":"editable"},{"domain":"product","name":"product_owner","path":"/product_owner","pageKind":"list","mode":"readonly"},{"domain":"ec","name":"ec_category","path":"/ec_category","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_product","path":"/ec_product","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_product_attribute","path":"/ec_product_attribute","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_product_image","path":"/ec_product_image","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_cart","path":"/ec_cart","pageKind":"list","mode":"readonly"},{"domain":"ec","name":"ec_order","path":"/ec_order","pageKind":"list","mode":"readonly"},{"domain":"ec","name":"ec_order_item","path":"/ec_order_item","pageKind":"list","mode":"readonly"},{"domain":"ec","name":"ec_review","path":"/ec_review","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_contract","path":"/gasorder_contract","pageKind":"list","mode":"managed"},{"domain":"gasorder","name":"gasorder_contract_product","path":"/gasorder_contract_product","pageKind":"list","mode":"append_only"},{"domain":"gasorder","name":"gasorder_contract_revision","path":"/gasorder_contract_revision","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_basic","path":"/gasorder_basic","pageKind":"list","mode":"append_only"},{"domain":"gasorder","name":"gasorder_item","path":"/gasorder_item","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_assign","path":"/gasorder_assign","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_status","path":"/gasorder_status","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_track","path":"/gasorder_track","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_track_point","path":"/gasorder_track_point","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_confirm","path":"/gasorder_confirm","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_payment","path":"/gasorder_payment","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"fin_payment","path":"/fin_payment","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"fin_settlement","path":"/fin_settlement","pageKind":"list","mode":"writable"},{"domain":"finance","name":"fin_reconciliation","path":"/fin_reconciliation","pageKind":"list","mode":"readonly"},{"domain":"content","name":"cms_content","path":"/cms_content","pageKind":"list","mode":"writable"},{"domain":"customer_service","name":"cs_ticket","path":"/cs_ticket","pageKind":"list","mode":"writable"},{"domain":"platform","name":"platform_account","path":"/platform_account","pageKind":"list","mode":"writable"},{"domain":"platform","name":"platform_role","path":"/platform_role","pageKind":"list","mode":"writable"},{"domain":"platform","name":"platform_menu","path":"/platform_menu","pageKind":"tree","mode":"readonly"},{"domain":"wallet","name":"wallet_basic","path":"/wallet_basic","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"wallet_bank","path":"/wallet_bank","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"wallet_payment","path":"/wallet_payment","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"wallet_record","path":"/wallet_record","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"wallet_refund","path":"/wallet_refund","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"wallet_apply_cash","path":"/wallet_apply_cash","pageKind":"list","mode":"readonly"}],"routes":[{"method":"GET","path":"/gasorder_contract"},{"method":"GET","path":"/gasorder_contract_product"},{"method":"GET","path":"/gasorder_contract_product/:identity"},{"method":"GET","path":"/gasorder_contract_revision"},{"method":"GET","path":"/gasorder_contract_revision/:identity"},{"method":"GET","path":"/gasorder_contract/:identity"},{"method":"GET","path":"/gasorder_confirm"},{"method":"GET","path":"/gasorder_confirm/:identity"},{"method":"GET","path":"/gasorder_track"},{"method":"GET","path":"/gasorder_track_point"},{"method":"GET","path":"/gasorder_track_point/:identity"},{"method":"GET","path":"/gasorder_track/:identity"},{"method":"GET","path":"/gasorder_basic"},{"method":"GET","path":"/gasorder_basic/:identity"},{"method":"GET","path":"/gasorder_item"},{"method":"GET","path":"/gasorder_item/:identity"},{"method":"GET","path":"/gasorder_assign"},{"method":"GET","path":"/gasorder_assign/:identity"},{"method":"GET","path":"/gasorder_status"},{"method":"GET","path":"/gasorder_status/:identity"},{"method":"GET","path":"/gasorder_payment"},{"method":"GET","path":"/gasorder_payment/:identity"},{"method":"GET","path":"/gas_basic"},{"method":"GET","path":"/gas_basic/:identity"},{"method":"GET","path":"/gas_account"},{"method":"GET","path":"/gas_account/:identity"},{"method":"GET","path":"/product_type"},{"method":"GET","path":"/product_type/:identity"},{"method":"GET","path":"/product_warehouse"},{"method":"GET","path":"/product_warehouse/:identity"},{"method":"GET","path":"/product_info"},{"method":"GET","path":"/product_info/:identity"},{"method":"GET","path":"/product_repair"},{"method":"GET","path":"/product_repair/:identity"},{"method":"GET","path":"/product_owner"},{"method":"GET","path":"/product_owner/:identity"},{"method":"GET","path":"/platform_role"},{"method":"GET","path":"/platform_role/:identity"},{"method":"GET","path":"/platform_role/:identity/menu"},{"method":"GET","path":"/platform_account"},{"method":"GET","path":"/platform_account/:identity"},{"method":"GET","path":"/platform_menu"},{"method":"GET","path":"/platform_menu/:identity"},{"method":"GET","path":"/ping/hello"},{"method":"GET","path":"/ec_product"},{"method":"GET","path":"/ec_product_attribute"},{"method":"GET","path":"/ec_product_attribute/:identity"},{"method":"GET","path":"/ec_product_image"},{"method":"GET","path":"/ec_product_image/:identity"},{"method":"GET","path":"/ec_product/:identity"},{"method":"GET","path":"/ec_category"},{"method":"GET","path":"/ec_category/:identity"},{"method":"GET","path":"/ec_cart"},{"method":"GET","path":"/ec_cart/:identity"},{"method":"GET","path":"/ec_order"},{"method":"GET","path":"/ec_order_item"},{"method":"GET","path":"/ec_order_item/:identity"},{"method":"GET","path":"/ec_order/:identity"},{"method":"GET","path":"/ec_review"},{"method":"GET","path":"/ec_review/:identity"},{"method":"GET","path":"/wallet_basic"},{"method":"GET","path":"/wallet_basic/:identity"},{"method":"GET","path":"/wallet_bank"},{"method":"GET","path":"/wallet_bank/:identity"},{"method":"GET","path":"/wallet_record"},{"method":"GET","path":"/wallet_record/:identity"},{"method":"GET","path":"/wallet_refund"},{"method":"GET","path":"/wallet_refund/:identity"},{"method":"GET","path":"/wallet_payment"},{"method":"GET","path":"/wallet_payment/:identity"},{"method":"GET","path":"/wallet_apply_cash"},{"method":"GET","path":"/wallet_apply_cash/:identity"},{"method":"GET","path":"/user_account"},{"method":"GET","path":"/user_account/:identity"},{"method":"GET","path":"/user_address"},{"method":"GET","path":"/user_address/:identity"},{"method":"GET","path":"/user_service_relation"},{"method":"GET","path":"/user_service_relation/:identity"},{"method":"GET","path":"/fin_payment"},{"method":"GET","path":"/fin_payment/:identity"},{"method":"GET","path":"/fin_settlement"},{"method":"GET","path":"/fin_settlement/:identity"},{"method":"GET","path":"/fin_reconciliation"},{"method":"GET","path":"/fin_reconciliation/:identity"},{"method":"GET","path":"/delivery_basic"},{"method":"GET","path":"/delivery_basic/:identity"},{"method":"GET","path":"/delivery_account"},{"method":"GET","path":"/delivery_account/:identity"},{"method":"GET","path":"/dashboard/overview"},{"method":"GET","path":"/staff_account"},{"method":"GET","path":"/staff_account/:identity"},{"method":"GET","path":"/staff_credential"},{"method":"GET","path":"/staff_credential/:identity"},{"method":"GET","path":"/cms_content"},{"method":"GET","path":"/cms_content/:identity"},{"method":"GET","path":"/cs_ticket"},{"method":"GET","path":"/cs_ticket/:identity"},{"method":"GET","path":"/auth/profile"},{"method":"POST","path":"/gasorder_basic"},{"method":"POST","path":"/gasorder_basic/:identity/assign"},{"method":"POST","path":"/gasorder_basic/:identity/awaiting-confirmation"},{"method":"POST","path":"/gasorder_basic/:identity/ready"},{"method":"POST","path":"/gasorder_basic/:identity/recover"},{"method":"POST","path":"/gasorder_basic/:identity/complete"},{"method":"POST","path":"/gasorder_basic/:identity/cancel"},{"method":"POST","path":"/gasorder_basic/:identity/filling"},{"method":"POST","path":"/gasorder_basic/:identity/delivering"},{"method":"POST","path":"/gasorder_basic/:identity/exception"},{"method":"POST","path":"/gasorder_contract"},{"method":"POST","path":"/gasorder_contract/:identity/activate"},{"method":"POST","path":"/gasorder_contract/:identity/renew"},{"method":"POST","path":"/gasorder_contract/:identity/terminate"},{"method":"POST","path":"/gasorder_contract_product"},{"method":"POST","path":"/gasorder_contract_product/:identity/unbind"},{"method":"POST","path":"/gas_basic"},{"method":"POST","path":"/gas_basic/:identity/review"},{"method":"POST","path":"/gas_account"},{"method":"POST","path":"/product_type"},{"method":"POST","path":"/product_warehouse"},{"method":"POST","path":"/product_info"},{"method":"POST","path":"/product_repair"},{"method":"POST","path":"/platform_account"},{"method":"POST","path":"/platform_role"},{"method":"POST","path":"/wallet_apply_cash/:identity/approve"},{"method":"POST","path":"/wallet_apply_cash/:identity/reject"},{"method":"POST","path":"/wallet_apply_cash/:identity/complete"},{"method":"POST","path":"/wallet_basic/owner/:owner_type/:owner_identity"},{"method":"POST","path":"/wallet_basic/:identity/recharge"},{"method":"POST","path":"/ec_product"},{"method":"POST","path":"/ec_product_attribute"},{"method":"POST","path":"/ec_product_image"},{"method":"POST","path":"/ec_category"},{"method":"POST","path":"/user_account"},{"method":"POST","path":"/user_address"},{"method":"POST","path":"/user_service_relation"},{"method":"POST","path":"/delivery_basic"},{"method":"POST","path":"/delivery_account"},{"method":"POST","path":"/staff_account"},{"method":"POST","path":"/staff_credential"},{"method":"POST","path":"/cms_content"},{"method":"POST","path":"/cs_ticket"},{"method":"POST","path":"/auth/login"},{"method":"POST","path":"/fin_settlement"},{"method":"PUT","path":"/product_type/:identity"},{"method":"PUT","path":"/product_warehouse/:identity"},{"method":"PUT","path":"/product_info/:identity"},{"method":"PUT","path":"/product_repair/:identity"},{"method":"PUT","path":"/platform_role/:identity"},{"method":"PUT","path":"/platform_role/:identity/menu"},{"method":"PUT","path":"/platform_account/:identity"},{"method":"PUT","path":"/ec_product_attribute/:identity"},{"method":"PUT","path":"/ec_product_image/:identity"},{"method":"PUT","path":"/ec_product/:identity"},{"method":"PUT","path":"/ec_category/:identity"},{"method":"PUT","path":"/gas_basic/:identity"},{"method":"PUT","path":"/gas_account/:identity"},{"method":"PUT","path":"/gasorder_contract/:identity"},{"method":"PUT","path":"/user_account/:identity"},{"method":"PUT","path":"/user_address/:identity"},{"method":"PUT","path":"/user_service_relation/:identity"},{"method":"PUT","path":"/delivery_basic/:identity"},{"method":"PUT","path":"/delivery_account/:identity"},{"method":"PUT","path":"/staff_account/:identity"},{"method":"PUT","path":"/staff_credential/:identity"},{"method":"PUT","path":"/cms_content/:identity"},{"method":"PUT","path":"/cs_ticket/:identity"},{"method":"PUT","path":"/auth/password"},{"method":"PUT","path":"/fin_settlement/:identity"},{"method":"PATCH","path":"/product_info/:identity/status"},{"method":"PATCH","path":"/product_info/:identity/lifecycle"},{"method":"PATCH","path":"/product_type/:identity/status"},{"method":"PATCH","path":"/product_warehouse/:identity/status"},{"method":"PATCH","path":"/product_repair/:identity/status"},{"method":"PATCH","path":"/platform_account/:identity/status"},{"method":"PATCH","path":"/platform_role/:identity/status"},{"method":"PATCH","path":"/ec_product_attribute/:identity/status"},{"method":"PATCH","path":"/ec_product_image/:identity/status"},{"method":"PATCH","path":"/ec_product/:identity/status"},{"method":"PATCH","path":"/ec_category/:identity/status"},{"method":"PATCH","path":"/user_account/:identity/status"},{"method":"PATCH","path":"/user_address/:identity/status"},{"method":"PATCH","path":"/user_service_relation/:identity/status"},{"method":"PATCH","path":"/gas_basic/:identity/status"},{"method":"PATCH","path":"/gas_account/:identity/status"},{"method":"PATCH","path":"/delivery_basic/:identity/status"},{"method":"PATCH","path":"/delivery_account/:identity/status"},{"method":"PATCH","path":"/staff_account/:identity/status"},{"method":"PATCH","path":"/staff_credential/:identity/status"},{"method":"PATCH","path":"/cms_content/:identity/status"},{"method":"PATCH","path":"/cs_ticket/:identity/status"},{"method":"PATCH","path":"/wallet_basic/:identity/status"},{"method":"PATCH","path":"/fin_settlement/:identity/status"},{"method":"DELETE","path":"/ec_product_attribute/:identity"},{"method":"DELETE","path":"/ec_product_image/:identity"},{"method":"DELETE","path":"/ec_product/:identity"},{"method":"DELETE","path":"/ec_category/:identity"},{"method":"DELETE","path":"/user_account/:identity"},{"method":"DELETE","path":"/user_address/:identity"},{"method":"DELETE","path":"/user_service_relation/:identity"},{"method":"DELETE","path":"/gas_basic/:identity"},{"method":"DELETE","path":"/gas_account/:identity"},{"method":"DELETE","path":"/delivery_basic/:identity"},{"method":"DELETE","path":"/delivery_account/:identity"},{"method":"DELETE","path":"/staff_account/:identity"},{"method":"DELETE","path":"/staff_credential/:identity"},{"method":"DELETE","path":"/cms_content/:identity"},{"method":"DELETE","path":"/cs_ticket/:identity"},{"method":"DELETE","path":"/platform_account/:identity"},{"method":"DELETE","path":"/platform_role/:identity"},{"method":"DELETE","path":"/fin_settlement/:identity"}]} +{"resources":[{"domain":"gas","name":"gas_basic","path":"/gas_basic","pageKind":"list","mode":"writable"},{"domain":"gas","name":"gas_account","path":"/gas_account","pageKind":"list","mode":"writable"},{"domain":"delivery","name":"delivery_basic","path":"/delivery_basic","pageKind":"list","mode":"writable"},{"domain":"delivery","name":"delivery_account","path":"/delivery_account","pageKind":"list","mode":"writable"},{"domain":"staff","name":"staff_account","path":"/staff_account","pageKind":"list","mode":"writable"},{"domain":"staff","name":"staff_credential","path":"/staff_credential","pageKind":"list","mode":"writable"},{"domain":"user","name":"user_account","path":"/user_account","pageKind":"list","mode":"writable"},{"domain":"user","name":"user_address","path":"/user_address","pageKind":"list","mode":"writable"},{"domain":"user","name":"user_service_relation","path":"/user_service_relation","pageKind":"list","mode":"writable"},{"domain":"product","name":"product_type","path":"/product_type","pageKind":"list","mode":"editable"},{"domain":"product","name":"product_warehouse","path":"/product_warehouse","pageKind":"list","mode":"editable"},{"domain":"product","name":"product_info","path":"/product_info","pageKind":"list","mode":"editable"},{"domain":"product","name":"product_repair","path":"/product_repair","pageKind":"list","mode":"editable"},{"domain":"product","name":"product_owner","path":"/product_owner","pageKind":"list","mode":"readonly"},{"domain":"ec","name":"ec_category","path":"/ec_category","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_product","path":"/ec_product","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_product_attribute","path":"/ec_product_attribute","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_product_image","path":"/ec_product_image","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_cart","path":"/ec_cart","pageKind":"list","mode":"readonly"},{"domain":"ec","name":"ec_order","path":"/ec_order","pageKind":"list","mode":"readonly"},{"domain":"ec","name":"ec_order_item","path":"/ec_order_item","pageKind":"list","mode":"readonly"},{"domain":"ec","name":"ec_review","path":"/ec_review","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_contract","path":"/gasorder_contract","pageKind":"list","mode":"managed"},{"domain":"gasorder","name":"gasorder_contract_product","path":"/gasorder_contract_product","pageKind":"list","mode":"append_only"},{"domain":"gasorder","name":"gasorder_contract_revision","path":"/gasorder_contract_revision","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_basic","path":"/gasorder_basic","pageKind":"list","mode":"append_only"},{"domain":"gasorder","name":"gasorder_item","path":"/gasorder_item","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_assign","path":"/gasorder_assign","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_status","path":"/gasorder_status","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_track","path":"/gasorder_track","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_track_point","path":"/gasorder_track_point","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_confirm","path":"/gasorder_confirm","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_payment","path":"/gasorder_payment","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"fin_payment","path":"/fin_payment","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"fin_settlement","path":"/fin_settlement","pageKind":"list","mode":"writable"},{"domain":"finance","name":"fin_reconciliation","path":"/fin_reconciliation","pageKind":"list","mode":"readonly"},{"domain":"content","name":"cms_content","path":"/cms_content","pageKind":"list","mode":"writable"},{"domain":"customer_service","name":"cs_ticket","path":"/cs_ticket","pageKind":"list","mode":"writable"},{"domain":"platform","name":"platform_account","path":"/platform_account","pageKind":"list","mode":"writable"},{"domain":"platform","name":"platform_role","path":"/platform_role","pageKind":"list","mode":"writable"},{"domain":"platform","name":"platform_menu","path":"/platform_menu","pageKind":"tree","mode":"readonly"},{"domain":"wallet","name":"wallet_basic","path":"/wallet_basic","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"wallet_bank","path":"/wallet_bank","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"payment_order","path":"/payment_order","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"wallet_record","path":"/wallet_record","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"payment_refund","path":"/payment_refund","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"wallet_apply_cash","path":"/wallet_apply_cash","pageKind":"list","mode":"readonly"}],"routes":[{"method":"GET","path":"/gasorder_contract"},{"method":"GET","path":"/gasorder_contract_product"},{"method":"GET","path":"/gasorder_contract_product/:identity"},{"method":"GET","path":"/gasorder_contract_revision"},{"method":"GET","path":"/gasorder_contract_revision/:identity"},{"method":"GET","path":"/gasorder_contract/:identity"},{"method":"GET","path":"/gasorder_confirm"},{"method":"GET","path":"/gasorder_confirm/:identity"},{"method":"GET","path":"/gasorder_track"},{"method":"GET","path":"/gasorder_track_point"},{"method":"GET","path":"/gasorder_track_point/:identity"},{"method":"GET","path":"/gasorder_track/:identity"},{"method":"GET","path":"/gasorder_basic"},{"method":"GET","path":"/gasorder_basic/:identity"},{"method":"GET","path":"/gasorder_item"},{"method":"GET","path":"/gasorder_item/:identity"},{"method":"GET","path":"/gasorder_assign"},{"method":"GET","path":"/gasorder_assign/:identity"},{"method":"GET","path":"/gasorder_status"},{"method":"GET","path":"/gasorder_status/:identity"},{"method":"GET","path":"/gasorder_payment"},{"method":"GET","path":"/gasorder_payment/:identity"},{"method":"GET","path":"/gas_basic"},{"method":"GET","path":"/gas_basic/:identity"},{"method":"GET","path":"/gas_account"},{"method":"GET","path":"/gas_account/:identity"},{"method":"GET","path":"/product_type"},{"method":"GET","path":"/product_type/:identity"},{"method":"GET","path":"/product_warehouse"},{"method":"GET","path":"/product_warehouse/:identity"},{"method":"GET","path":"/product_info"},{"method":"GET","path":"/product_info/:identity"},{"method":"GET","path":"/product_repair"},{"method":"GET","path":"/product_repair/:identity"},{"method":"GET","path":"/product_owner"},{"method":"GET","path":"/product_owner/:identity"},{"method":"GET","path":"/platform_role"},{"method":"GET","path":"/platform_role/:identity"},{"method":"GET","path":"/platform_role/:identity/menu"},{"method":"GET","path":"/platform_account"},{"method":"GET","path":"/platform_account/:identity"},{"method":"GET","path":"/platform_menu"},{"method":"GET","path":"/platform_menu/:identity"},{"method":"GET","path":"/payment_order"},{"method":"GET","path":"/payment_order/:identity"},{"method":"GET","path":"/payment_refund"},{"method":"GET","path":"/payment_refund/:identity"},{"method":"GET","path":"/ping/hello"},{"method":"GET","path":"/ec_product"},{"method":"GET","path":"/ec_product_attribute"},{"method":"GET","path":"/ec_product_attribute/:identity"},{"method":"GET","path":"/ec_product_image"},{"method":"GET","path":"/ec_product_image/:identity"},{"method":"GET","path":"/ec_product/:identity"},{"method":"GET","path":"/ec_category"},{"method":"GET","path":"/ec_category/:identity"},{"method":"GET","path":"/ec_cart"},{"method":"GET","path":"/ec_cart/:identity"},{"method":"GET","path":"/ec_order"},{"method":"GET","path":"/ec_order_item"},{"method":"GET","path":"/ec_order_item/:identity"},{"method":"GET","path":"/ec_order/:identity"},{"method":"GET","path":"/ec_review"},{"method":"GET","path":"/ec_review/:identity"},{"method":"GET","path":"/wallet_basic"},{"method":"GET","path":"/wallet_basic/:identity"},{"method":"GET","path":"/wallet_bank"},{"method":"GET","path":"/wallet_bank/:identity"},{"method":"GET","path":"/wallet_record"},{"method":"GET","path":"/wallet_record/:identity"},{"method":"GET","path":"/wallet_apply_cash"},{"method":"GET","path":"/wallet_apply_cash/:identity"},{"method":"GET","path":"/user_account"},{"method":"GET","path":"/user_account/:identity"},{"method":"GET","path":"/user_address"},{"method":"GET","path":"/user_address/:identity"},{"method":"GET","path":"/user_service_relation"},{"method":"GET","path":"/user_service_relation/:identity"},{"method":"GET","path":"/fin_payment"},{"method":"GET","path":"/fin_payment/:identity"},{"method":"GET","path":"/fin_settlement"},{"method":"GET","path":"/fin_settlement/:identity"},{"method":"GET","path":"/fin_reconciliation"},{"method":"GET","path":"/fin_reconciliation/:identity"},{"method":"GET","path":"/delivery_basic"},{"method":"GET","path":"/delivery_basic/:identity"},{"method":"GET","path":"/delivery_account"},{"method":"GET","path":"/delivery_account/:identity"},{"method":"GET","path":"/dashboard/overview"},{"method":"GET","path":"/staff_account"},{"method":"GET","path":"/staff_account/:identity"},{"method":"GET","path":"/staff_credential"},{"method":"GET","path":"/staff_credential/:identity"},{"method":"GET","path":"/cms_content"},{"method":"GET","path":"/cms_content/:identity"},{"method":"GET","path":"/cs_ticket"},{"method":"GET","path":"/cs_ticket/:identity"},{"method":"GET","path":"/auth/profile"},{"method":"POST","path":"/gasorder_basic"},{"method":"POST","path":"/gasorder_basic/:identity/assign"},{"method":"POST","path":"/gasorder_basic/:identity/awaiting-confirmation"},{"method":"POST","path":"/gasorder_basic/:identity/ready"},{"method":"POST","path":"/gasorder_basic/:identity/recover"},{"method":"POST","path":"/gasorder_basic/:identity/complete"},{"method":"POST","path":"/gasorder_basic/:identity/cancel"},{"method":"POST","path":"/gasorder_basic/:identity/filling"},{"method":"POST","path":"/gasorder_basic/:identity/delivering"},{"method":"POST","path":"/gasorder_basic/:identity/exception"},{"method":"POST","path":"/gasorder_contract"},{"method":"POST","path":"/gasorder_contract/:identity/activate"},{"method":"POST","path":"/gasorder_contract/:identity/renew"},{"method":"POST","path":"/gasorder_contract/:identity/terminate"},{"method":"POST","path":"/gasorder_contract_product"},{"method":"POST","path":"/gasorder_contract_product/:identity/unbind"},{"method":"POST","path":"/gas_basic"},{"method":"POST","path":"/gas_basic/:identity/review"},{"method":"POST","path":"/gas_account"},{"method":"POST","path":"/product_type"},{"method":"POST","path":"/product_warehouse"},{"method":"POST","path":"/product_info"},{"method":"POST","path":"/product_repair"},{"method":"POST","path":"/payment_refund/:identity/approve"},{"method":"POST","path":"/payment_refund/:identity/reject"},{"method":"POST","path":"/platform_account"},{"method":"POST","path":"/platform_role"},{"method":"POST","path":"/wallet_apply_cash/:identity/approve"},{"method":"POST","path":"/wallet_apply_cash/:identity/reject"},{"method":"POST","path":"/wallet_apply_cash/:identity/complete"},{"method":"POST","path":"/wallet_basic/owner/:owner_type/:owner_identity"},{"method":"POST","path":"/wallet_basic/:identity/recharge"},{"method":"POST","path":"/ec_product"},{"method":"POST","path":"/ec_product_attribute"},{"method":"POST","path":"/ec_product_image"},{"method":"POST","path":"/ec_category"},{"method":"POST","path":"/user_account"},{"method":"POST","path":"/user_address"},{"method":"POST","path":"/user_service_relation"},{"method":"POST","path":"/delivery_basic"},{"method":"POST","path":"/delivery_account"},{"method":"POST","path":"/staff_account"},{"method":"POST","path":"/staff_credential"},{"method":"POST","path":"/cms_content"},{"method":"POST","path":"/cs_ticket"},{"method":"POST","path":"/auth/login"},{"method":"POST","path":"/fin_settlement"},{"method":"PUT","path":"/product_type/:identity"},{"method":"PUT","path":"/product_warehouse/:identity"},{"method":"PUT","path":"/product_info/:identity"},{"method":"PUT","path":"/product_repair/:identity"},{"method":"PUT","path":"/platform_role/:identity"},{"method":"PUT","path":"/platform_role/:identity/menu"},{"method":"PUT","path":"/platform_account/:identity"},{"method":"PUT","path":"/ec_product_attribute/:identity"},{"method":"PUT","path":"/ec_product_image/:identity"},{"method":"PUT","path":"/ec_product/:identity"},{"method":"PUT","path":"/ec_category/:identity"},{"method":"PUT","path":"/gas_basic/:identity"},{"method":"PUT","path":"/gas_account/:identity"},{"method":"PUT","path":"/gasorder_contract/:identity"},{"method":"PUT","path":"/user_account/:identity"},{"method":"PUT","path":"/user_address/:identity"},{"method":"PUT","path":"/user_service_relation/:identity"},{"method":"PUT","path":"/delivery_basic/:identity"},{"method":"PUT","path":"/delivery_account/:identity"},{"method":"PUT","path":"/staff_account/:identity"},{"method":"PUT","path":"/staff_credential/:identity"},{"method":"PUT","path":"/cms_content/:identity"},{"method":"PUT","path":"/cs_ticket/:identity"},{"method":"PUT","path":"/auth/password"},{"method":"PUT","path":"/fin_settlement/:identity"},{"method":"PATCH","path":"/product_info/:identity/status"},{"method":"PATCH","path":"/product_info/:identity/lifecycle"},{"method":"PATCH","path":"/product_type/:identity/status"},{"method":"PATCH","path":"/product_warehouse/:identity/status"},{"method":"PATCH","path":"/product_repair/:identity/status"},{"method":"PATCH","path":"/platform_account/:identity/status"},{"method":"PATCH","path":"/platform_role/:identity/status"},{"method":"PATCH","path":"/ec_product_attribute/:identity/status"},{"method":"PATCH","path":"/ec_product_image/:identity/status"},{"method":"PATCH","path":"/ec_product/:identity/status"},{"method":"PATCH","path":"/ec_category/:identity/status"},{"method":"PATCH","path":"/user_account/:identity/status"},{"method":"PATCH","path":"/user_address/:identity/status"},{"method":"PATCH","path":"/user_service_relation/:identity/status"},{"method":"PATCH","path":"/gas_basic/:identity/status"},{"method":"PATCH","path":"/gas_account/:identity/status"},{"method":"PATCH","path":"/delivery_basic/:identity/status"},{"method":"PATCH","path":"/delivery_account/:identity/status"},{"method":"PATCH","path":"/staff_account/:identity/status"},{"method":"PATCH","path":"/staff_credential/:identity/status"},{"method":"PATCH","path":"/cms_content/:identity/status"},{"method":"PATCH","path":"/cs_ticket/:identity/status"},{"method":"PATCH","path":"/wallet_basic/:identity/status"},{"method":"PATCH","path":"/fin_settlement/:identity/status"},{"method":"DELETE","path":"/ec_product_attribute/:identity"},{"method":"DELETE","path":"/ec_product_image/:identity"},{"method":"DELETE","path":"/ec_product/:identity"},{"method":"DELETE","path":"/ec_category/:identity"},{"method":"DELETE","path":"/user_account/:identity"},{"method":"DELETE","path":"/user_address/:identity"},{"method":"DELETE","path":"/user_service_relation/:identity"},{"method":"DELETE","path":"/gas_basic/:identity"},{"method":"DELETE","path":"/gas_account/:identity"},{"method":"DELETE","path":"/delivery_basic/:identity"},{"method":"DELETE","path":"/delivery_account/:identity"},{"method":"DELETE","path":"/staff_account/:identity"},{"method":"DELETE","path":"/staff_credential/:identity"},{"method":"DELETE","path":"/cms_content/:identity"},{"method":"DELETE","path":"/cs_ticket/:identity"},{"method":"DELETE","path":"/platform_account/:identity"},{"method":"DELETE","path":"/platform_role/:identity"},{"method":"DELETE","path":"/fin_settlement/:identity"}]} diff --git a/frontend/platform_admin/src/router/routes/modules/platform.ts b/frontend/platform_admin/src/router/routes/modules/platform.ts index 68a3527..7e2ebe9 100644 --- a/frontend/platform_admin/src/router/routes/modules/platform.ts +++ b/frontend/platform_admin/src/router/routes/modules/platform.ts @@ -111,6 +111,7 @@ const routes: AppRouteRecordRaw[] = [ group('finance', 'finance', '财务管理', 'icon-bar-chart', 90, [ child('finance', 'withdrawals', 'withdrawals', '提现记录', '/wallet_apply_cash', 'wallet_apply_cash'), child('finance', 'payments', 'payments', '支付记录', '/fin_payment', 'fin_payment'), + child('finance', 'refunds', 'refunds', '退款审核', '/payment_refund', 'payment_refund'), child('finance', 'settlements', 'settlements', '财务结算', '/fin_settlement', 'fin_settlement'), child('finance', 'reconciliations', 'reconciliations', '财务对账', '/fin_reconciliation', 'fin_reconciliation'), ]),