feat: integrate unified payment and wallet refunds

This commit is contained in:
2026-08-02 23:24:32 +08:00
parent f0b8af0bc8
commit 82a2106a97
64 changed files with 1519 additions and 254 deletions

View File

@@ -79,6 +79,44 @@ class ClientRepository {
subtitleKeys: const ['amount', 'direction'],
);
Future<List<ClientRecord>> refunds() => _records(
'$root/refunds',
titleKeys: const ['refund_no'],
subtitleKeys: const ['reason', 'amount'],
statusKey: 'refund_status',
);
Future<Map<String, Object?>> 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<void> createRefund({
required String business,
required String identity,
required String requestNo,
required String reason,
required List<Map<String, Object?>> items,
}) async {
await _api.post('$root/$business/orders/$identity/refunds', body: {
'request_no': requestNo,
'reason': reason,
'items': items,
});
}
Future<Map<String, Object?>?> serviceRelation() async {
final value = await _api.get('$root/service-relation');
if (value == null || value == '') return null;

View File

@@ -0,0 +1,2 @@
export 'payment_jsapi_stub.dart'
if (dart.library.js_interop) 'payment_jsapi_web.dart';

View File

@@ -0,0 +1,2 @@
Future<void> invokeWechatJsapi(String clientArgs) =>
throw UnsupportedError('微信 JSAPI 仅支持 Web');

View File

@@ -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<void> invokeWechatJsapi(String clientArgs) {
final completer = Completer<void>();
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;
}

View File

@@ -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<void> launch(Map<String, Object?> 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<String, dynamic>();
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('微信未接受支付请求');
}
}

View File

@@ -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<void> _openActions(
BuildContext context,
ClientRecord record,
String business,
) async {
final action = await showModalBottomSheet<String>(
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<String, Object?>();
return <String, Object?>{
'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<String?> _reason(BuildContext context) async {
final controller = TextEditingController();
final value = await showDialog<String>(
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),
),
],
),
),
);
}

View File

@@ -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<ClientRecord>? onRecordTap;
@override
State<RecordListPage> createState() => _RecordListPageState();
@@ -74,7 +77,10 @@ class _RecordListPageState extends State<RecordListPage> {
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)),
],

View File

@@ -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"

View File

@@ -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:

View File

@@ -1,6 +1,6 @@
# Platform API
平台总后台 API 严格沿用 `sample/server` BSM-SDK Core 分层和运行方式。
平台总后台 API 采用 BSM-SDK Core 分层和运行方式。
启动前请设置运行配置JWT 密钥必须为 16、24 或 32 个字符。

View File

@@ -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 因历史行存在而中断整库迁移。

View File

@@ -1,4 +1,4 @@
// 平台 HTTP API 进程入口,沿用 sample/server 的 BSM 启动规范。
// 平台 HTTP API 进程入口,沿用仓库统一的 BSM 启动规范。
package main
import (

View File

@@ -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"

View File

@@ -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

View File

@@ -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=

View File

@@ -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")

View File

@@ -17,7 +17,7 @@ var (
MemoryService *cache.Cache
)
// NewImpl 与 sample/server 保持一致,所有进程通过此处创建共享基础设施。
// NewImpl 遵循仓库统一约定,所有进程通过此处创建共享基础设施。
func NewImpl() {
MemoryService = with.Memory(nil)
RedisService = with.RedisCache(config.Spec.Cache)

View File

@@ -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 返回用户自己的供气订单。

View File

@@ -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))
}

View File

@@ -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"}).

View File

@@ -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))
}
}

View File

@@ -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{},
}

View File

@@ -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 {

View File

@@ -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},

View File

@@ -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")
}

View File

@@ -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"},

View File

@@ -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")

View File

@@ -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},

View File

@@ -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},
}

View File

@@ -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
}

View File

@@ -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})
}

View File

@@ -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")
}

View File

@@ -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
})
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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},

View File

@@ -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)

View File

@@ -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"),
}
}

View File

@@ -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) }

View File

@@ -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"` // 支付金额,单位分
}

View File

@@ -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" }

View File

@@ -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" }

View File

@@ -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"},

View File

@@ -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" }

View File

@@ -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" }

View File

@@ -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")
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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{})

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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=

View File

@@ -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() }
} }
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -59,7 +59,7 @@ SLA 从事件创建还是用户确认开始计时须由业务方确认;系统
## 3. 商城下单至完成
1. 用户浏览分类和商品,加入购物车或立即购买,选择地址、优惠券、预约时段和支付方式。
2. 系统冻结库存、核算优惠和运费/服务费,创建待支付订单;支付回调成功后转为待履约。
2. 系统冻结库存、核算优惠和运费/服务费,创建待支付订单;支付回调成功后转为待履约。支付统一创建 `payment_order`,客户端返回不作为成功依据,必须以验签回调或主动查单结果为准。支付单默认 30 分钟过期,由 Worker 触发 API 关单,过期后成功回调进入支付异常待人工确认。
3. 若有合同,创建第三方签署任务。需合同的商品应在签约成功后才允许派单;签约失败/超时可取消并按规则退款。
4. 按商品履约属性生成配送、安装或维修任务。系统按区域、技能、上班状态、距离、负载和分账规则分配人员;无法派单时进入人工调度队列。
5. 配送任务从接单开始记录配送轨迹:接单、出发、位置上报、到达、签收/异常等节点按时间顺序关联订单。用户查看简化进度与预计到达,运营人员查看完整轨迹和定位质量。
@@ -97,7 +97,7 @@ SLA 从事件创建还是用户确认开始计时须由业务方确认;系统
## 7. 资金与提现
- 支付、充值、退款、佣金、提现均以不可变资金流水记录,余额是流水聚合结果而非唯一事实来源。
- 支付、充值、退款、佣金、提现均以不可变资金流水记录,余额是流水聚合结果而非唯一事实来源。首期退款只能由用户端在支付成功后配置天数内发起,已履约订单禁止退款;支持订单项和多次部分退款,累计不超过实付金额。财务审核通过后在同一事务内退入用户钱包并写不可变流水,不调用渠道原路退款。
- 提现流程:服务人员/气站提交申请 -> 系统校验可提现余额、实名与风控 -> 财务审核 -> 线下打款/三方打款 -> 回填凭证和结果。
- 附件规定“审核通过,线下打款”;本期应支持审核通过、审核拒绝(必填原因)、打款中、已打款、打款失败、撤回。
- 固定时间对账应对比平台订单、支付渠道、退款和佣金流水;差异项不得自动提现。

View File

@@ -95,7 +95,8 @@
- 用户端 API 固定为 `/heqi/client/v1/user`,令牌客户端为 `user_app`,不得与任何后台或工作人员令牌互用。
- 首期实现手机号密码/验证码登录、普通注册、邀请注册、资料、地址、服务归属、已发布内容与阅读确认、商城下单和余额支付、物流查询/确认收货、供气合同及订单查询、工单、钱包充值/提现/银行卡。邀请注册可携带气站 `identity` 和可选配送点 `identity`,服务端在事务内校验组织关系并建立唯一服务归属。
- 充值先创建待支付订单;仅开发配置允许 Mock 支付确认,确认后才写余额及不可变流水。微信和支付宝未配置渠道时必须明确返回不可用,不得模拟成功。
- 充值先创建待支付订单;仅开发配置允许 Mock 支付确认,确认后才写余额及不可变流水。微信和支付宝未配置渠道时必须明确返回不可用,不得模拟成功。Android/iOS 通过渠道 SDK 调起支付宝 App 支付和微信 App 支付Web 使用支付宝手机网站支付或微信 JSAPI。客户端回传仅用于提示并轮询服务端支付状态不能直接推进订单。
- 用户可在未履约订单的退款窗口内选择订单项和数量提交退款原因;金额由服务端按实付分摊核定。退款申请、审核结果和钱包入账状态均可在订单页查看。
- 商城订单交易状态与物流状态分离;物流单号、公司、发货和收货时间由服务端保存,用户只能查看本人订单并确认收货。
- 首期不伪造设备控制、安全事件、押金、消息、发票、收藏、紧急联系人、账户注销和完整售后能力;文档中这些能力保留为后续迭代,不得以静态成功响应冒充已实现。

View File

@@ -90,7 +90,8 @@
- 登录令牌短期有效,刷新令牌可撤销;后台高权限账号启用 MFA、IP/设备策略。平台后台管理的平台、气站、配送、员工和业主账号密码按当前实施口径仅要求不少于 6 个字符,不附加复杂度校验。
- 权限校验在服务端执行,前端菜单隐藏不构成权限控制。按角色、站点、区域、对象归属联合鉴权。
- 手机号、地址、身份证明、收款账户、定位、视频为敏感数据:传输 TLS、存储加密/字段加密、显示脱敏、访问留痕、最小化留存。
- 所有支付回调验证签名与金额、订单、商户号一致性;合同文件使用可信第三方原文与哈希存证。
- 所有支付回调验证签名与金额、订单、商户号一致性;合同文件使用可信第三方原文与哈希存证。渠道回调入口为 `/heqi/payment-return/v1/{alipay|wechat}/notify`,不使用用户 JWT必须完成渠道证书验签、商户/appid、平台支付单号、金额、币种和状态校验后才可在数据库事务中推进业务。重复通知必须幂等原始敏感报文只保存摘要。
- `payment_order` 是统一支付尝试事实;`payment_refund``payment_refund_item` 保存用户退款申请及明细。审批通过与钱包入账必须同事务完成。
- 图片/视频上传做文件类型、大小、病毒/恶意内容检测;访问采用短期授权,不使用公开桶。
- 设备控制、告警等级调整、资金审核、数据导出、账号注销等高风险操作要求二次确认和审计。

View File

@@ -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) {

View File

@@ -1,4 +1,4 @@
export type ResourceMode =
export type ResourceMode =
| 'writable'
| 'readonly'
| 'append_only'
@@ -140,6 +140,10 @@ const fieldLabels: Record<string, string> = {
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' }),

File diff suppressed because one or more lines are too long

View File

@@ -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'),
]),