From ad54880768dc336d721a6ac665d3c97dba62d969 Mon Sep 17 00:00:00 2001 From: zhx Date: Mon, 15 Jun 2026 19:17:09 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=9B=B8=E5=85=B3=E7=9A=84?= =?UTF-8?q?=E6=94=AF=E4=BB=98=E5=92=8C=E6=96=B0app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/proto/wallet/v1/wallet.pb.go | 38 ++- api/proto/wallet/v1/wallet.proto | 4 + pkg/xerr/grpc.go | 4 +- .../mysql/040_external_recharge_payments.sql | 63 ++++ scripts/mysql/042_mifapay_payment_methods.sql | 63 ++++ scripts/mysql/045_manager_profiles.sql | 44 +++ .../046_mifapay_payment_method_cos_logos.sql | 66 +++++ scripts/mysql/047_add_huwaa_app.sql | 15 + .../admin/internal/modules/hostorg/handler.go | 29 ++ .../admin/internal/modules/hostorg/reader.go | 128 ++++++++ .../admin/internal/modules/hostorg/request.go | 5 + .../admin/internal/modules/hostorg/routes.go | 2 + .../admin/internal/modules/hostorg/service.go | 14 + .../internal/modules/hostorg/service_test.go | 33 +++ .../internal/modules/payment/exchange_rate.go | 274 ++++++++++++++++++ .../admin/internal/modules/payment/handler.go | 69 ++++- .../internal/modules/payment/handler_test.go | 64 ++++ .../admin/internal/modules/payment/routes.go | 1 + server/admin/internal/repository/seed.go | 4 +- .../053_payment_product_menu_rename.sql | 5 + .../internal/transport/http/response_test.go | 65 ++++- .../http/walletapi/h5_recharge_handler.go | 217 +++++++++++++- .../walletapi/h5_recharge_handler_test.go | 25 ++ .../deploy/mysql/initdb/001_user_service.sql | 16 +- .../user-service/internal/domain/host/host.go | 17 ++ .../internal/service/host/business.go | 6 +- .../internal/service/host/commands.go | 6 +- .../internal/service/host/service.go | 5 +- .../internal/service/host/service_test.go | 38 ++- .../internal/storage/mysql/host/common.go | 2 + .../internal/storage/mysql/host/queries.go | 30 +- .../internal/testutil/mysqltest/mysqltest.go | 30 ++ .../internal/transport/grpc/host_admin.go | 3 +- services/wallet-service/configs/config.yaml | 28 +- .../mysql/initdb/001_wallet_service.sql | 63 ++++ .../internal/client/mifapay/client.go | 66 ++++- .../internal/client/mifapay/client_test.go | 108 +++++++ .../wallet-service/internal/config/config.go | 81 +++++- .../internal/config/config_test.go | 119 +++++++- .../internal/domain/ledger/ledger.go | 3 + .../service/wallet/external_recharge.go | 99 ++++++- .../internal/service/wallet/service_test.go | 63 ++++ .../mysql/third_party_payment_repository.go | 68 ++++- .../internal/transport/grpc/server.go | 3 + 44 files changed, 2011 insertions(+), 75 deletions(-) create mode 100644 scripts/mysql/045_manager_profiles.sql create mode 100644 scripts/mysql/046_mifapay_payment_method_cos_logos.sql create mode 100644 scripts/mysql/047_add_huwaa_app.sql create mode 100644 server/admin/internal/modules/payment/exchange_rate.go create mode 100644 server/admin/migrations/053_payment_product_menu_rename.sql create mode 100644 services/gateway-service/internal/transport/http/walletapi/h5_recharge_handler_test.go create mode 100644 services/wallet-service/internal/client/mifapay/client_test.go diff --git a/api/proto/wallet/v1/wallet.pb.go b/api/proto/wallet/v1/wallet.pb.go index f5f13732..1526c903 100644 --- a/api/proto/wallet/v1/wallet.pb.go +++ b/api/proto/wallet/v1/wallet.pb.go @@ -11233,8 +11233,12 @@ type CreateH5RechargeOrderRequest struct { NotifyUrl string `protobuf:"bytes,12,opt,name=notify_url,json=notifyUrl,proto3" json:"notify_url,omitempty"` ClientIp string `protobuf:"bytes,13,opt,name=client_ip,json=clientIp,proto3" json:"client_ip,omitempty"` Language string `protobuf:"bytes,14,opt,name=language,proto3" json:"language,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // payer_name / payer_account 来自 gateway 重新解析的目标用户资料,不接受 H5 前端自报。 + PayerName string `protobuf:"bytes,15,opt,name=payer_name,json=payerName,proto3" json:"payer_name,omitempty"` + PayerAccount string `protobuf:"bytes,16,opt,name=payer_account,json=payerAccount,proto3" json:"payer_account,omitempty"` + PayerEmail string `protobuf:"bytes,17,opt,name=payer_email,json=payerEmail,proto3" json:"payer_email,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *CreateH5RechargeOrderRequest) Reset() { @@ -11365,6 +11369,27 @@ func (x *CreateH5RechargeOrderRequest) GetLanguage() string { return "" } +func (x *CreateH5RechargeOrderRequest) GetPayerName() string { + if x != nil { + return x.PayerName + } + return "" +} + +func (x *CreateH5RechargeOrderRequest) GetPayerAccount() string { + if x != nil { + return x.PayerAccount + } + return "" +} + +func (x *CreateH5RechargeOrderRequest) GetPayerEmail() string { + if x != nil { + return x.PayerEmail + } + return "" +} + type SubmitH5RechargeTxRequest struct { state protoimpl.MessageState `protogen:"open.v1"` RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` @@ -17430,7 +17455,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\x0etransaction_id\x18\x18 \x01(\tR\rtransactionId\x12\"\n" + "\rcreated_at_ms\x18\x19 \x01(\x03R\vcreatedAtMs\x12\"\n" + "\rupdated_at_ms\x18\x1a \x01(\x03R\vupdatedAtMs\x12+\n" + - "\x11idempotent_replay\x18\x1b \x01(\bR\x10idempotentReplay\"\x83\x04\n" + + "\x11idempotent_replay\x18\x1b \x01(\bR\x10idempotentReplay\"\xe8\x04\n" + "\x1cCreateH5RechargeOrderRequest\x12\x1d\n" + "\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" + @@ -17451,7 +17476,12 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\n" + "notify_url\x18\f \x01(\tR\tnotifyUrl\x12\x1b\n" + "\tclient_ip\x18\r \x01(\tR\bclientIp\x12\x1a\n" + - "\blanguage\x18\x0e \x01(\tR\blanguage\"\xaf\x01\n" + + "\blanguage\x18\x0e \x01(\tR\blanguage\x12\x1d\n" + + "\n" + + "payer_name\x18\x0f \x01(\tR\tpayerName\x12#\n" + + "\rpayer_account\x18\x10 \x01(\tR\fpayerAccount\x12\x1f\n" + + "\vpayer_email\x18\x11 \x01(\tR\n" + + "payerEmail\"\xaf\x01\n" + "\x19SubmitH5RechargeTxRequest\x12\x1d\n" + "\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" + diff --git a/api/proto/wallet/v1/wallet.proto b/api/proto/wallet/v1/wallet.proto index 01fa15ae..bf7f1101 100644 --- a/api/proto/wallet/v1/wallet.proto +++ b/api/proto/wallet/v1/wallet.proto @@ -1276,6 +1276,10 @@ message CreateH5RechargeOrderRequest { string notify_url = 12; string client_ip = 13; string language = 14; + // payer_name / payer_account 来自 gateway 重新解析的目标用户资料,不接受 H5 前端自报。 + string payer_name = 15; + string payer_account = 16; + string payer_email = 17; } message SubmitH5RechargeTxRequest { diff --git a/pkg/xerr/grpc.go b/pkg/xerr/grpc.go index f51474a8..ce09f661 100644 --- a/pkg/xerr/grpc.go +++ b/pkg/xerr/grpc.go @@ -78,7 +78,9 @@ func codeFromGRPC(code codes.Code) Code { return Unauthorized case codes.PermissionDenied: return PermissionDenied - case codes.Unavailable: + case codes.Unavailable, codes.DeadlineExceeded: + // 下游服务未启动、网络不可达或 gRPC deadline 到期都属于依赖暂不可用;gateway 应返回 UPSTREAM_ERROR, + // 不能降级为 INTERNAL_ERROR,否则本地联调和线上排障都会误判成业务代码内部崩溃。 return Unavailable case codes.AlreadyExists, codes.FailedPrecondition, codes.Aborted: return Conflict diff --git a/scripts/mysql/040_external_recharge_payments.sql b/scripts/mysql/040_external_recharge_payments.sql index a26f4650..71395bb2 100644 --- a/scripts/mysql/040_external_recharge_payments.sql +++ b/scripts/mysql/040_external_recharge_payments.sql @@ -127,3 +127,66 @@ INSERT IGNORE INTO third_party_payment_methods ( ('lalu', 'mifapay', 'PH', 'Philippines', 'PHP', 'Ewallet', 'Gcash', 'GCash', '', 'active', 1, 1310, @now_ms, @now_ms), ('lalu', 'mifapay', 'PH', 'Philippines', 'PHP', 'Ewallet', 'PayMaya', 'PayMaya', '', 'active', 1, 1320, @now_ms, @now_ms), ('lalu', 'mifapay', 'PH', 'Philippines', 'PHP', 'QR', 'QRPH', 'QRPH', '', 'active', 1, 1330, @now_ms, @now_ms); + +-- MiFaPay logo 使用已上传 COS 的固定对象地址;仅覆盖空值、早期占位图和 Apifox 临时预览图,避免误改运营手工替换的正式图。 +UPDATE third_party_payment_methods m +JOIN ( + SELECT 'AE' AS country_code, 'Card' AS pay_way, 'CreditCard' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/ae/card-creditcard.png' AS logo_url + UNION ALL SELECT 'BD' AS country_code, 'Ewallet' AS pay_way, 'Nagad' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/bd/ewallet-nagad.png' AS logo_url + UNION ALL SELECT 'BD' AS country_code, 'Ewallet' AS pay_way, 'bKash' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/bd/ewallet-bkash.png' AS logo_url + UNION ALL SELECT 'BH' AS country_code, 'Card' AS pay_way, 'CreditCard' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/bh/card-creditcard.png' AS logo_url + UNION ALL SELECT 'EG' AS country_code, 'Card' AS pay_way, 'CreditCard' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/eg/card-creditcard.png' AS logo_url + UNION ALL SELECT 'EG' AS country_code, 'Ewallet' AS pay_way, 'FawryPay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/eg/ewallet-fawrypay.png' AS logo_url + UNION ALL SELECT 'EG' AS country_code, 'Ewallet' AS pay_way, 'Meeza' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/eg/ewallet-meeza.png' AS logo_url + UNION ALL SELECT 'EG' AS country_code, 'Ewallet' AS pay_way, 'Opay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/eg/ewallet-opay.png' AS logo_url + UNION ALL SELECT 'EG' AS country_code, 'Ewallet' AS pay_way, 'Orange' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/eg/ewallet-orange.png' AS logo_url + UNION ALL SELECT 'EG' AS country_code, 'Ewallet' AS pay_way, 'Vodafone' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/eg/ewallet-vodafone.png' AS logo_url + UNION ALL SELECT 'GLOBAL' AS country_code, 'BankTransfer' AS pay_way, 'UnionPay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/global/banktransfer-unionpay.png' AS logo_url + UNION ALL SELECT 'GLOBAL' AS country_code, 'Card' AS pay_way, 'CreditCard' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/global/card-creditcard.png' AS logo_url + UNION ALL SELECT 'GLOBAL' AS country_code, 'Ewallet' AS pay_way, 'ApplePay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/global/ewallet-applepay.png' AS logo_url + UNION ALL SELECT 'GLOBAL' AS country_code, 'Ewallet' AS pay_way, 'GooglePay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/global/ewallet-googlepay.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'BankTransfer' AS pay_way, 'BCA' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-bca.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'BankTransfer' AS pay_way, 'BNI' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-bni.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'BankTransfer' AS pay_way, 'BRI' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-bri.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'BankTransfer' AS pay_way, 'Mandiri' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-mandiri.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'BankTransfer' AS pay_way, 'Permata' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-permata.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'BankTransfer' AS pay_way, 'QRIS' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-qris.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'Ewallet' AS pay_way, 'DANA' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/ewallet-dana.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'Ewallet' AS pay_way, 'LinkAja' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/ewallet-linkaja.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'Ewallet' AS pay_way, 'OVO' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/ewallet-ovo.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'Ewallet' AS pay_way, 'ShopeePay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/ewallet-shopeepay.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'QR' AS pay_way, 'QRIS' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/qr-qris.png' AS logo_url + UNION ALL SELECT 'IN' AS country_code, 'Ewallet' AS pay_way, 'BHIM' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-bhim.png' AS logo_url + UNION ALL SELECT 'IN' AS country_code, 'Ewallet' AS pay_way, 'GooglePay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-googlepay.png' AS logo_url + UNION ALL SELECT 'IN' AS country_code, 'Ewallet' AS pay_way, 'Mobikwik' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-mobikwik.png' AS logo_url + UNION ALL SELECT 'IN' AS country_code, 'Ewallet' AS pay_way, 'Paytm' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-paytm.png' AS logo_url + UNION ALL SELECT 'IN' AS country_code, 'Ewallet' AS pay_way, 'PhonePe' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-phonepe.png' AS logo_url + UNION ALL SELECT 'IN' AS country_code, 'Ewallet' AS pay_way, 'UPI' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-upi.png' AS logo_url + UNION ALL SELECT 'KW' AS country_code, 'Card' AS pay_way, 'KENT' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/kw/card-kent.png' AS logo_url + UNION ALL SELECT 'KW' AS country_code, 'Card' AS pay_way, 'Knet' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/kw/card-knet.png' AS logo_url + UNION ALL SELECT 'MY' AS country_code, 'BankTransfer' AS pay_way, 'FPX' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/my/banktransfer-fpx.png' AS logo_url + UNION ALL SELECT 'MY' AS country_code, 'Ewallet' AS pay_way, 'TNG' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/my/ewallet-tng.png' AS logo_url + UNION ALL SELECT 'OM' AS country_code, 'Card' AS pay_way, 'CreditCard' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/om/card-creditcard.png' AS logo_url + UNION ALL SELECT 'PH' AS country_code, 'BankTransfer' AS pay_way, 'QRPH' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/ph/banktransfer-qrph.png' AS logo_url + UNION ALL SELECT 'PH' AS country_code, 'Ewallet' AS pay_way, 'Gcash' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/ph/ewallet-gcash.png' AS logo_url + UNION ALL SELECT 'PH' AS country_code, 'Ewallet' AS pay_way, 'PayMaya' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/ph/ewallet-paymaya.png' AS logo_url + UNION ALL SELECT 'PH' AS country_code, 'QR' AS pay_way, 'QRPH' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/ph/qr-qrph.png' AS logo_url + UNION ALL SELECT 'PK' AS country_code, 'Ewallet' AS pay_way, 'Easypaisa' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/pk/ewallet-easypaisa.png' AS logo_url + UNION ALL SELECT 'PK' AS country_code, 'Ewallet' AS pay_way, 'JazzCash' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/pk/ewallet-jazzcash.png' AS logo_url + UNION ALL SELECT 'QA' AS country_code, 'Card' AS pay_way, 'CreditCard' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/qa/card-creditcard.png' AS logo_url + UNION ALL SELECT 'SA' AS country_code, 'Card' AS pay_way, 'MADA' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/sa/card-mada.png' AS logo_url + UNION ALL SELECT 'SA' AS country_code, 'Ewallet' AS pay_way, 'ApplePay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/sa/ewallet-applepay.png' AS logo_url + UNION ALL SELECT 'SA' AS country_code, 'Ewallet' AS pay_way, 'STCPay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/sa/ewallet-stcpay.png' AS logo_url + UNION ALL SELECT 'TH' AS country_code, 'BankTransfer' AS pay_way, 'PromptPay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/th/banktransfer-promptpay.png' AS logo_url + UNION ALL SELECT 'TH' AS country_code, 'Ewallet' AS pay_way, 'TrueMoney' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/th/ewallet-truemoney.png' AS logo_url + UNION ALL SELECT 'VN' AS country_code, 'BankTransfer' AS pay_way, 'VN_BankTransfer' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/vn/banktransfer-vn-banktransfer.png' AS logo_url + UNION ALL SELECT 'VN' AS country_code, 'QR' AS pay_way, 'VietQR' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/vn/qr-vietqr.png' AS logo_url +) logos + ON UPPER(m.country_code) = logos.country_code + AND LOWER(m.pay_way) = LOWER(logos.pay_way) + AND LOWER(m.pay_type) = LOWER(logos.pay_type) +SET m.logo_url = logos.logo_url, + m.updated_at_ms = @now_ms +WHERE m.app_code = 'lalu' + AND m.provider_code = 'mifapay' + AND (m.logo_url = '' OR m.logo_url LIKE 'https://placehold.co/%' OR m.logo_url LIKE '%/image-preview'); diff --git a/scripts/mysql/042_mifapay_payment_methods.sql b/scripts/mysql/042_mifapay_payment_methods.sql index 4c366999..c9a6de0a 100644 --- a/scripts/mysql/042_mifapay_payment_methods.sql +++ b/scripts/mysql/042_mifapay_payment_methods.sql @@ -105,3 +105,66 @@ ON DUPLICATE KEY UPDATE usd_to_currency_rate = IF(usd_to_currency_rate > 0, usd_to_currency_rate, VALUES(usd_to_currency_rate)), sort_order = VALUES(sort_order), updated_at_ms = VALUES(updated_at_ms); + +-- MiFaPay logo 使用已上传 COS 的固定对象地址;仅覆盖空值、早期占位图和 Apifox 临时预览图,避免误改运营手工替换的正式图。 +UPDATE third_party_payment_methods m +JOIN ( + SELECT 'AE' AS country_code, 'Card' AS pay_way, 'CreditCard' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/ae/card-creditcard.png' AS logo_url + UNION ALL SELECT 'BD' AS country_code, 'Ewallet' AS pay_way, 'Nagad' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/bd/ewallet-nagad.png' AS logo_url + UNION ALL SELECT 'BD' AS country_code, 'Ewallet' AS pay_way, 'bKash' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/bd/ewallet-bkash.png' AS logo_url + UNION ALL SELECT 'BH' AS country_code, 'Card' AS pay_way, 'CreditCard' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/bh/card-creditcard.png' AS logo_url + UNION ALL SELECT 'EG' AS country_code, 'Card' AS pay_way, 'CreditCard' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/eg/card-creditcard.png' AS logo_url + UNION ALL SELECT 'EG' AS country_code, 'Ewallet' AS pay_way, 'FawryPay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/eg/ewallet-fawrypay.png' AS logo_url + UNION ALL SELECT 'EG' AS country_code, 'Ewallet' AS pay_way, 'Meeza' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/eg/ewallet-meeza.png' AS logo_url + UNION ALL SELECT 'EG' AS country_code, 'Ewallet' AS pay_way, 'Opay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/eg/ewallet-opay.png' AS logo_url + UNION ALL SELECT 'EG' AS country_code, 'Ewallet' AS pay_way, 'Orange' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/eg/ewallet-orange.png' AS logo_url + UNION ALL SELECT 'EG' AS country_code, 'Ewallet' AS pay_way, 'Vodafone' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/eg/ewallet-vodafone.png' AS logo_url + UNION ALL SELECT 'GLOBAL' AS country_code, 'BankTransfer' AS pay_way, 'UnionPay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/global/banktransfer-unionpay.png' AS logo_url + UNION ALL SELECT 'GLOBAL' AS country_code, 'Card' AS pay_way, 'CreditCard' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/global/card-creditcard.png' AS logo_url + UNION ALL SELECT 'GLOBAL' AS country_code, 'Ewallet' AS pay_way, 'ApplePay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/global/ewallet-applepay.png' AS logo_url + UNION ALL SELECT 'GLOBAL' AS country_code, 'Ewallet' AS pay_way, 'GooglePay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/global/ewallet-googlepay.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'BankTransfer' AS pay_way, 'BCA' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-bca.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'BankTransfer' AS pay_way, 'BNI' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-bni.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'BankTransfer' AS pay_way, 'BRI' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-bri.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'BankTransfer' AS pay_way, 'Mandiri' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-mandiri.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'BankTransfer' AS pay_way, 'Permata' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-permata.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'BankTransfer' AS pay_way, 'QRIS' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-qris.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'Ewallet' AS pay_way, 'DANA' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/ewallet-dana.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'Ewallet' AS pay_way, 'LinkAja' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/ewallet-linkaja.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'Ewallet' AS pay_way, 'OVO' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/ewallet-ovo.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'Ewallet' AS pay_way, 'ShopeePay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/ewallet-shopeepay.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'QR' AS pay_way, 'QRIS' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/qr-qris.png' AS logo_url + UNION ALL SELECT 'IN' AS country_code, 'Ewallet' AS pay_way, 'BHIM' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-bhim.png' AS logo_url + UNION ALL SELECT 'IN' AS country_code, 'Ewallet' AS pay_way, 'GooglePay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-googlepay.png' AS logo_url + UNION ALL SELECT 'IN' AS country_code, 'Ewallet' AS pay_way, 'Mobikwik' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-mobikwik.png' AS logo_url + UNION ALL SELECT 'IN' AS country_code, 'Ewallet' AS pay_way, 'Paytm' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-paytm.png' AS logo_url + UNION ALL SELECT 'IN' AS country_code, 'Ewallet' AS pay_way, 'PhonePe' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-phonepe.png' AS logo_url + UNION ALL SELECT 'IN' AS country_code, 'Ewallet' AS pay_way, 'UPI' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-upi.png' AS logo_url + UNION ALL SELECT 'KW' AS country_code, 'Card' AS pay_way, 'KENT' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/kw/card-kent.png' AS logo_url + UNION ALL SELECT 'KW' AS country_code, 'Card' AS pay_way, 'Knet' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/kw/card-knet.png' AS logo_url + UNION ALL SELECT 'MY' AS country_code, 'BankTransfer' AS pay_way, 'FPX' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/my/banktransfer-fpx.png' AS logo_url + UNION ALL SELECT 'MY' AS country_code, 'Ewallet' AS pay_way, 'TNG' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/my/ewallet-tng.png' AS logo_url + UNION ALL SELECT 'OM' AS country_code, 'Card' AS pay_way, 'CreditCard' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/om/card-creditcard.png' AS logo_url + UNION ALL SELECT 'PH' AS country_code, 'BankTransfer' AS pay_way, 'QRPH' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/ph/banktransfer-qrph.png' AS logo_url + UNION ALL SELECT 'PH' AS country_code, 'Ewallet' AS pay_way, 'Gcash' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/ph/ewallet-gcash.png' AS logo_url + UNION ALL SELECT 'PH' AS country_code, 'Ewallet' AS pay_way, 'PayMaya' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/ph/ewallet-paymaya.png' AS logo_url + UNION ALL SELECT 'PH' AS country_code, 'QR' AS pay_way, 'QRPH' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/ph/qr-qrph.png' AS logo_url + UNION ALL SELECT 'PK' AS country_code, 'Ewallet' AS pay_way, 'Easypaisa' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/pk/ewallet-easypaisa.png' AS logo_url + UNION ALL SELECT 'PK' AS country_code, 'Ewallet' AS pay_way, 'JazzCash' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/pk/ewallet-jazzcash.png' AS logo_url + UNION ALL SELECT 'QA' AS country_code, 'Card' AS pay_way, 'CreditCard' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/qa/card-creditcard.png' AS logo_url + UNION ALL SELECT 'SA' AS country_code, 'Card' AS pay_way, 'MADA' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/sa/card-mada.png' AS logo_url + UNION ALL SELECT 'SA' AS country_code, 'Ewallet' AS pay_way, 'ApplePay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/sa/ewallet-applepay.png' AS logo_url + UNION ALL SELECT 'SA' AS country_code, 'Ewallet' AS pay_way, 'STCPay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/sa/ewallet-stcpay.png' AS logo_url + UNION ALL SELECT 'TH' AS country_code, 'BankTransfer' AS pay_way, 'PromptPay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/th/banktransfer-promptpay.png' AS logo_url + UNION ALL SELECT 'TH' AS country_code, 'Ewallet' AS pay_way, 'TrueMoney' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/th/ewallet-truemoney.png' AS logo_url + UNION ALL SELECT 'VN' AS country_code, 'BankTransfer' AS pay_way, 'VN_BankTransfer' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/vn/banktransfer-vn-banktransfer.png' AS logo_url + UNION ALL SELECT 'VN' AS country_code, 'QR' AS pay_way, 'VietQR' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/vn/qr-vietqr.png' AS logo_url +) logos + ON UPPER(m.country_code) = logos.country_code + AND LOWER(m.pay_way) = LOWER(logos.pay_way) + AND LOWER(m.pay_type) = LOWER(logos.pay_type) +SET m.logo_url = logos.logo_url, + m.updated_at_ms = @now_ms +WHERE m.app_code = 'lalu' + AND m.provider_code = 'mifapay' + AND (m.logo_url = '' OR m.logo_url LIKE 'https://placehold.co/%' OR m.logo_url LIKE '%/image-preview'); diff --git a/scripts/mysql/045_manager_profiles.sql b/scripts/mysql/045_manager_profiles.sql new file mode 100644 index 00000000..d976a12f --- /dev/null +++ b/scripts/mysql/045_manager_profiles.sql @@ -0,0 +1,44 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +USE hyapp_user; + +CREATE TABLE IF NOT EXISTS manager_profiles ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', + user_id BIGINT NOT NULL PRIMARY KEY COMMENT '经理用户 ID', + status VARCHAR(32) NOT NULL COMMENT '业务状态', + contact_info VARCHAR(128) NOT NULL DEFAULT '' COMMENT '联系信息', + created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建后台管理员 ID', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + KEY idx_manager_profiles_status (app_code, status, updated_at_ms), + KEY idx_manager_profiles_created_by (app_code, created_by_admin_id, created_at_ms) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='经理资料表'; + +SET @ddl := IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'manager_profiles' AND COLUMN_NAME = 'contact_info') = 0, + 'ALTER TABLE manager_profiles ADD COLUMN contact_info VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''联系信息'' AFTER status', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +INSERT INTO manager_profiles ( + app_code, user_id, status, contact_info, created_by_admin_id, created_at_ms, updated_at_ms +) +SELECT + bl.app_code, + bl.created_by_user_id, + 'active', + '', + 0, + MIN(bl.created_at_ms), + MAX(bl.updated_at_ms) +FROM bd_leader_profiles bl +JOIN users u + ON u.app_code = bl.app_code + AND u.user_id = bl.created_by_user_id +WHERE bl.created_by_user_id > 0 +GROUP BY bl.app_code, bl.created_by_user_id +ON DUPLICATE KEY UPDATE + updated_at_ms = GREATEST(manager_profiles.updated_at_ms, VALUES(updated_at_ms)); diff --git a/scripts/mysql/046_mifapay_payment_method_cos_logos.sql b/scripts/mysql/046_mifapay_payment_method_cos_logos.sql new file mode 100644 index 00000000..080fa700 --- /dev/null +++ b/scripts/mysql/046_mifapay_payment_method_cos_logos.sql @@ -0,0 +1,66 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED); + +-- MiFaPay logo 使用已上传 COS 的固定对象地址;仅覆盖空值、早期占位图和 Apifox 临时预览图,避免误改运营手工替换的正式图。 +UPDATE third_party_payment_methods m +JOIN ( + SELECT 'AE' AS country_code, 'Card' AS pay_way, 'CreditCard' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/ae/card-creditcard.png' AS logo_url + UNION ALL SELECT 'BD' AS country_code, 'Ewallet' AS pay_way, 'Nagad' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/bd/ewallet-nagad.png' AS logo_url + UNION ALL SELECT 'BD' AS country_code, 'Ewallet' AS pay_way, 'bKash' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/bd/ewallet-bkash.png' AS logo_url + UNION ALL SELECT 'BH' AS country_code, 'Card' AS pay_way, 'CreditCard' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/bh/card-creditcard.png' AS logo_url + UNION ALL SELECT 'EG' AS country_code, 'Card' AS pay_way, 'CreditCard' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/eg/card-creditcard.png' AS logo_url + UNION ALL SELECT 'EG' AS country_code, 'Ewallet' AS pay_way, 'FawryPay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/eg/ewallet-fawrypay.png' AS logo_url + UNION ALL SELECT 'EG' AS country_code, 'Ewallet' AS pay_way, 'Meeza' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/eg/ewallet-meeza.png' AS logo_url + UNION ALL SELECT 'EG' AS country_code, 'Ewallet' AS pay_way, 'Opay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/eg/ewallet-opay.png' AS logo_url + UNION ALL SELECT 'EG' AS country_code, 'Ewallet' AS pay_way, 'Orange' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/eg/ewallet-orange.png' AS logo_url + UNION ALL SELECT 'EG' AS country_code, 'Ewallet' AS pay_way, 'Vodafone' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/eg/ewallet-vodafone.png' AS logo_url + UNION ALL SELECT 'GLOBAL' AS country_code, 'BankTransfer' AS pay_way, 'UnionPay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/global/banktransfer-unionpay.png' AS logo_url + UNION ALL SELECT 'GLOBAL' AS country_code, 'Card' AS pay_way, 'CreditCard' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/global/card-creditcard.png' AS logo_url + UNION ALL SELECT 'GLOBAL' AS country_code, 'Ewallet' AS pay_way, 'ApplePay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/global/ewallet-applepay.png' AS logo_url + UNION ALL SELECT 'GLOBAL' AS country_code, 'Ewallet' AS pay_way, 'GooglePay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/global/ewallet-googlepay.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'BankTransfer' AS pay_way, 'BCA' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-bca.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'BankTransfer' AS pay_way, 'BNI' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-bni.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'BankTransfer' AS pay_way, 'BRI' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-bri.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'BankTransfer' AS pay_way, 'Mandiri' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-mandiri.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'BankTransfer' AS pay_way, 'Permata' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-permata.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'BankTransfer' AS pay_way, 'QRIS' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-qris.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'Ewallet' AS pay_way, 'DANA' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/ewallet-dana.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'Ewallet' AS pay_way, 'LinkAja' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/ewallet-linkaja.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'Ewallet' AS pay_way, 'OVO' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/ewallet-ovo.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'Ewallet' AS pay_way, 'ShopeePay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/ewallet-shopeepay.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'QR' AS pay_way, 'QRIS' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/qr-qris.png' AS logo_url + UNION ALL SELECT 'IN' AS country_code, 'Ewallet' AS pay_way, 'BHIM' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-bhim.png' AS logo_url + UNION ALL SELECT 'IN' AS country_code, 'Ewallet' AS pay_way, 'GooglePay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-googlepay.png' AS logo_url + UNION ALL SELECT 'IN' AS country_code, 'Ewallet' AS pay_way, 'Mobikwik' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-mobikwik.png' AS logo_url + UNION ALL SELECT 'IN' AS country_code, 'Ewallet' AS pay_way, 'Paytm' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-paytm.png' AS logo_url + UNION ALL SELECT 'IN' AS country_code, 'Ewallet' AS pay_way, 'PhonePe' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-phonepe.png' AS logo_url + UNION ALL SELECT 'IN' AS country_code, 'Ewallet' AS pay_way, 'UPI' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-upi.png' AS logo_url + UNION ALL SELECT 'KW' AS country_code, 'Card' AS pay_way, 'KENT' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/kw/card-kent.png' AS logo_url + UNION ALL SELECT 'KW' AS country_code, 'Card' AS pay_way, 'Knet' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/kw/card-knet.png' AS logo_url + UNION ALL SELECT 'MY' AS country_code, 'BankTransfer' AS pay_way, 'FPX' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/my/banktransfer-fpx.png' AS logo_url + UNION ALL SELECT 'MY' AS country_code, 'Ewallet' AS pay_way, 'TNG' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/my/ewallet-tng.png' AS logo_url + UNION ALL SELECT 'OM' AS country_code, 'Card' AS pay_way, 'CreditCard' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/om/card-creditcard.png' AS logo_url + UNION ALL SELECT 'PH' AS country_code, 'BankTransfer' AS pay_way, 'QRPH' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/ph/banktransfer-qrph.png' AS logo_url + UNION ALL SELECT 'PH' AS country_code, 'Ewallet' AS pay_way, 'Gcash' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/ph/ewallet-gcash.png' AS logo_url + UNION ALL SELECT 'PH' AS country_code, 'Ewallet' AS pay_way, 'PayMaya' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/ph/ewallet-paymaya.png' AS logo_url + UNION ALL SELECT 'PH' AS country_code, 'QR' AS pay_way, 'QRPH' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/ph/qr-qrph.png' AS logo_url + UNION ALL SELECT 'PK' AS country_code, 'Ewallet' AS pay_way, 'Easypaisa' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/pk/ewallet-easypaisa.png' AS logo_url + UNION ALL SELECT 'PK' AS country_code, 'Ewallet' AS pay_way, 'JazzCash' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/pk/ewallet-jazzcash.png' AS logo_url + UNION ALL SELECT 'QA' AS country_code, 'Card' AS pay_way, 'CreditCard' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/qa/card-creditcard.png' AS logo_url + UNION ALL SELECT 'SA' AS country_code, 'Card' AS pay_way, 'MADA' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/sa/card-mada.png' AS logo_url + UNION ALL SELECT 'SA' AS country_code, 'Ewallet' AS pay_way, 'ApplePay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/sa/ewallet-applepay.png' AS logo_url + UNION ALL SELECT 'SA' AS country_code, 'Ewallet' AS pay_way, 'STCPay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/sa/ewallet-stcpay.png' AS logo_url + UNION ALL SELECT 'TH' AS country_code, 'BankTransfer' AS pay_way, 'PromptPay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/th/banktransfer-promptpay.png' AS logo_url + UNION ALL SELECT 'TH' AS country_code, 'Ewallet' AS pay_way, 'TrueMoney' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/th/ewallet-truemoney.png' AS logo_url + UNION ALL SELECT 'VN' AS country_code, 'BankTransfer' AS pay_way, 'VN_BankTransfer' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/vn/banktransfer-vn-banktransfer.png' AS logo_url + UNION ALL SELECT 'VN' AS country_code, 'QR' AS pay_way, 'VietQR' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/vn/qr-vietqr.png' AS logo_url +) logos + ON UPPER(m.country_code) = logos.country_code + AND LOWER(m.pay_way) = LOWER(logos.pay_way) + AND LOWER(m.pay_type) = LOWER(logos.pay_type) +SET m.logo_url = logos.logo_url, + m.updated_at_ms = @now_ms +WHERE m.app_code = 'lalu' + AND m.provider_code = 'mifapay' + AND (m.logo_url = '' OR m.logo_url LIKE 'https://placehold.co/%' OR m.logo_url LIKE '%/image-preview'); diff --git a/scripts/mysql/047_add_huwaa_app.sql b/scripts/mysql/047_add_huwaa_app.sql new file mode 100644 index 00000000..7823deb0 --- /dev/null +++ b/scripts/mysql/047_add_huwaa_app.sql @@ -0,0 +1,15 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +USE hyapp_user; + +SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED); + +-- App 注册表是 gateway 包名解析的事实来源;Huwaa 使用独立 app_code 隔离用户、钱包、房间和统计数据。 +INSERT INTO apps (app_code, app_name, package_name, platform, status, created_at_ms, updated_at_ms) +VALUES ('huwaa', 'Huwaa', 'com.app.huwaa', '', 'active', @now_ms, @now_ms) +ON DUPLICATE KEY UPDATE + app_name = VALUES(app_name), + package_name = VALUES(package_name), + platform = VALUES(platform), + status = VALUES(status), + updated_at_ms = VALUES(updated_at_ms); diff --git a/server/admin/internal/modules/hostorg/handler.go b/server/admin/internal/modules/hostorg/handler.go index ebcd4a28..3fd05015 100644 --- a/server/admin/internal/modules/hostorg/handler.go +++ b/server/admin/internal/modules/hostorg/handler.go @@ -63,6 +63,35 @@ func (h *Handler) ListAgencies(c *gin.Context) { response.OK(c, response.Page{Items: items, Page: query.Page, PageSize: query.PageSize, Total: total}) } +func (h *Handler) ListManagers(c *gin.Context) { + query, ok := parseListQuery(c) + if !ok { + return + } + items, total, err := h.service.ListManagers(c.Request.Context(), query) + if err != nil { + response.ServerError(c, "获取经理列表失败") + return + } + response.OK(c, response.Page{Items: items, Page: query.Page, PageSize: query.PageSize, Total: total}) +} + +func (h *Handler) CreateManager(c *gin.Context) { + var req createManagerRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "经理参数不正确") + return + } + item, err := h.service.CreateManager(c.Request.Context(), adminActorID(c), middleware.CurrentRequestID(c), req) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + writeHostOrgAuditLog(c, h.audit, "create-manager", "manager_profiles", item.UserID, + fmt.Sprintf("user_id=%d contact=%q", item.UserID, strings.TrimSpace(req.Contact))) + response.Created(c, item) +} + func (h *Handler) ListHosts(c *gin.Context) { query, ok := parseListQuery(c) if !ok { diff --git a/server/admin/internal/modules/hostorg/reader.go b/server/admin/internal/modules/hostorg/reader.go index b9e3f903..32188db2 100644 --- a/server/admin/internal/modules/hostorg/reader.go +++ b/server/admin/internal/modules/hostorg/reader.go @@ -5,6 +5,7 @@ import ( "database/sql" "fmt" "sort" + "strconv" "strings" "time" @@ -41,6 +42,21 @@ type CoinSellerListItem struct { UpdatedAtMs int64 `json:"updatedAtMs"` } +type ManagerListItem struct { + UserID int64 `json:"userId,string"` + DisplayUserID string `json:"displayUserId"` + Username string `json:"username"` + Avatar string `json:"avatar"` + RegionID int64 `json:"regionId"` + RegionName string `json:"regionName"` + Status string `json:"status"` + Contact string `json:"contact"` + BDLeaderCount int64 `json:"bdLeaderCount"` + LastInvitedAtMs int64 `json:"lastInvitedAtMs"` + CreatedAtMs int64 `json:"createdAtMs"` + UpdatedAtMs int64 `json:"updatedAtMs"` +} + type CoinSellerSalaryRateTier struct { RegionID int64 `json:"regionId,string"` MinUSDMinor int64 `json:"minUsdMinor"` @@ -557,6 +573,118 @@ func (r *Reader) DeleteBDLeader(ctx context.Context, userID int64) (*userclient. return item, nil } +// CreateManagerProfile 创建或恢复经理身份。 +// Manager 是 manager_profiles 表里的独立身份;这里只写经理事实和联系方式,不改 Host/Agency/BD 任一关系。 +func (r *Reader) CreateManagerProfile(ctx context.Context, userID int64, actorID int64, contact string) (*ManagerListItem, error) { + if r == nil || r.db == nil { + return nil, errUserDBNotConfigured() + } + if userID <= 0 { + return nil, fmt.Errorf("target_user_id is required") + } + contact = strings.TrimSpace(contact) + if len([]rune(contact)) > 128 { + return nil, fmt.Errorf("contact is too long") + } + nowMs := time.Now().UnixMilli() + appCode := appctx.FromContext(ctx) + // ON DUPLICATE KEY 用于后台重复添加同一经理时刷新联系方式并恢复启用状态, + // 不新增第二条身份,也不触碰该经理已经邀请出的 BD Leader/Admin 归属统计。 + if _, err := r.db.ExecContext(ctx, ` + INSERT INTO manager_profiles ( + app_code, user_id, status, contact_info, created_by_admin_id, created_at_ms, updated_at_ms + ) VALUES (?, ?, 'active', ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + status = 'active', + contact_info = VALUES(contact_info), + updated_at_ms = VALUES(updated_at_ms) + `, appCode, userID, contact, actorID, nowMs, nowMs); err != nil { + return nil, err + } + items, _, err := r.ListManagers(ctx, listQuery{Keyword: strconv.FormatInt(userID, 10), Page: 1, PageSize: 1}) + if err != nil { + return nil, err + } + if len(items) == 0 { + return nil, fmt.Errorf("manager not found") + } + return items[0], nil +} + +// ListManagers 按 manager_profiles 的独立身份事实读取经理列表。 +// 经理是 BD Leader/Admin 的上级,不属于 Host/Agency/BD 链路;下级 BD Leader 只作为统计信息左连汇总。 +func (r *Reader) ListManagers(ctx context.Context, query listQuery) ([]*ManagerListItem, int64, error) { + if r == nil || r.db == nil { + return nil, 0, errUserDBNotConfigured() + } + appCode := appctx.FromContext(ctx) + whereSQL := ` + FROM manager_profiles mp + JOIN users manager ON manager.app_code = mp.app_code AND manager.user_id = mp.user_id + LEFT JOIN bd_leader_profiles bl ON bl.app_code = mp.app_code AND bl.created_by_user_id = mp.user_id + ` + userCountryRegionJoin("manager", "manager_country_region", "manager_region") + ` + WHERE mp.app_code = ?` + args := []any{appCode} + if query.Status != "" { + whereSQL += " AND mp.status = ?" + args = append(args, query.Status) + } + if query.RegionID > 0 { + whereSQL += " AND manager_region.region_id = ?" + args = append(args, query.RegionID) + } + if keyword := strings.TrimSpace(query.Keyword); keyword != "" { + like := "%" + keyword + "%" + managerSQL, managerArgs := shared.UserIdentityKeywordSQL("manager", "mp.user_id", keyword, time.Now().UnixMilli()) + whereSQL += " AND (" + managerSQL + " OR manager_region.name LIKE ?)" + args = append(args, managerArgs...) + args = append(args, like) + } + + var total int64 + if err := r.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT mp.user_id) `+whereSQL, args...).Scan(&total); err != nil { + return nil, 0, err + } + rows, err := r.db.QueryContext(ctx, fmt.Sprintf(` + SELECT mp.user_id, COALESCE(manager.current_display_user_id, ''), COALESCE(manager.username, ''), + COALESCE(manager.avatar, ''), `+userCountryRegionIDSQL("manager_region")+`, `+userCountryRegionNameSQL("manager_region")+`, + mp.status, COALESCE(mp.contact_info, ''), COUNT(DISTINCT bl.user_id), COALESCE(MAX(bl.created_at_ms), 0), + mp.created_at_ms, mp.updated_at_ms + %s + GROUP BY mp.user_id, manager.current_display_user_id, manager.username, manager.avatar, + manager_region.region_id, manager_region.name, mp.status, mp.contact_info, mp.created_at_ms, mp.updated_at_ms + ORDER BY mp.updated_at_ms DESC, mp.user_id DESC + LIMIT ? OFFSET ? + `, whereSQL), append(args, query.PageSize, offset(query.Page, query.PageSize))...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + items := make([]*ManagerListItem, 0, query.PageSize) + for rows.Next() { + item := &ManagerListItem{} + if err := rows.Scan( + &item.UserID, + &item.DisplayUserID, + &item.Username, + &item.Avatar, + &item.RegionID, + &item.RegionName, + &item.Status, + &item.Contact, + &item.BDLeaderCount, + &item.LastInvitedAtMs, + &item.CreatedAtMs, + &item.UpdatedAtMs, + ); err != nil { + return nil, 0, err + } + items = append(items, item) + } + return items, total, rows.Err() +} + // ListAgencies 只读 user-service 的 Agency 事实表;后台关闭和入会开关仍走写命令。 func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclient.Agency, int64, error) { if r == nil || r.db == nil { diff --git a/server/admin/internal/modules/hostorg/request.go b/server/admin/internal/modules/hostorg/request.go index 93d59b26..af379ac9 100644 --- a/server/admin/internal/modules/hostorg/request.go +++ b/server/admin/internal/modules/hostorg/request.go @@ -60,6 +60,11 @@ type createBDLeaderRequest struct { Reason string `json:"reason"` } +type createManagerRequest struct { + Contact string `json:"contact"` + TargetUserID flexibleInt64 `json:"targetUserId" binding:"required"` +} + type createBDRequest struct { CommandID string `json:"commandId" binding:"required"` TargetUserID flexibleInt64 `json:"targetUserId" binding:"required"` diff --git a/server/admin/internal/modules/hostorg/routes.go b/server/admin/internal/modules/hostorg/routes.go index 31694367..54ef15b0 100644 --- a/server/admin/internal/modules/hostorg/routes.go +++ b/server/admin/internal/modules/hostorg/routes.go @@ -15,6 +15,8 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) { protected.GET("/admin/bds", middleware.RequirePermission("bd:view"), h.ListBDs) protected.POST("/admin/bds", middleware.RequirePermission("bd:create"), h.CreateBD) protected.PATCH("/admin/bds/:user_id/status", middleware.RequirePermission("bd:update"), h.SetBDStatus) + protected.GET("/admin/managers", middleware.RequirePermission("bd:view"), h.ListManagers) + protected.POST("/admin/managers", middleware.RequirePermission("bd:create"), h.CreateManager) protected.GET("/admin/agencies", middleware.RequirePermission("agency:view"), h.ListAgencies) protected.POST("/admin/agencies", middleware.RequirePermission("agency:create"), h.CreateAgency) protected.POST("/admin/agencies/:agency_id/close", middleware.RequirePermission("agency:status"), h.CloseAgency) diff --git a/server/admin/internal/modules/hostorg/service.go b/server/admin/internal/modules/hostorg/service.go index d33e3bec..0e7460cf 100644 --- a/server/admin/internal/modules/hostorg/service.go +++ b/server/admin/internal/modules/hostorg/service.go @@ -50,6 +50,20 @@ func (s *Service) ListAgencies(ctx context.Context, query listQuery) ([]*usercli return s.reader.ListAgencies(ctx, query) } +func (s *Service) ListManagers(ctx context.Context, query listQuery) ([]*ManagerListItem, int64, error) { + query = normalizeListQuery(query) + return s.reader.ListManagers(ctx, query) +} + +func (s *Service) CreateManager(ctx context.Context, actorID int64, requestID string, req createManagerRequest) (*ManagerListItem, error) { + targetUserID, err := s.resolveDisplayUserID(ctx, requestID, req.TargetUserID.Int64()) + if err != nil { + return nil, err + } + // 经理身份只落 manager_profiles 表;联系方式作为后台运营资料保存,不参与经理中心授权。 + return s.reader.CreateManagerProfile(ctx, targetUserID, actorID, req.Contact) +} + func (s *Service) ListHosts(ctx context.Context, query listQuery) ([]*userclient.HostProfile, int64, error) { query = normalizeListQuery(query) return s.reader.ListHostProfiles(ctx, query) diff --git a/server/admin/internal/modules/hostorg/service_test.go b/server/admin/internal/modules/hostorg/service_test.go index e864a367..e7dd9b09 100644 --- a/server/admin/internal/modules/hostorg/service_test.go +++ b/server/admin/internal/modules/hostorg/service_test.go @@ -1,6 +1,7 @@ package hostorg import ( + "encoding/json" "strings" "testing" @@ -17,6 +18,38 @@ func TestNormalizeListQueryAllowsComputedSorts(t *testing.T) { } } +// TestManagerListItemJSONHasNoAgencyOrParentBD 锁定后台经理列表的响应口径:经理是 BD Leader/Admin 的上级,不暴露 Agency 或上级 BD 字段。 +func TestManagerListItemJSONHasNoAgencyOrParentBD(t *testing.T) { + item := ManagerListItem{ + UserID: 316033326332776448, + DisplayUserID: "165549", + Username: "Kitty", + Status: "active", + Contact: "+63", + BDLeaderCount: 3, + LastInvitedAtMs: 1720000000000, + } + payload, err := json.Marshal(item) + if err != nil { + t.Fatalf("marshal manager list item: %v", err) + } + body := string(payload) + for _, want := range []string{ + `"userId":"316033326332776448"`, + `"displayUserId":"165549"`, + `"contact":"+63"`, + `"bdLeaderCount":3`, + `"lastInvitedAtMs":1720000000000`, + } { + if !strings.Contains(body, want) { + t.Fatalf("manager json should contain %s, got %s", want, body) + } + } + if strings.Contains(body, "parentBd") || strings.Contains(body, "ParentBD") || strings.Contains(body, "agency") || strings.Contains(body, "Agency") { + t.Fatalf("manager json must not expose agency or parent bd fields: %s", body) + } +} + // TestBDRoleFromStatusPathUsesLeaderTableWithAPIPrefix 锁定线上真实 FullPath 带 /api/v1 前缀时,BD Leader 状态仍然写独立负责人表。 func TestBDRoleFromStatusPathUsesLeaderTableWithAPIPrefix(t *testing.T) { role, target := bdRoleFromStatusPath("/api/v1/admin/bd-leaders/:user_id/status") diff --git a/server/admin/internal/modules/payment/exchange_rate.go b/server/admin/internal/modules/payment/exchange_rate.go new file mode 100644 index 00000000..886ef3c4 --- /dev/null +++ b/server/admin/internal/modules/payment/exchange_rate.go @@ -0,0 +1,274 @@ +package payment + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "sort" + "strconv" + "strings" + "time" + + walletv1 "hyapp.local/api/proto/wallet/v1" +) + +type exchangeRateClient interface { + LatestUSDRates(ctx context.Context, currencies []string) (exchangeRateResult, error) +} + +type exchangeRateResult struct { + Rates map[string]string `json:"rates"` + SourceNames []string `json:"sourceNames"` + Sources []exchangeRateSourceStatusDTO `json:"sources"` +} + +type exchangeRateSourceStatusDTO struct { + Name string `json:"name"` + URL string `json:"url"` + Success bool `json:"success"` + Currencies []string `json:"currencies,omitempty"` + Error string `json:"error,omitempty"` +} + +type thirdPartyPaymentRateSyncDTO struct { + UpdatedCount int `json:"updatedCount"` + SkippedCount int `json:"skippedCount"` + MissingCurrencies []string `json:"missingCurrencies"` + SourceNames []string `json:"sourceNames"` + Sources []exchangeRateSourceStatusDTO `json:"sources"` + Rates map[string]string `json:"rates"` +} + +type httpExchangeRateClient struct { + client *http.Client + sources []exchangeRateSource +} + +type exchangeRateSource struct { + name string + url string + parse func([]byte) (map[string]float64, error) +} + +func newDefaultExchangeRateClient() exchangeRateClient { + return &httpExchangeRateClient{ + client: &http.Client{Timeout: 8 * time.Second}, + sources: []exchangeRateSource{ + {name: "open.er-api.com", url: "https://open.er-api.com/v6/latest/USD", parse: parseOpenExchangeRateAPI}, + {name: "fawazahmed0-currency-api", url: "https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/usd.json", parse: parseFawazCurrencyAPI}, + {name: "FloatRates", url: "https://www.floatrates.com/daily/usd.json", parse: parseFloatRates}, + {name: "Frankfurter", url: "https://api.frankfurter.dev/v1/latest?base=USD", parse: parseFrankfurterRates}, + }, + } +} + +func (c *httpExchangeRateClient) LatestUSDRates(ctx context.Context, currencies []string) (exchangeRateResult, error) { + required, rates := splitRequiredCurrencies(currencies) + if len(required) == 0 { + return exchangeRateResult{Rates: rates}, nil + } + sourceNames := make([]string, 0, len(c.sources)) + sourceStatuses := make([]exchangeRateSourceStatusDTO, 0, len(c.sources)) + for _, source := range c.sources { + // 每个源独立失败,不阻断后续源;同一个币种只取最先成功源,避免不同源的更新时间混在同一个币种上来回抖动。 + sourceRates, err := c.fetchSource(ctx, source) + if err != nil { + sourceStatuses = append(sourceStatuses, exchangeRateSourceStatusDTO{Name: source.name, URL: source.url, Success: false, Error: err.Error()}) + continue + } + matched := make([]string, 0, len(required)) + for currency := range required { + if _, exists := rates[currency]; exists { + continue + } + value := sourceRates[currency] + if value <= 0 { + continue + } + rates[currency] = formatExchangeRate(value) + matched = append(matched, currency) + } + sort.Strings(matched) + if len(matched) > 0 { + sourceNames = append(sourceNames, source.name) + } + sourceStatuses = append(sourceStatuses, exchangeRateSourceStatusDTO{Name: source.name, URL: source.url, Success: true, Currencies: matched}) + if len(rates)-usdRateCount(rates) == len(required) { + break + } + } + if len(rates)-usdRateCount(rates) == 0 { + return exchangeRateResult{}, errors.New("no exchange rate source returned requested currencies") + } + return exchangeRateResult{Rates: rates, SourceNames: sourceNames, Sources: sourceStatuses}, nil +} + +func (c *httpExchangeRateClient) fetchSource(ctx context.Context, source exchangeRateSource) (map[string]float64, error) { + client := c.client + if client == nil { + client = &http.Client{Timeout: 8 * time.Second} + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, source.url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "HYAppAdmin/1.0 exchange-rate-sync") + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("http %d", resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err != nil { + return nil, err + } + return source.parse(body) +} + +func parseOpenExchangeRateAPI(body []byte) (map[string]float64, error) { + var payload struct { + Result string `json:"result"` + Rates map[string]float64 `json:"rates"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return nil, err + } + if payload.Result != "" && payload.Result != "success" { + return nil, fmt.Errorf("result %s", payload.Result) + } + return normalizeFloatRateMap(payload.Rates), nil +} + +func parseFrankfurterRates(body []byte) (map[string]float64, error) { + var payload struct { + Rates map[string]float64 `json:"rates"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return nil, err + } + return normalizeFloatRateMap(payload.Rates), nil +} + +func parseFawazCurrencyAPI(body []byte) (map[string]float64, error) { + var payload struct { + Rates map[string]float64 `json:"usd"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return nil, err + } + return normalizeFloatRateMap(payload.Rates), nil +} + +func parseFloatRates(body []byte) (map[string]float64, error) { + var payload map[string]struct { + Code string `json:"code"` + AlphaCode string `json:"alphaCode"` + Rate string `json:"rate"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return nil, err + } + rates := make(map[string]float64, len(payload)) + for key, item := range payload { + currency := strings.ToUpper(firstNonEmptyString(item.AlphaCode, item.Code, key)) + value, err := strconv.ParseFloat(strings.TrimSpace(item.Rate), 64) + if currency == "" || err != nil || value <= 0 { + continue + } + rates[currency] = value + } + return rates, nil +} + +func normalizeFloatRateMap(input map[string]float64) map[string]float64 { + rates := make(map[string]float64, len(input)) + for currency, value := range input { + currency = strings.ToUpper(strings.TrimSpace(currency)) + if currency == "" || value <= 0 { + continue + } + rates[currency] = value + } + return rates +} + +func firstNonEmptyString(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + +func collectThirdPartyPaymentMethods(channels []*walletv1.ThirdPartyPaymentChannel) []*walletv1.ThirdPartyPaymentMethod { + seen := make(map[int64]struct{}) + methods := make([]*walletv1.ThirdPartyPaymentMethod, 0) + for _, channel := range channels { + for _, method := range channel.GetMethods() { + if method.GetMethodId() <= 0 { + continue + } + if _, exists := seen[method.GetMethodId()]; exists { + continue + } + seen[method.GetMethodId()] = struct{}{} + methods = append(methods, method) + } + } + return methods +} + +func collectThirdPartyPaymentCurrencies(methods []*walletv1.ThirdPartyPaymentMethod) []string { + currencies := make(map[string]struct{}) + for _, method := range methods { + if currency := strings.ToUpper(strings.TrimSpace(method.GetCurrencyCode())); currency != "" { + currencies[currency] = struct{}{} + } + } + return sortedMapKeys(currencies) +} + +func splitRequiredCurrencies(currencies []string) (map[string]struct{}, map[string]string) { + required := make(map[string]struct{}) + rates := make(map[string]string) + for _, currency := range currencies { + currency = strings.ToUpper(strings.TrimSpace(currency)) + if currency == "" { + continue + } + if currency == "USD" { + rates[currency] = "1.00000000" + continue + } + required[currency] = struct{}{} + } + return required, rates +} + +func usdRateCount(rates map[string]string) int { + if rates["USD"] == "" { + return 0 + } + return 1 +} + +func sortedMapKeys(values map[string]struct{}) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func formatExchangeRate(value float64) string { + return strconv.FormatFloat(value, 'f', 8, 64) +} diff --git a/server/admin/internal/modules/payment/handler.go b/server/admin/internal/modules/payment/handler.go index a138e506..08b8f27f 100644 --- a/server/admin/internal/modules/payment/handler.go +++ b/server/admin/internal/modules/payment/handler.go @@ -23,14 +23,15 @@ import ( const usdtMicroUnit int64 = 1_000_000 type Handler struct { - wallet walletclient.Client - userDB *sql.DB - audit shared.OperationLogger + wallet walletclient.Client + userDB *sql.DB + audit shared.OperationLogger + exchangeRates exchangeRateClient } // New 组装支付后台 handler;wallet 负责账单和商品事实,userDB 只补后台列表需要的用户展示资料。 func New(wallet walletclient.Client, userDB *sql.DB, audit shared.OperationLogger) *Handler { - return &Handler{wallet: wallet, userDB: userDB, audit: audit} + return &Handler{wallet: wallet, userDB: userDB, audit: audit, exchangeRates: newDefaultExchangeRateClient()} } func (h *Handler) ListRechargeBills(c *gin.Context) { @@ -314,6 +315,66 @@ func (h *Handler) UpdateThirdPartyPaymentRate(c *gin.Context) { response.OK(c, item) } +func (h *Handler) SyncThirdPartyPaymentRates(c *gin.Context) { + resp, err := h.wallet.ListThirdPartyPaymentChannels(c.Request.Context(), &walletv1.ListThirdPartyPaymentChannelsRequest{ + RequestId: middleware.CurrentRequestID(c), + AppCode: appctx.FromContext(c.Request.Context()), + ProviderCode: strings.TrimSpace(firstQuery(c, "provider_code", "providerCode")), + IncludeDisabledMethods: true, + }) + if err != nil { + writeWalletError(c, err, "获取三方支付方式失败") + return + } + methods := collectThirdPartyPaymentMethods(resp.GetChannels()) + currencies := collectThirdPartyPaymentCurrencies(methods) + if len(currencies) == 0 { + response.OK(c, thirdPartyPaymentRateSyncDTO{UpdatedCount: 0}) + return + } + // 汇率同步必须以服务端实时拉取结果为准:前端按钮只触发动作,不传任何汇率,避免浏览器篡改本币金额。 + rateClient := h.exchangeRates + if rateClient == nil { + rateClient = newDefaultExchangeRateClient() + } + rateResult, err := rateClient.LatestUSDRates(c.Request.Context(), currencies) + if err != nil { + response.ServerError(c, "同步支付汇率失败") + return + } + updated := 0 + skipped := make(map[string]struct{}) + for _, method := range methods { + currencyCode := strings.ToUpper(strings.TrimSpace(method.GetCurrencyCode())) + rate := rateResult.Rates[currencyCode] + if rate == "" { + skipped[currencyCode] = struct{}{} + continue + } + if _, err := h.wallet.UpdateThirdPartyPaymentRate(c.Request.Context(), &walletv1.UpdateThirdPartyPaymentRateRequest{ + RequestId: middleware.CurrentRequestID(c), + AppCode: appctx.FromContext(c.Request.Context()), + MethodId: method.GetMethodId(), + UsdToCurrencyRate: rate, + OperatorUserId: actorID(c), + }); err != nil { + writeWalletError(c, err, "写入支付汇率失败") + return + } + updated++ + } + skippedCurrencies := sortedMapKeys(skipped) + shared.OperationLog(c, h.audit, "sync-third-party-payment-rates", "third_party_payment_methods", "success", strings.Join(rateResult.SourceNames, ",")) + response.OK(c, thirdPartyPaymentRateSyncDTO{ + UpdatedCount: updated, + SkippedCount: len(skippedCurrencies), + MissingCurrencies: skippedCurrencies, + SourceNames: rateResult.SourceNames, + Sources: rateResult.Sources, + Rates: rateResult.Rates, + }) +} + type rechargeProductRequest struct { AmountUSDT string `json:"amountUsdt"` AmountUSDTMicro int64 `json:"amountUsdtMicro"` diff --git a/server/admin/internal/modules/payment/handler_test.go b/server/admin/internal/modules/payment/handler_test.go index 8b438926..964bad97 100644 --- a/server/admin/internal/modules/payment/handler_test.go +++ b/server/admin/internal/modules/payment/handler_test.go @@ -108,6 +108,58 @@ func TestUpdateThirdPartyPaymentRateTrimsRateAndForwardsOperator(t *testing.T) { } } +func TestSyncThirdPartyPaymentRatesUpdatesAllMethodsFromFetchedCurrencyRates(t *testing.T) { + wallet := &mockPaymentWallet{thirdPartyChannelsResp: &walletv1.ListThirdPartyPaymentChannelsResponse{ + Channels: []*walletv1.ThirdPartyPaymentChannel{{ + AppCode: "lalu", + ProviderCode: "mifapay", + ProviderName: "MiFaPay", + Status: "active", + Methods: []*walletv1.ThirdPartyPaymentMethod{ + {MethodId: 810, CurrencyCode: "SAR", Status: "active"}, + {MethodId: 811, CurrencyCode: "BHD", Status: "disabled"}, + }, + }}, + }} + handler := New(wallet, nil, nil) + handler.exchangeRates = fakeExchangeRateClient{result: exchangeRateResult{ + Rates: map[string]string{"SAR": "3.75000000", "BHD": "0.37600000"}, + SourceNames: []string{"open.er-api.com"}, + }} + router := newPaymentHandlerTestRouter(handler) + request := httptest.NewRequest(http.MethodPost, "/admin/payment/third-party-rates/sync", nil) + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) + } + if wallet.lastThirdPartyChannels == nil || !wallet.lastThirdPartyChannels.GetIncludeDisabledMethods() { + t.Fatalf("sync must read active and disabled methods: %+v", wallet.lastThirdPartyChannels) + } + if len(wallet.updateRateRequests) != 2 { + t.Fatalf("sync should update every method with a fetched currency rate: %+v", wallet.updateRateRequests) + } + got := map[int64]string{} + for _, req := range wallet.updateRateRequests { + if req.GetOperatorUserId() != 7 { + t.Fatalf("sync operator mismatch: %+v", req) + } + got[req.GetMethodId()] = req.GetUsdToCurrencyRate() + } + if got[810] != "3.75000000" || got[811] != "0.37600000" { + t.Fatalf("synced rates mismatch: %+v", got) + } + var response adminPaymentTestResponse + if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { + t.Fatalf("decode response failed: %v", err) + } + if response.Code != 0 || response.Data["updatedCount"].(float64) != 2 { + t.Fatalf("sync response mismatch: %+v", response) + } +} + func TestCreateRechargeProductForwardsWebCoinSellerAudience(t *testing.T) { wallet := &mockPaymentWallet{} router := newPaymentHandlerTestRouter(New(wallet, nil, nil)) @@ -148,6 +200,7 @@ func newPaymentHandlerTestRouter(handler *Handler) *gin.Engine { router.GET("/admin/payment/third-party-channels", handler.ListThirdPartyPaymentChannels) router.PATCH("/admin/payment/third-party-methods/:method_id/status", handler.SetThirdPartyPaymentMethodStatus) router.PATCH("/admin/payment/third-party-rates/:method_id", handler.UpdateThirdPartyPaymentRate) + router.POST("/admin/payment/third-party-rates/sync", handler.SyncThirdPartyPaymentRates) router.POST("/admin/payment/recharge-products", handler.CreateRechargeProduct) return router } @@ -164,6 +217,7 @@ type mockPaymentWallet struct { thirdPartyChannelsResp *walletv1.ListThirdPartyPaymentChannelsResponse lastSetMethodStatus *walletv1.SetThirdPartyPaymentMethodStatusRequest lastUpdateRate *walletv1.UpdateThirdPartyPaymentRateRequest + updateRateRequests []*walletv1.UpdateThirdPartyPaymentRateRequest lastCreateProduct *walletv1.CreateRechargeProductRequest } @@ -195,6 +249,7 @@ func (m *mockPaymentWallet) SetThirdPartyPaymentMethodStatus(_ context.Context, func (m *mockPaymentWallet) UpdateThirdPartyPaymentRate(_ context.Context, req *walletv1.UpdateThirdPartyPaymentRateRequest) (*walletv1.ThirdPartyPaymentMethodResponse, error) { m.lastUpdateRate = req + m.updateRateRequests = append(m.updateRateRequests, req) return &walletv1.ThirdPartyPaymentMethodResponse{Method: &walletv1.ThirdPartyPaymentMethod{ MethodId: req.GetMethodId(), AppCode: req.GetAppCode(), @@ -208,6 +263,15 @@ func (m *mockPaymentWallet) UpdateThirdPartyPaymentRate(_ context.Context, req * }}, nil } +type fakeExchangeRateClient struct { + result exchangeRateResult + err error +} + +func (f fakeExchangeRateClient) LatestUSDRates(context.Context, []string) (exchangeRateResult, error) { + return f.result, f.err +} + func (m *mockPaymentWallet) CreateRechargeProduct(_ context.Context, req *walletv1.CreateRechargeProductRequest) (*walletv1.RechargeProductResponse, error) { m.lastCreateProduct = req return &walletv1.RechargeProductResponse{Product: &walletv1.RechargeProduct{ diff --git a/server/admin/internal/modules/payment/routes.go b/server/admin/internal/modules/payment/routes.go index 8392b04f..cfece406 100644 --- a/server/admin/internal/modules/payment/routes.go +++ b/server/admin/internal/modules/payment/routes.go @@ -15,6 +15,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) { protected.GET("/admin/payment/third-party-channels", middleware.RequirePermission("payment-third-party:view"), h.ListThirdPartyPaymentChannels) protected.PATCH("/admin/payment/third-party-methods/:method_id/status", middleware.RequirePermission("payment-third-party:update"), h.SetThirdPartyPaymentMethodStatus) protected.PATCH("/admin/payment/third-party-rates/:method_id", middleware.RequirePermission("payment-third-party:update"), h.UpdateThirdPartyPaymentRate) + protected.POST("/admin/payment/third-party-rates/sync", middleware.RequirePermission("payment-third-party:update"), h.SyncThirdPartyPaymentRates) protected.GET("/admin/payment/recharge-products", middleware.RequirePermission("payment-product:view"), h.ListRechargeProducts) protected.POST("/admin/payment/recharge-products", middleware.RequirePermission("payment-product:create"), h.CreateRechargeProduct) protected.PUT("/admin/payment/recharge-products/:product_id", middleware.RequirePermission("payment-product:update"), h.UpdateRechargeProduct) diff --git a/server/admin/internal/repository/seed.go b/server/admin/internal/repository/seed.go index d65c209c..94014302 100644 --- a/server/admin/internal/repository/seed.go +++ b/server/admin/internal/repository/seed.go @@ -275,7 +275,7 @@ func (s *Store) seedMenus() error { {ParentID: &operationsID, Title: "礼物钻石", Code: "operation-gift-diamond", Path: "/operations/gift-diamonds", Icon: "diamond", PermissionCode: "gift-diamond:view", Sort: 73, Visible: true}, {ParentID: &paymentID, Title: "账单列表", Code: "payment-bill-list", Path: "/payment/bills", Icon: "receipt", PermissionCode: "payment-bill:view", Sort: 68, Visible: true}, {ParentID: &paymentID, Title: "三方支付", Code: "payment-third-party", Path: "/payment/third-party", Icon: "wallet", PermissionCode: "payment-third-party:view", Sort: 69, Visible: true}, - {ParentID: &paymentID, Title: "内购配置", Code: "payment-recharge-products", Path: "/payment/recharge-products", Icon: "wallet", PermissionCode: "payment-product:view", Sort: 70, Visible: true}, + {ParentID: &paymentID, Title: "支付内购商品", Code: "payment-recharge-products", Path: "/payment/recharge-products", Icon: "wallet", PermissionCode: "payment-product:view", Sort: 70, Visible: true}, {ParentID: &activityID, Title: "每日任务", Code: "daily-task-list", Path: "/activities/daily-tasks", Icon: "task", PermissionCode: "daily-task:view", Sort: 69, Visible: true}, {ParentID: &activityID, Title: "注册奖励", Code: "registration-reward", Path: "/activities/registration-reward", Icon: "gift", PermissionCode: "registration-reward:view", Sort: 70, Visible: true}, {ParentID: &activityID, Title: "成就配置", Code: "achievement-config", Path: "/activities/achievements", Icon: "military_tech", PermissionCode: "achievement:view", Sort: 71, Visible: true}, @@ -291,7 +291,7 @@ func (s *Store) seedMenus() error { {ParentID: &gameID, Title: "全站机器人", Code: "game-robots", Path: "/games/robots", Icon: "team", PermissionCode: "game:view", Sort: 90, Visible: true}, {ParentID: &geoID, Title: "国家管理", Code: "host-org-countries", Path: "/host/countries", Icon: "public", PermissionCode: "country:view", Sort: 70, Visible: true}, {ParentID: &geoID, Title: "区域管理", Code: "host-org-regions", Path: "/host/regions", Icon: "map", PermissionCode: "region:view", Sort: 71, Visible: true}, - {ParentID: &hostOrgID, Title: "Manager列表", Code: "host-org-managers", Path: "/host/managers", Icon: "users", PermissionCode: "agency:view", Sort: 80, Visible: true}, + {ParentID: &hostOrgID, Title: "经理列表", Code: "host-org-managers", Path: "/host/managers", Icon: "users", PermissionCode: "bd:view", Sort: 80, Visible: true}, {ParentID: &hostOrgID, Title: "Agency 列表", Code: "host-org-agencies", Path: "/host/agencies", Icon: "apartment", PermissionCode: "agency:view", Sort: 81, Visible: true}, {ParentID: &hostOrgID, Title: "BD Leader 列表", Code: "host-org-bd-leaders", Path: "/host/bd-leaders", Icon: "shield", PermissionCode: "bd:view", Sort: 82, Visible: true}, {ParentID: &hostOrgID, Title: "BD 列表", Code: "host-org-bds", Path: "/host/bds", Icon: "team", PermissionCode: "bd:view", Sort: 83, Visible: true}, diff --git a/server/admin/migrations/053_payment_product_menu_rename.sql b/server/admin/migrations/053_payment_product_menu_rename.sql new file mode 100644 index 00000000..c7f44f01 --- /dev/null +++ b/server/admin/migrations/053_payment_product_menu_rename.sql @@ -0,0 +1,5 @@ +SET @now_ms = UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000; + +UPDATE admin_menus +SET title = '支付内购商品', updated_at_ms = @now_ms +WHERE code = 'payment-recharge-products'; diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index 80c542b9..0f8f568f 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -7100,6 +7100,64 @@ func TestH5RechargeOptionsUsesTokenCountryAndCoinSellerAudience(t *testing.T) { } } +func TestH5RechargeOptionsNormalizesSaudiCountryNameToPaymentCountryCode(t *testing.T) { + walletClient := &fakeWalletClient{h5OptionsResp: &walletv1.H5RechargeOptionsResponse{ + PaymentMethods: []*walletv1.ThirdPartyPaymentMethod{{ + MethodId: 810, + ProviderCode: "mifapay", + ProviderName: "MiFaPay", + CountryCode: "SA", + CountryName: "Saudi Arabia", + CurrencyCode: "SAR", + PayWay: "Card", + PayType: "MADA", + MethodName: "MADA", + Status: "active", + UsdToCurrencyRate: "3.75000000", + }}, + }} + identityClient := &fakeUserIdentityClient{resolveByDisplay: map[string]int64{"sa-name": 77}} + profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{77: { + UserId: 77, + DisplayUserId: "sa-name", + Username: "sa_name_user", + Status: userv1.UserStatus_USER_STATUS_ACTIVE, + Country: "Saudi Arabia", + CountryDisplayName: "沙特阿拉伯", + RegionId: 7100, + }}} + hostClient := &fakeUserHostClient{roleSummary: &userv1.UserRoleSummary{UserId: 77}} + handler := NewHandlerWithClients(&fakeRoomClient{}, nil, identityClient, profileClient) + handler.SetWalletClient(walletClient) + handler.SetUserHostClient(hostClient) + router := handler.Routes(auth.NewVerifier("secret")) + // 历史用户资料可能把国家写成英文名或中文展示名;H5 支付链路必须先归一为 SA,再让 wallet-service 按国家返回 MiFaPay 方式。 + request := httptest.NewRequest(http.MethodGet, "/api/v1/recharge/h5/options?display_user_id=sa-name", nil) + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) + } + if walletClient.lastH5Options == nil || walletClient.lastH5Options.GetTargetCountryCode() != "SA" { + t.Fatalf("h5 options should normalize country name to SA: %+v", walletClient.lastH5Options) + } + var response httpkit.ResponseEnvelope + if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { + t.Fatalf("decode response failed: %v", err) + } + data, ok := response.Data.(map[string]any) + if response.Code != httpkit.CodeOK || !ok { + t.Fatalf("h5 options response mismatch: %+v", response) + } + account := data["account"].(map[string]any) + methods := data["payment_methods"].([]any) + if account["country_code"] != "SA" || len(methods) != 1 || methods[0].(map[string]any)["country_code"] != "SA" { + t.Fatalf("normalized h5 options response data mismatch: %+v", data) + } +} + func TestH5RechargeOptionsResolvesDisplayUserCountryWithoutToken(t *testing.T) { walletClient := &fakeWalletClient{h5OptionsResp: &walletv1.H5RechargeOptionsResponse{ PaymentMethods: []*walletv1.ThirdPartyPaymentMethod{{ @@ -7258,7 +7316,7 @@ func TestH5RechargeCreateOrderUsesProfileCountryForNormalUser(t *testing.T) { request := httptest.NewRequest(http.MethodPost, "/api/v1/recharge/h5/orders", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 55)) request.Header.Set("X-Forwarded-For", "203.0.113.9") - request.Header.Set("Accept-Language", "ar-EG") + request.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6") recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) @@ -7276,7 +7334,10 @@ func TestH5RechargeCreateOrderUsesProfileCountryForNormalUser(t *testing.T) { walletClient.lastH5CreateOrder.GetProviderCode() != "mifapay" || walletClient.lastH5CreateOrder.GetPaymentMethodId() != 1210 || walletClient.lastH5CreateOrder.GetReturnUrl() != "https://h5.example/return" || - walletClient.lastH5CreateOrder.GetLanguage() != "ar-EG" { + walletClient.lastH5CreateOrder.GetLanguage() != "en" || + walletClient.lastH5CreateOrder.GetPayerName() != "normal55" || + walletClient.lastH5CreateOrder.GetPayerAccount() != "163055" || + walletClient.lastH5CreateOrder.GetPayerEmail() != "163055@lalu.com" { t.Fatalf("h5 create order wallet request mismatch: %+v", walletClient.lastH5CreateOrder) } var response httpkit.ResponseEnvelope diff --git a/services/gateway-service/internal/transport/http/walletapi/h5_recharge_handler.go b/services/gateway-service/internal/transport/http/walletapi/h5_recharge_handler.go index de7eac44..e4ce7146 100644 --- a/services/gateway-service/internal/transport/http/walletapi/h5_recharge_handler.go +++ b/services/gateway-service/internal/transport/http/walletapi/h5_recharge_handler.go @@ -203,7 +203,10 @@ func (h *Handler) createH5RechargeOrder(writer http.ResponseWriter, request *htt PaymentMethodId: paymentMethodID, ReturnUrl: firstNonEmptyString(body.ReturnURL, body.ReturnURLAlt), ClientIp: httpkit.ClientIP(request), - Language: httpkit.FirstQueryOrHeader(request, "language", "X-Language", "Accept-Language"), + Language: h5RechargePaymentLanguage(h5RechargePaymentLanguageQuery(request)), + PayerName: target.Username, + PayerAccount: target.DisplayUserID, + PayerEmail: h5RechargePayerEmail(target.DisplayUserID, appcode.FromContext(request.Context())), }) if err != nil { httpkit.WriteRPCError(writer, request, err) @@ -340,7 +343,7 @@ func (h *Handler) resolveH5RechargeTarget(writer http.ResponseWriter, request *h DisplayUserID: user.GetDisplayUserId(), Username: user.GetUsername(), Avatar: user.GetAvatar(), - CountryCode: strings.ToUpper(strings.TrimSpace(user.GetCountry())), + CountryCode: h5RechargeCountryCodeFromUser(user), CountryName: firstNonEmptyString(user.GetCountryDisplayName(), user.GetCountryName()), RegionID: user.GetRegionId(), RegionCode: user.GetRegionCode(), @@ -352,6 +355,216 @@ func (h *Handler) resolveH5RechargeTarget(writer http.ResponseWriter, request *h }, true } +func h5RechargeCountryCodeFromUser(user *userv1.User) string { + if user == nil { + return "" + } + for _, candidate := range []string{user.GetCountry(), user.GetCountryName(), user.GetCountryDisplayName()} { + // H5 支付方式和下单都按 wallet-service 的国家码过滤;这里把历史资料里的英文名、中文名或 alpha-3 统一成 ISO2。 + // 如果用户资料本身已经是 SA 这类规范国家码,函数会直接返回,避免改变正常注册链路产生的 country 快照。 + if countryCode := normalizeH5RechargeCountryCode(candidate); countryCode != "" { + return countryCode + } + } + return "" +} + +func h5RechargePayerEmail(account string, appCode string) string { + localPart := h5RechargeEmailLocalPart(account) + domainLabel := h5RechargeEmailDomainLabel(appcode.Normalize(appCode)) + // MiFaPay 要求 H5 下单传 email;产品口径是“用户账号 + @ + app_code + .com”。 + // 这里不接受前端自报邮箱,只用 gateway 已解析出的目标账号和当前租户,避免用户能篡改付款人身份快照。 + return localPart + "@" + domainLabel + ".com" +} + +func h5RechargePaymentLanguage(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "en" + } + if comma := strings.IndexByte(value, ','); comma >= 0 { + value = value[:comma] + } + if semicolon := strings.IndexByte(value, ';'); semicolon >= 0 { + value = value[:semicolon] + } + value = strings.TrimSpace(value) + if !validH5RechargeLanguageTag(value) { + return "en" + } + // MiFaPay language 只接受单个语言标签;这里保留显式 query 的首选标签,例如 zh-CN。 + // gateway 不再从 Header 兜底,避免浏览器 Accept-Language 在无 H5 query 时影响支付页语言或触发三方格式校验。 + return value +} + +func h5RechargePaymentLanguageQuery(request *http.Request) string { + // 支付语言属于 H5 页面入口参数,而不是浏览器能力探测结果;只认页面 query,避免没有 App 语言参数时又把 + // Accept-Language 的 q 权重列表带进 MiFaPay,造成同一用户在不同系统浏览器里出现不可预期支付页语言。 + for _, key := range []string{"language", "lang", "locale", "app_lang", "appLanguage"} { + if value := strings.TrimSpace(request.URL.Query().Get(key)); value != "" { + return value + } + } + return "" +} + +func validH5RechargeLanguageTag(value string) bool { + if value == "" { + return false + } + parts := strings.Split(value, "-") + if len(parts) == 0 || len(parts) > 4 || len(parts[0]) < 2 || len(parts[0]) > 3 || !allASCIILetters(parts[0]) { + return false + } + for _, part := range parts[1:] { + if len(part) < 2 || len(part) > 8 || !allASCIILettersOrDigits(part) { + return false + } + } + return true +} + +func h5RechargeEmailLocalPart(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "user" + } + var builder strings.Builder + for _, char := range value { + switch { + case char >= 'a' && char <= 'z': + builder.WriteRune(char) + case char >= 'A' && char <= 'Z': + builder.WriteRune(char) + case char >= '0' && char <= '9': + builder.WriteRune(char) + case char == '.', char == '_', char == '-', char == '+': + builder.WriteRune(char) + default: + builder.WriteByte('-') + } + } + normalized := strings.Trim(builder.String(), ".-") + if normalized == "" { + return "user" + } + return normalized +} + +func h5RechargeEmailDomainLabel(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return appcode.Default + } + var builder strings.Builder + for _, char := range value { + switch { + case char >= 'a' && char <= 'z': + builder.WriteRune(char) + case char >= '0' && char <= '9': + builder.WriteRune(char) + case char == '-': + builder.WriteRune(char) + default: + builder.WriteByte('-') + } + } + normalized := strings.Trim(builder.String(), "-") + if normalized == "" { + return appcode.Default + } + return normalized +} + +func normalizeH5RechargeCountryCode(value string) string { + normalized := strings.ToUpper(strings.TrimSpace(value)) + if normalized == "" { + return "" + } + normalized = strings.NewReplacer("_", " ", "-", " ").Replace(normalized) + normalized = strings.Join(strings.Fields(normalized), " ") + if countryCode, ok := h5RechargeCountryAliases[normalized]; ok { + return countryCode + } + if len(normalized) >= 2 && len(normalized) <= 3 && allASCIIUpperLetters(normalized) { + return normalized + } + return "" +} + +func allASCIIUpperLetters(value string) bool { + for _, char := range value { + if char < 'A' || char > 'Z' { + return false + } + } + return value != "" +} + +func allASCIILetters(value string) bool { + for _, char := range value { + if (char < 'A' || char > 'Z') && (char < 'a' || char > 'z') { + return false + } + } + return value != "" +} + +func allASCIILettersOrDigits(value string) bool { + for _, char := range value { + if (char < 'A' || char > 'Z') && (char < 'a' || char > 'z') && (char < '0' || char > '9') { + return false + } + } + return value != "" +} + +var h5RechargeCountryAliases = map[string]string{ + "ARE": "AE", + "UNITED ARAB EMIRATES": "AE", + "UAE": "AE", + "阿拉伯联合酋长国": "AE", + "BANGLADESH": "BD", + "BGD": "BD", + "孟加拉国": "BD", + "BAHRAIN": "BH", + "BHR": "BH", + "巴林": "BH", + "EGY": "EG", + "EGYPT": "EG", + "埃及": "EG", + "IDN": "ID", + "INDONESIA": "ID", + "印度尼西亚": "ID", + "IND": "IN", + "INDIA": "IN", + "印度": "IN", + "KWT": "KW", + "KUWAIT": "KW", + "科威特": "KW", + "OMAN": "OM", + "OMN": "OM", + "阿曼": "OM", + "PAKISTAN": "PK", + "PAK": "PK", + "巴基斯坦": "PK", + "PHILIPPINES": "PH", + "PHL": "PH", + "菲律宾": "PH", + "QAT": "QA", + "QATAR": "QA", + "卡塔尔": "QA", + "SAU": "SA", + "SAUDI ARABIA": "SA", + "沙特阿拉伯": "SA", + "THA": "TH", + "THAILAND": "TH", + "泰国": "TH", + "VNM": "VN", + "VIETNAM": "VN", + "越南": "VN", +} + func (h *Handler) resolveH5RechargeAudience(writer http.ResponseWriter, request *http.Request, userID int64) (string, bool, string, bool) { resp, err := h.userHostClient.GetUserRoleSummary(request.Context(), &userv1.GetUserRoleSummaryRequest{ Meta: httpkit.UserMeta(request, ""), diff --git a/services/gateway-service/internal/transport/http/walletapi/h5_recharge_handler_test.go b/services/gateway-service/internal/transport/http/walletapi/h5_recharge_handler_test.go new file mode 100644 index 00000000..1b0b727e --- /dev/null +++ b/services/gateway-service/internal/transport/http/walletapi/h5_recharge_handler_test.go @@ -0,0 +1,25 @@ +package walletapi + +import ( + "net/http/httptest" + "testing" +) + +func TestH5RechargePaymentLanguageReadsOnlyPageQuery(t *testing.T) { + queryRequest := httptest.NewRequest("POST", "/api/v1/recharge/h5/orders?language=zh-CN", nil) + queryRequest.Header.Set("Accept-Language", "ar,en;q=0.9") + if got := h5RechargePaymentLanguage(h5RechargePaymentLanguageQuery(queryRequest)); got != "zh-CN" { + t.Fatalf("language query must win over headers, got %q", got) + } + + legacyQueryRequest := httptest.NewRequest("POST", "/api/v1/recharge/h5/orders?lang=ar", nil) + if got := h5RechargePaymentLanguage(h5RechargePaymentLanguageQuery(legacyQueryRequest)); got != "ar" { + t.Fatalf("legacy lang query must still be accepted, got %q", got) + } + + headerOnlyRequest := httptest.NewRequest("POST", "/api/v1/recharge/h5/orders", nil) + headerOnlyRequest.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") + if got := h5RechargePaymentLanguage(h5RechargePaymentLanguageQuery(headerOnlyRequest)); got != "en" { + t.Fatalf("missing h5 language query must default to en, got %q", got) + } +} diff --git a/services/user-service/deploy/mysql/initdb/001_user_service.sql b/services/user-service/deploy/mysql/initdb/001_user_service.sql index b50d536f..8b181545 100644 --- a/services/user-service/deploy/mysql/initdb/001_user_service.sql +++ b/services/user-service/deploy/mysql/initdb/001_user_service.sql @@ -21,7 +21,9 @@ CREATE TABLE IF NOT EXISTS apps ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应用注册表'; INSERT INTO apps (app_code, app_name, package_name, platform, status, created_at_ms, updated_at_ms) -VALUES ('lalu', 'Lalu', 'com.org.laluparty', '', 'active', 0, 0) +VALUES + ('lalu', 'Lalu', 'com.org.laluparty', '', 'active', 0, 0), + ('huwaa', 'Huwaa', 'com.app.huwaa', '', 'active', 0, 0) ON DUPLICATE KEY UPDATE app_name = VALUES(app_name), package_name = VALUES(package_name), @@ -755,6 +757,18 @@ CREATE TABLE IF NOT EXISTS bd_leader_profiles ( updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='BD Leader资料表'; +CREATE TABLE IF NOT EXISTS manager_profiles ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', + user_id BIGINT NOT NULL PRIMARY KEY COMMENT '经理用户 ID', + status VARCHAR(32) NOT NULL COMMENT '业务状态', + contact_info VARCHAR(128) NOT NULL DEFAULT '' COMMENT '联系信息', + created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建后台管理员 ID', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + KEY idx_manager_profiles_status (app_code, status, updated_at_ms), + KEY idx_manager_profiles_created_by (app_code, created_by_admin_id, created_at_ms) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='经理资料表'; + CREATE TABLE IF NOT EXISTS coin_seller_profiles ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', user_id BIGINT NOT NULL PRIMARY KEY COMMENT '用户 ID', diff --git a/services/user-service/internal/domain/host/host.go b/services/user-service/internal/domain/host/host.go index 239df2bd..bd4967ef 100644 --- a/services/user-service/internal/domain/host/host.go +++ b/services/user-service/internal/domain/host/host.go @@ -26,6 +26,11 @@ const ( // BDStatusDisabled 表示 BD 或 BD Leader 已停用。 BDStatusDisabled = "disabled" + // ManagerStatusActive 表示经理身份有效,可以进入经理中心并邀请 BD Leader/Admin。 + ManagerStatusActive = "active" + // ManagerStatusDisabled 表示经理身份已停用,不能继续使用经理中心能力。 + ManagerStatusDisabled = "disabled" + // BDRoleBD 是普通拓展角色。 BDRoleBD = "bd" // BDRoleLeader 是 BD Leader,具备邀请 BD 的能力;它与普通 BD 身份分表保存。 @@ -181,6 +186,18 @@ type BDProfile struct { UpdatedAtMs int64 } +// ManagerProfile 是经理身份事实。 +// 经理是 BD Leader/Admin 的上级,和 Host/Agency/BD 链路没有父子关系。 +type ManagerProfile struct { + UserID int64 + RegionID int64 + Status string + Contact string + CreatedByAdminID int64 + CreatedAtMs int64 + UpdatedAtMs int64 +} + // CoinSellerProfile 是币商角色事实,余额不保存在 user-service。 type CoinSellerProfile struct { UserID int64 diff --git a/services/user-service/internal/service/host/business.go b/services/user-service/internal/service/host/business.go index 01c6ded4..19513863 100644 --- a/services/user-service/internal/service/host/business.go +++ b/services/user-service/internal/service/host/business.go @@ -13,7 +13,7 @@ const ( ) // CheckBusinessCapability 是 App/H5 业务入口的服务端身份边界。 -// 首版经理中心只认 active Agency owner,不把客户端自报角色或普通 Agency 成员当作授权依据。 +// 经理中心只认 manager_profiles 中的 active 经理身份,不把客户端自报角色、Agency owner 或普通 BD 当作授权依据。 func (s *Service) CheckBusinessCapability(ctx context.Context, actorUserID int64, capability string) (bool, string, error) { if s.repository == nil { return false, "", xerr.New(xerr.Unavailable, "host repository is not configured") @@ -24,12 +24,12 @@ func (s *Service) CheckBusinessCapability(ctx context.Context, actorUserID int64 capability = strings.ToLower(strings.TrimSpace(capability)) switch capability { case CapabilityManagerCenter, CapabilityManagerResourceGrant: - _, ok, err := s.repository.HasActiveAgencyOwner(ctx, actorUserID) + ok, err := s.repository.HasActiveManagerProfile(ctx, actorUserID) if err != nil { return false, "", err } if !ok { - return false, "active_agency_owner_required", nil + return false, "active_manager_required", nil } return true, "", nil default: diff --git a/services/user-service/internal/service/host/commands.go b/services/user-service/internal/service/host/commands.go index 280cfa5c..bb8a0e5c 100644 --- a/services/user-service/internal/service/host/commands.go +++ b/services/user-service/internal/service/host/commands.go @@ -168,7 +168,8 @@ type ProcessRoleInvitationCommand struct { NowMs int64 } -// CreateBDLeaderInput 是后台创建 BD Leader 的外部输入。 +// CreateBDLeaderInput 是创建 BD Leader 的外部输入。 +// AdminUserID 是历史字段名:后台创建时是后台管理员 ID,经理中心创建时是经理 App 用户 ID。 type CreateBDLeaderInput struct { CommandID string AdminUserID int64 @@ -177,7 +178,8 @@ type CreateBDLeaderInput struct { RequestID string } -// CreateBDLeaderCommand 是后台创建 BD Leader 事务需要的完整命令。 +// CreateBDLeaderCommand 是创建 BD Leader 事务需要的完整命令。 +// AdminUserID 会写入 bd_leader_profiles.created_by_user_id,用于审计和经理下级统计。 type CreateBDLeaderCommand struct { CommandID string AdminUserID int64 diff --git a/services/user-service/internal/service/host/service.go b/services/user-service/internal/service/host/service.go index ef0a2852..90dc5c67 100644 --- a/services/user-service/internal/service/host/service.go +++ b/services/user-service/internal/service/host/service.go @@ -42,6 +42,7 @@ type Repository interface { GetUserRoleSummary(ctx context.Context, userID int64) (hostdomain.UserRoleSummary, error) GetAgency(ctx context.Context, agencyID int64) (hostdomain.Agency, error) HasActiveAgencyOwner(ctx context.Context, userID int64) (int64, bool, error) + HasActiveManagerProfile(ctx context.Context, userID int64) (bool, error) ListAgencyMembers(ctx context.Context, agencyID int64, status string) ([]hostdomain.AgencyMembership, error) ListAgencyApplications(ctx context.Context, agencyID int64, status string) ([]hostdomain.AgencyApplication, error) } @@ -355,7 +356,9 @@ func (s *Service) ProcessRoleInvitation(ctx context.Context, command ProcessRole }) } -// CreateBDLeader 由后台创建 BD Leader。后台权限和操作审计在 admin-server,user-service 只维护业务事实一致性。 +// CreateBDLeader 创建 BD Leader/Admin 身份。 +// 后台 admin 的权限在 admin-server 校验;App 经理中心的权限由 CheckBusinessCapability 基于 manager_profiles 校验。 +// 本方法只负责统一写入 BD Leader 事实,保证幂等、区域和身份冲突规则不会因入口不同而分叉。 func (s *Service) CreateBDLeader(ctx context.Context, command CreateBDLeaderInput) (hostdomain.BDProfile, error) { if err := s.requireWriteDependencies(); err != nil { return hostdomain.BDProfile{}, err diff --git a/services/user-service/internal/service/host/service_test.go b/services/user-service/internal/service/host/service_test.go index 80254346..6ee0de15 100644 --- a/services/user-service/internal/service/host/service_test.go +++ b/services/user-service/internal/service/host/service_test.go @@ -65,6 +65,7 @@ func assertNoHostOrgRegionSnapshotColumns(t *testing.T, repository *mysqltest.Re AND COLUMN_NAME = 'region_id' AND TABLE_NAME IN ( 'host_profiles', + 'manager_profiles', 'bd_profiles', 'bd_leader_profiles', 'agencies', @@ -81,34 +82,51 @@ func assertNoHostOrgRegionSnapshotColumns(t *testing.T, repository *mysqltest.Re } } -func TestCheckBusinessCapabilityRequiresActiveAgencyOwner(t *testing.T) { +func TestCheckBusinessCapabilityRequiresActiveManagerProfile(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) seedActiveUser(t, repository, 101, 10) seedActiveUser(t, repository, 202, 10) + seedActiveUser(t, repository, 303, 10) repository.PutAgency(hostdomain.Agency{ AgencyID: 301, - OwnerUserID: 101, + OwnerUserID: 202, RegionID: 10, - Name: "Manager Agency", + Name: "Agency Owner Is Not Manager", Status: hostdomain.AgencyStatusActive, }) + repository.PutManagerProfile(hostdomain.ManagerProfile{ + UserID: 101, + Status: hostdomain.ManagerStatusActive, + }) + repository.PutManagerProfile(hostdomain.ManagerProfile{ + UserID: 303, + Status: hostdomain.ManagerStatusDisabled, + }) svc := newHostService(repository, 1000, 2000) allowed, reason, err := svc.CheckBusinessCapability(ctx, 101, hostservice.CapabilityManagerResourceGrant) if err != nil { - t.Fatalf("CheckBusinessCapability for owner failed: %v", err) + t.Fatalf("CheckBusinessCapability for manager failed: %v", err) } if !allowed || reason != "" { - t.Fatalf("active agency owner should be allowed: allowed=%v reason=%q", allowed, reason) + t.Fatalf("active manager should be allowed: allowed=%v reason=%q", allowed, reason) } allowed, reason, err = svc.CheckBusinessCapability(ctx, 202, hostservice.CapabilityManagerResourceGrant) if err != nil { - t.Fatalf("CheckBusinessCapability for non-owner failed: %v", err) + t.Fatalf("CheckBusinessCapability for agency owner failed: %v", err) } - if allowed || reason != "active_agency_owner_required" { - t.Fatalf("non-owner should be denied by stable reason: allowed=%v reason=%q", allowed, reason) + if allowed || reason != "active_manager_required" { + t.Fatalf("agency owner without manager profile should be denied: allowed=%v reason=%q", allowed, reason) + } + + allowed, reason, err = svc.CheckBusinessCapability(ctx, 303, hostservice.CapabilityManagerCenter) + if err != nil { + t.Fatalf("CheckBusinessCapability for disabled manager failed: %v", err) + } + if allowed || reason != "active_manager_required" { + t.Fatalf("disabled manager should be denied: allowed=%v reason=%q", allowed, reason) } allowed, _, err = svc.CheckBusinessCapability(ctx, 101, "unknown_capability") @@ -936,6 +954,10 @@ func TestGetUserRoleSummaryAggregatesRolesAndPendingInvitations(t *testing.T) { RegionID: 60, Status: hostdomain.BDStatusActive, }) + repository.PutManagerProfile(hostdomain.ManagerProfile{ + UserID: 931, + Status: hostdomain.ManagerStatusActive, + }) svc := newHostService(repository, 8000, 9000) if _, err := svc.CreateCoinSeller(ctx, hostservice.CreateCoinSellerInput{ CommandID: "admin-create-coin-seller-931", diff --git a/services/user-service/internal/storage/mysql/host/common.go b/services/user-service/internal/storage/mysql/host/common.go index 5b066b43..6b5bdf26 100644 --- a/services/user-service/internal/storage/mysql/host/common.go +++ b/services/user-service/internal/storage/mysql/host/common.go @@ -58,6 +58,8 @@ var ( bdLeaderProfileColumns = fmt.Sprintf(` user_id, 'bd_leader', %s, 0, status, created_by_user_id, created_at_ms, updated_at_ms`, userRegionProjection("bd_leader_profiles", "user_id")) + managerProfileColumns = fmt.Sprintf(` + user_id, %s, status, contact_info, created_by_admin_id, created_at_ms, updated_at_ms`, userRegionProjection("manager_profiles", "user_id")) agencyMembershipColumns = fmt.Sprintf(` membership_id, agency_id, host_user_id, %s, membership_type, status, joined_at_ms, COALESCE(ended_at_ms, 0), COALESCE(ended_by_user_id, 0), diff --git a/services/user-service/internal/storage/mysql/host/queries.go b/services/user-service/internal/storage/mysql/host/queries.go index a34b1936..b79da454 100644 --- a/services/user-service/internal/storage/mysql/host/queries.go +++ b/services/user-service/internal/storage/mysql/host/queries.go @@ -207,6 +207,7 @@ func (r *Repository) GetUserRoleSummary(ctx context.Context, userID int64) (host var bdStatus string var leaderUserID int64 var leaderStatus string + var managerUserID int64 var coinSellerStatus string var merchantAssetType string @@ -220,6 +221,7 @@ func (r *Repository) GetUserRoleSummary(ctx context.Context, userID int64) (host COALESCE(b.status, ''), COALESCE(bl.user_id, 0), COALESCE(bl.status, ''), + COALESCE(m.user_id, 0), COALESCE(c.status, ''), COALESCE(c.merchant_asset_type, '') FROM (SELECT ? AS user_id) u @@ -231,6 +233,8 @@ func (r *Repository) GetUserRoleSummary(ctx context.Context, userID int64) (host ON b.app_code = ? AND b.user_id = u.user_id LEFT JOIN bd_leader_profiles bl ON bl.app_code = ? AND bl.user_id = u.user_id + LEFT JOIN manager_profiles m + ON m.app_code = ? AND m.user_id = u.user_id AND m.status = ? LEFT JOIN coin_seller_profiles c ON c.app_code = ? AND c.user_id = u.user_id`, userID, @@ -238,8 +242,9 @@ func (r *Repository) GetUserRoleSummary(ctx context.Context, userID int64) (host app, hostdomain.AgencyStatusActive, app, app, + app, hostdomain.ManagerStatusActive, app, - ).Scan(&hostStatus, &hostSource, &hostAgencyID, &ownerAgencyID, &bdUserID, &bdStatus, &leaderUserID, &leaderStatus, &coinSellerStatus, &merchantAssetType) + ).Scan(&hostStatus, &hostSource, &hostAgencyID, &ownerAgencyID, &bdUserID, &bdStatus, &leaderUserID, &leaderStatus, &managerUserID, &coinSellerStatus, &merchantAssetType) if err != nil { return hostdomain.UserRoleSummary{}, err } @@ -248,11 +253,11 @@ func (r *Repository) GetUserRoleSummary(ctx context.Context, userID int64) (host summary.IsHost = hostStatus == hostdomain.HostStatusActive if ownerAgencyID > 0 { summary.AgencyID = ownerAgencyID - // 经理中心首版授权边界是 active Agency owner;普通 Agency 成员只展示 Agency 入口,不具备 manager 身份。 - summary.IsManager = true } else { summary.AgencyID = hostAgencyID } + // 经理身份来自独立 manager_profiles 表;Agency owner、Host、BD 或 BD Leader 身份都不会隐式获得经理中心入口。 + summary.IsManager = managerUserID > 0 summary.IsAgency = ownerAgencyID > 0 || (summary.IsHost && (hostSource == hostdomain.HostSourceAgencyInvitation || hostSource == hostdomain.HostSourceAdminCreateAgency)) summary.BDStatus = bdStatus if bdUserID > 0 && bdStatus == hostdomain.BDStatusActive { @@ -292,6 +297,25 @@ func (r *Repository) HasActiveAgencyOwner(ctx context.Context, userID int64) (in return agency.AgencyID, true, nil } +// HasActiveManagerProfile 精确判断用户是否拥有 active 经理身份。 +// 经理是 BD Leader/Admin 的上级身份,不能从 Agency owner 或 BD/BD Leader 身份推导出来。 +func (r *Repository) HasActiveManagerProfile(ctx context.Context, userID int64) (bool, error) { + var id int64 + err := r.db.QueryRowContext(ctx, ` + SELECT user_id + FROM manager_profiles + WHERE app_code = ? AND user_id = ? AND status = ? + LIMIT 1 + `, appcode.FromContext(ctx), userID, hostdomain.ManagerStatusActive).Scan(&id) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, err + } + return id > 0, nil +} + // ListBDLeaderBDs 读取 BD Leader 直属普通 BD;调用前先校验独立 Leader 行当前仍有效,避免停用后继续读取团队数据。 func (r *Repository) ListBDLeaderBDs(ctx context.Context, command hostservice.ListBDLeaderBDsCommand) ([]hostdomain.BDProfile, error) { leader, err := queryBDLeaderProfile(ctx, r.db, "WHERE user_id = ?", command.LeaderUserID) diff --git a/services/user-service/internal/testutil/mysqltest/mysqltest.go b/services/user-service/internal/testutil/mysqltest/mysqltest.go index 42342cf2..218252cc 100644 --- a/services/user-service/internal/testutil/mysqltest/mysqltest.go +++ b/services/user-service/internal/testutil/mysqltest/mysqltest.go @@ -732,6 +732,36 @@ func (r *Repository) PutBDProfile(profile hostdomain.BDProfile) { } } +// PutManagerProfile seeds manager identity rows into manager_profiles. +func (r *Repository) PutManagerProfile(profile hostdomain.ManagerProfile) { + r.t.Helper() + + if profile.Status == "" { + profile.Status = hostdomain.ManagerStatusActive + } + nowMs := profile.UpdatedAtMs + if nowMs == 0 { + nowMs = time.Now().UnixMilli() + } + if profile.CreatedAtMs == 0 { + profile.CreatedAtMs = nowMs + } + profile.UpdatedAtMs = nowMs + + _, err := r.schema.DB.ExecContext(context.Background(), ` + INSERT INTO manager_profiles ( + user_id, status, contact_info, created_by_admin_id, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + status = VALUES(status), + contact_info = VALUES(contact_info), + updated_at_ms = VALUES(updated_at_ms) + `, profile.UserID, profile.Status, profile.Contact, profile.CreatedByAdminID, profile.CreatedAtMs, profile.UpdatedAtMs) + if err != nil { + r.t.Fatalf("seed manager profile %d failed: %v", profile.UserID, err) + } +} + // PutAgency seeds an active Agency fact for host-domain tests. func (r *Repository) PutAgency(agency hostdomain.Agency) { r.t.Helper() diff --git a/services/user-service/internal/transport/grpc/host_admin.go b/services/user-service/internal/transport/grpc/host_admin.go index 0856a00e..cdd5ee6a 100644 --- a/services/user-service/internal/transport/grpc/host_admin.go +++ b/services/user-service/internal/transport/grpc/host_admin.go @@ -8,7 +8,8 @@ import ( hostservice "hyapp/services/user-service/internal/service/host" ) -// CreateBDLeader 处理后台创建 BD Leader;权限和操作审计在 admin-server,user-service 只认事实约束。 +// CreateBDLeader 处理 BD Leader/Admin 身份创建。 +// 后台 admin 入口和经理中心入口复用同一个事实写入 RPC,具体入口权限在调用方进入该 RPC 前完成。 func (s *Server) CreateBDLeader(ctx context.Context, req *userv1.CreateBDLeaderRequest) (*userv1.CreateBDLeaderResponse, error) { if s.hostSvc == nil { return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured")) diff --git a/services/wallet-service/configs/config.yaml b/services/wallet-service/configs/config.yaml index c47f61d2..d9d68ed8 100644 --- a/services/wallet-service/configs/config.yaml +++ b/services/wallet-service/configs/config.yaml @@ -54,13 +54,23 @@ google_play: mifapay: # MiFaPay 是 H5 统一三方支付入口;商家公钥只用于记录商户侧配置,真实下单签名必须继续使用商户私钥。 enabled: true - mer_account: "6000055d34f445418124ab7c0beb81a6" - mer_no: "10001910" - merchant_public_key: "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvVDVrlVS+HkfLx+56v1Fyfqj5Gd+DRkPIK4EOu+SMi+wtP4dwPBeXkrL1w+nSC0UFQ2Py690qdRu1FCSTNMWC8kfzddC4xlckJKXa9NoDyeJIN/Z5Za9Fg2znfqTee9BE8NnJk4EtmRwKJQaP8sCrYsIDv/bagywM9t25lKn2bmUOZKVVUtn/Xf5OBYDE6AVHB83J+HxX5VPLie+dbRdsRR+hoZs/vzLTNlZsTcrAnWUf7cqd9XNr39gXnXgdw61kzAC9RtSLbRPAo474F03xmLZKsL70vmBgex9/paljPxNnFVq1CPxmam5yzY0NtS6iXUlU7ttqAzBolSwPWla7QIDAQAB" - private_key: "" - platform_public_key: "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4Kt1qR5jwf4xUVHqo2pLZW2Xb43P9c8BjMHeq4ih+u+8Qr5YtJa2EPv/sNoAQktjaUhsg9tJvtn1Pc46xIo3XeZiicdn91eNSdC6mZ8NxT6IJyyEr5x15o6hJORkiDbYg8k3rf40bZl8B6Oz6pEGPIYrmO8DVWN0Ak7qmWhHF5ePKusfLjy4TWEt2JmQGHAz+lfkDGaQzwtrC2kt4adYwcdpY78B4fH8Ssofr2fLuMQYl2QYWIV0lplAQicuTRhgWj4tora/MoK/LbKKFCthrElGvVJtial1QCfhOOUVgdjzGKdmuer8qiuseDraVIKYkAjuY9fGBWF+2N7mNIWOKwIDAQAB" - api_base_url: "https://platformtest.xqdmipay.com" - http_timeout: "10s" + environment: "test" # 可选值:test/prod;切换环境时必须保证后台商户公钥和本地私钥是一对。 + environment_test: + mer_account: "d00f5fc97a114a0db291eceaf9841739" + mer_no: "10001925" + merchant_public_key: "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA7FsuU9HXVXuFMKMbz10Hq0B3JfOkPJZwUfEUTdST3UuHpkp90LVLCoYfHJ8W9tV1HUhvZ+taTWrPY82vL5KPkyKok/M64NCRR3Gf/dojm6W+/ajylBhkQYiR/aksXCVTbjgt8eAbWCHpsoqqji0ZjdVZM/br6yHz89mm3nWK3GMPheRjwC2LROUdqOkpb2H3C4PKEm/zGBW0/88uSDIUzsoKwyMOhMyJuXqI+HMpMG3SNT732f4SllJrrF/reRJnok87LqBPOTCfAVeEwgWhk0PD6sktdISarqnVgYqfGBpAS+AmqhaNforZ6UTdNFDBMithau+6f/qC3+rr3YiiwQIDAQAB" + private_key: "MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDsWy5T0ddVe4UwoxvPXQerQHcl86Q8lnBR8RRN1JPdS4emSn3QtUsKhh8cnxb21XUdSG9n61pNas9jza8vko+TIqiT8zrg0JFHcZ/92iObpb79qPKUGGRBiJH9qSxcJVNuOC3x4BtYIemyiqqOLRmN1Vkz9uvrIfPz2abedYrcYw+F5GPALYtE5R2o6SlvYfcLg8oSb/MYFbT/zy5IMhTOygrDIw6EzIm5eoj4cykwbdI1PvfZ/hKWUmusX+t5EmeiTzsuoE85MJ8BV4TCBaGTQ8PqyS10hJquqdWBip8YGkBL4CaqFo1+itnpRN00UMEyK2Fq77p/+oLf6uvdiKLBAgMBAAECggEALvaXJ/SEEuouNtHSDsvvYCm5AwN8WMWTn9SFMknQ76G4XGRkx3meZYLs36UEzRrw4FaqPD3ZfZSBjNUrwAohGR1D3dHwmwcWNtnu7mPPu3edkY1mVeLa2OKoD3zz4YNRJo9nIa5kAIkMhHtlFEcF0tbXmKmxitD+Ri+zJnS4mL18cUrnRlhjheY6c16FTzalnSKLRQDDXNApWDO/9AXlzQ5NKQQgOvzFcOK02Yy1crOIzar+lK9QVt+Zbd3F1jqflSSIuGGbmzqfPSUqi31JfVq5gj1wWZwRent+62DZI1Gk1CXfopk+z+w5eXFregR9ZAUpRiK3cFkIMLepMhK4oQKBgQD2DAjwCcd2H2QK/POSQhfc++bB5ZNKnRK53uTWSkii2WyAYTfxFBOxK8IZZ6lmE5vNO2OKJIYaH6A3jQE0rE2JiXgEauYhp3iS/J9RtjmZPEl1vlI7ELkc8t/yawvPi82xPZ1PKpsO5aUSVW6ozw5k2ShfRS75lKJ7Yt8TXP6INQKBgQD16sqm613oM0U5VEzdAtOo1EeB1ZM+fMBBTd+veqG7gPtb7AR9buTLi8p87b6npA4lgfr07ha3garEO9oZmy4MEIDBlCVb/McOqEZXII9slpcWwWwVXL+soRKPAjY4K4+r/3a044eTphaLve3HBhIhC8e7CO7XvgA4rihQTzx53QKBgCH2K02mq+d8Eb0KiTxI7y5LfuCIpboI0Erjn0aM3lXMGRPnV2dpVAaCeboz+sfiMQE1D1pEDFaSwvSn++CfX9x6IxzI/JbsnNH9GKiYtH6YObOM36DTgBmk1JCLggfVzkCGtq30vY5i2FQdgra+TchRySbNT9yXTY4maZAsp915AoGBANE2Nb/6H0MrUyopwNaJ0B4iss8PLxyLL1ifcrysXKnTWoTibxNzY2yTeen8rSWb2XkpcqIrZ+Ioi2o07SC5t1wGMjP94Ca5D3Q1ztnS1pHwL4vpdBVceajYw5wsqonw7WLLjGbp527Cbw3lwEOAs62vgvVo7SFYAII4EA36yx11AoGBAO+LUxVk9C7BShM9bcV479zCp4HTz1G3/FRk17FYOGFe3ZD+ueworMFTZidQbMevBH9g9m6jmP4WHdJ5BmvJeb/r4ZO+aJh1TvmLs5xdd4R8b34jTkrr1CDRLhiSWunwKLODsooa56i+JBBh3McKADc/o4xJ7JkT/lC5RG3jm6rv" + platform_public_key: "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoZnRxeMSOINq5SUdh7jJ9D99ffppPqruejTYxI6WDXvgjykvX2erB9r2sFPyWA6VtzH5MxLb7iUQm8ixv5XhG1ImIZ8RAw2bdkaLD2Km94H7OtZcAyEGXjMEazI6/qfKJjpgA9oz9Ei2AokLnvX9B75Rnx7/cW1GF3+bJEyT9Nk7t74wDvp4QeSmejJDq8Exzex0hksUjUWI+Mj9KhqFT0/AU9icbLNv5wfkMBqZW6Z3garPv0pfVYgZm0bBgyBVB9Bi9WMZ57puPixfBXJ6cyD26izap0ly4rJni/RQHfFtOHaj+Sr+WmDNZ/X4Wd7roS+0CgXjOUBaJdX5XJ0zUQIDAQAB" + api_base_url: "https://platformtest.xqdmipay.com" + http_timeout: "10s" + environment_prod: + mer_account: "6000055d34f445418124ab7c0beb81a6" + mer_no: "10001910" + merchant_public_key: "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvVDVrlVS+HkfLx+56v1Fyfqj5Gd+DRkPIK4EOu+SMi+wtP4dwPBeXkrL1w+nSC0UFQ2Py690qdRu1FCSTNMWC8kfzddC4xlckJKXa9NoDyeJIN/Z5Za9Fg2znfqTee9BE8NnJk4EtmRwKJQaP8sCrYsIDv/bagywM9t25lKn2bmUOZKVVUtn/Xf5OBYDE6AVHB83J+HxX5VPLie+dbRdsRR+hoZs/vzLTNlZsTcrAnWUf7cqd9XNr39gXnXgdw61kzAC9RtSLbRPAo474F03xmLZKsL70vmBgex9/paljPxNnFVq1CPxmam5yzY0NtS6iXUlU7ttqAzBolSwPWla7QIDAQAB" + private_key: "MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC9UNWuVVL4eR8vH7nq/UXJ+qPkZ34NGQ8grgQ675IyL7C0/h3A8F5eSsvXD6dILRQVDY/Lr3Sp1G7UUJJM0xYLyR/N10LjGVyQkpdr02gPJ4kg39nllr0WDbOd+pN570ETw2cmTgS2ZHAolBo/ywKtiwgO/9tqDLAz23bmUqfZuZQ5kpVVS2f9d/k4FgMToBUcHzcn4fFflU8uJ751tF2xFH6Ghmz+/MtM2VmxNysCdZR/typ31c2vf2BedeB3DrWTMAL1G1IttE8CjjvgXTfGYtkqwvvS+YGB7H3+lqWM/E2cVWrUI/GZqbnLNjQ21LqJdSVTu22oDMGiVLA9aVrtAgMBAAECggEBAI/jZ3VoTnaiAIydjX5IYAnC4VxROKIV4MSS3o441iIk0ybNWanyR5ddPA7KS/d0o6XYt+k1AvOtjUr7riaL7V6pz+1wZdsbxJnxgPsoJBgYkYYV/ZQdfLvNUC6LbBmqOgkeZEDd3YEPV5vP7R99Whyy5j4YQxkYabgQXw9x1TeKHtCVY7DROTBaKcXH+OKrF73GeJfxACIlgcG9RUdiTnmmRoSfrDN2zQy1RiWCBiQhoUYYfJrPtC1IRd8HE8M3ctLchoIQKJ9Zu+5gHM1KaARkn/Hkk8Mo1uzwnvsDgI5Mjcm403gm3/rIlH2q4+LSMtNJib3hqFrVEJ6cM8AgG8ECgYEA+wacF72UY+YjZCp7mJk4QwJEHUvws+mqY2uzONS/cDpODoCnZAMejEmVn0kCuY+kjD79YBduPuJTzrZoUS+YfqIBxHryUPbXJOs2l9la/o6P3Nbo+ILDBKNp7Fm0t96Z6Va7jwyVpclL0NXpv4Q4VP2TfDFxp/1xjSZRPSCjdP0CgYEAwREvfEVPTWROktftppH2/NS0Kv88o+YfnF/upRwIYYxl4zAPT4WUPvwELLwW9xfmt/IQGoY/6t/Vk0YZ1B14uPKNEyoJyMqDP6p6kT7vUwI8uPUP/n5wW3bLKJnoScKbgCQ+M3P/TRsOJQlthnS2EzcmqZgmCqfULaqEE89w2LECgYBe0pxcC2zmX6S2zaVqe1ZZWZ3JQ0pla7rFW4WTzimShzO9HrQcf2EmtRUj2jyBQ9VwdMrZZYBrmKr3xseZA1WjYCcyxQ6LrvKOK5uvuJc62TdWwIe4eAyaz+/PLAOsARY6olyvKJ3Oxj8xX9ywT43H7RGtNDAuuPoPy42cfZvBMQKBgFAQemrEq3Biyprm5O0Q1yZeZyD1nAY0JqPIvMTH7UnW2WHvykmmEaZVCL4sejGaPdnGjpsRVWxnysA6b/ehCTFZlNt/sXMNWISMhCu7t6/q25ewOSPG931WtEVwHf6Ir8fiP9FCp3soXyNI15zMxb0OQABQ0mXU147giclVW2zhAoGBAPlW9hqbWtHQIdXAnsCLqa4YaBNex+ghv1tjcsi79uiydg5RjEiWz43xzEQrywnALXEHqjSShvBsHVpa2uTOlpA515UqgLyl7JIeqAKrPx+5qKIo8901OG/u/b00x0GTdF3wYpTavqcaFexNTzfylYUs8FueLNVh9NvTSqjEHjvo" + platform_public_key: "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4Kt1qR5jwf4xUVHqo2pLZW2Xb43P9c8BjMHeq4ih+u+8Qr5YtJa2EPv/sNoAQktjaUhsg9tJvtn1Pc46xIo3XeZiicdn91eNSdC6mZ8NxT6IJyyEr5x15o6hJORkiDbYg8k3rf40bZl8B6Oz6pEGPIYrmO8DVWN0Ak7qmWhHF5ePKusfLjy4TWEt2JmQGHAz+lfkDGaQzwtrC2kt4adYwcdpY78B4fH8Ssofr2fLuMQYl2QYWIV0lplAQicuTRhgWj4tora/MoK/LbKKFCthrElGvVJtial1QCfhOOUVgdjzGKdmuer8qiuseDraVIKYkAjuY9fGBWF+2N7mNIWOKwIDAQAB" + api_base_url: "https://platform.xqdmipay.com" + http_timeout: "10s" tron_grid: enabled: true api_base_url: "https://api.trongrid.io" @@ -70,8 +80,8 @@ tron_grid: external_recharge: usdt_trc20_enabled: true usdt_trc20_address: "cccccccccccc" - mifapay_notify_url: "" - mifapay_return_url: "" + mifapay_notify_url: "http://127.0.0.1:13000/api/v1/payment/mifapay/notify" + mifapay_return_url: "http://127.0.0.1:7001/recharge/index.html" outbox_worker: enabled: true poll_interval: "1s" diff --git a/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql b/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql index 038c62b5..c9baa2fe 100644 --- a/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql +++ b/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql @@ -652,6 +652,69 @@ INSERT IGNORE INTO third_party_payment_methods ( ('lalu', 'mifapay', 'PH', 'Philippines', 'PHP', 'Ewallet', 'PayMaya', 'PayMaya', '', 'active', 1, 1320, @now_ms, @now_ms), ('lalu', 'mifapay', 'PH', 'Philippines', 'PHP', 'QR', 'QRPH', 'QRPH', '', 'active', 1, 1330, @now_ms, @now_ms); +-- MiFaPay logo 使用已上传 COS 的固定对象地址;仅覆盖空值、早期占位图和 Apifox 临时预览图,避免误改运营手工替换的正式图。 +UPDATE third_party_payment_methods m +JOIN ( + SELECT 'AE' AS country_code, 'Card' AS pay_way, 'CreditCard' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/ae/card-creditcard.png' AS logo_url + UNION ALL SELECT 'BD' AS country_code, 'Ewallet' AS pay_way, 'Nagad' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/bd/ewallet-nagad.png' AS logo_url + UNION ALL SELECT 'BD' AS country_code, 'Ewallet' AS pay_way, 'bKash' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/bd/ewallet-bkash.png' AS logo_url + UNION ALL SELECT 'BH' AS country_code, 'Card' AS pay_way, 'CreditCard' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/bh/card-creditcard.png' AS logo_url + UNION ALL SELECT 'EG' AS country_code, 'Card' AS pay_way, 'CreditCard' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/eg/card-creditcard.png' AS logo_url + UNION ALL SELECT 'EG' AS country_code, 'Ewallet' AS pay_way, 'FawryPay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/eg/ewallet-fawrypay.png' AS logo_url + UNION ALL SELECT 'EG' AS country_code, 'Ewallet' AS pay_way, 'Meeza' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/eg/ewallet-meeza.png' AS logo_url + UNION ALL SELECT 'EG' AS country_code, 'Ewallet' AS pay_way, 'Opay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/eg/ewallet-opay.png' AS logo_url + UNION ALL SELECT 'EG' AS country_code, 'Ewallet' AS pay_way, 'Orange' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/eg/ewallet-orange.png' AS logo_url + UNION ALL SELECT 'EG' AS country_code, 'Ewallet' AS pay_way, 'Vodafone' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/eg/ewallet-vodafone.png' AS logo_url + UNION ALL SELECT 'GLOBAL' AS country_code, 'BankTransfer' AS pay_way, 'UnionPay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/global/banktransfer-unionpay.png' AS logo_url + UNION ALL SELECT 'GLOBAL' AS country_code, 'Card' AS pay_way, 'CreditCard' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/global/card-creditcard.png' AS logo_url + UNION ALL SELECT 'GLOBAL' AS country_code, 'Ewallet' AS pay_way, 'ApplePay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/global/ewallet-applepay.png' AS logo_url + UNION ALL SELECT 'GLOBAL' AS country_code, 'Ewallet' AS pay_way, 'GooglePay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/global/ewallet-googlepay.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'BankTransfer' AS pay_way, 'BCA' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-bca.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'BankTransfer' AS pay_way, 'BNI' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-bni.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'BankTransfer' AS pay_way, 'BRI' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-bri.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'BankTransfer' AS pay_way, 'Mandiri' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-mandiri.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'BankTransfer' AS pay_way, 'Permata' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-permata.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'BankTransfer' AS pay_way, 'QRIS' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-qris.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'Ewallet' AS pay_way, 'DANA' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/ewallet-dana.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'Ewallet' AS pay_way, 'LinkAja' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/ewallet-linkaja.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'Ewallet' AS pay_way, 'OVO' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/ewallet-ovo.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'Ewallet' AS pay_way, 'ShopeePay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/ewallet-shopeepay.png' AS logo_url + UNION ALL SELECT 'ID' AS country_code, 'QR' AS pay_way, 'QRIS' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/id/qr-qris.png' AS logo_url + UNION ALL SELECT 'IN' AS country_code, 'Ewallet' AS pay_way, 'BHIM' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-bhim.png' AS logo_url + UNION ALL SELECT 'IN' AS country_code, 'Ewallet' AS pay_way, 'GooglePay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-googlepay.png' AS logo_url + UNION ALL SELECT 'IN' AS country_code, 'Ewallet' AS pay_way, 'Mobikwik' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-mobikwik.png' AS logo_url + UNION ALL SELECT 'IN' AS country_code, 'Ewallet' AS pay_way, 'Paytm' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-paytm.png' AS logo_url + UNION ALL SELECT 'IN' AS country_code, 'Ewallet' AS pay_way, 'PhonePe' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-phonepe.png' AS logo_url + UNION ALL SELECT 'IN' AS country_code, 'Ewallet' AS pay_way, 'UPI' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-upi.png' AS logo_url + UNION ALL SELECT 'KW' AS country_code, 'Card' AS pay_way, 'KENT' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/kw/card-kent.png' AS logo_url + UNION ALL SELECT 'KW' AS country_code, 'Card' AS pay_way, 'Knet' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/kw/card-knet.png' AS logo_url + UNION ALL SELECT 'MY' AS country_code, 'BankTransfer' AS pay_way, 'FPX' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/my/banktransfer-fpx.png' AS logo_url + UNION ALL SELECT 'MY' AS country_code, 'Ewallet' AS pay_way, 'TNG' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/my/ewallet-tng.png' AS logo_url + UNION ALL SELECT 'OM' AS country_code, 'Card' AS pay_way, 'CreditCard' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/om/card-creditcard.png' AS logo_url + UNION ALL SELECT 'PH' AS country_code, 'BankTransfer' AS pay_way, 'QRPH' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/ph/banktransfer-qrph.png' AS logo_url + UNION ALL SELECT 'PH' AS country_code, 'Ewallet' AS pay_way, 'Gcash' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/ph/ewallet-gcash.png' AS logo_url + UNION ALL SELECT 'PH' AS country_code, 'Ewallet' AS pay_way, 'PayMaya' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/ph/ewallet-paymaya.png' AS logo_url + UNION ALL SELECT 'PH' AS country_code, 'QR' AS pay_way, 'QRPH' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/ph/qr-qrph.png' AS logo_url + UNION ALL SELECT 'PK' AS country_code, 'Ewallet' AS pay_way, 'Easypaisa' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/pk/ewallet-easypaisa.png' AS logo_url + UNION ALL SELECT 'PK' AS country_code, 'Ewallet' AS pay_way, 'JazzCash' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/pk/ewallet-jazzcash.png' AS logo_url + UNION ALL SELECT 'QA' AS country_code, 'Card' AS pay_way, 'CreditCard' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/qa/card-creditcard.png' AS logo_url + UNION ALL SELECT 'SA' AS country_code, 'Card' AS pay_way, 'MADA' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/sa/card-mada.png' AS logo_url + UNION ALL SELECT 'SA' AS country_code, 'Ewallet' AS pay_way, 'ApplePay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/sa/ewallet-applepay.png' AS logo_url + UNION ALL SELECT 'SA' AS country_code, 'Ewallet' AS pay_way, 'STCPay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/sa/ewallet-stcpay.png' AS logo_url + UNION ALL SELECT 'TH' AS country_code, 'BankTransfer' AS pay_way, 'PromptPay' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/th/banktransfer-promptpay.png' AS logo_url + UNION ALL SELECT 'TH' AS country_code, 'Ewallet' AS pay_way, 'TrueMoney' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/th/ewallet-truemoney.png' AS logo_url + UNION ALL SELECT 'VN' AS country_code, 'BankTransfer' AS pay_way, 'VN_BankTransfer' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/vn/banktransfer-vn-banktransfer.png' AS logo_url + UNION ALL SELECT 'VN' AS country_code, 'QR' AS pay_way, 'VietQR' AS pay_type, 'https://media.haiyihy.com/admin/payment/mifapay/logos/vn/qr-vietqr.png' AS logo_url +) logos + ON UPPER(m.country_code) = logos.country_code + AND LOWER(m.pay_way) = LOWER(logos.pay_way) + AND LOWER(m.pay_type) = LOWER(logos.pay_type) +SET m.logo_url = logos.logo_url, + m.updated_at_ms = @now_ms +WHERE m.app_code = 'lalu' + AND m.provider_code = 'mifapay' + AND (m.logo_url = '' OR m.logo_url LIKE 'https://placehold.co/%' OR m.logo_url LIKE '%/image-preview'); + CREATE TABLE IF NOT EXISTS payment_orders ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', payment_order_id VARCHAR(96) NOT NULL COMMENT '平台支付订单 ID', diff --git a/services/wallet-service/internal/client/mifapay/client.go b/services/wallet-service/internal/client/mifapay/client.go index 86a95b72..d71ebf69 100644 --- a/services/wallet-service/internal/client/mifapay/client.go +++ b/services/wallet-service/internal/client/mifapay/client.go @@ -62,6 +62,7 @@ func (c *Client) CreateOrder(ctx context.Context, req walletservice.MifaPayCreat if c == nil || c.privateKey == nil { return walletservice.MifaPayCreateOrderResponse{}, xerr.New(xerr.Unavailable, "mifapay client is not configured") } + payer := rechargePayer(req) data := map[string]any{ "amount": strconv.FormatInt(req.ProviderAmountMinor, 10), "orderId": req.OrderID, @@ -78,6 +79,10 @@ func (c *Client) CreateOrder(ctx context.Context, req walletservice.MifaPayCreat "returnUrl": req.ReturnURL, "memberId": strconv.FormatInt(req.TargetUserID, 10), "userIp": req.ClientIP, + "email": payer.email, + "firstName": payer.firstName, + "lastName": payer.lastName, + "phone": payer.phone, } body, err := c.signedEnvelope(data) if err != nil { @@ -111,7 +116,12 @@ func (c *Client) CreateOrder(ctx context.Context, req walletservice.MifaPayCreat } outer = withCode.envelope if withCode.Code != "" && withCode.Code != "000000" { - return walletservice.MifaPayCreateOrderResponse{}, xerr.New(xerr.Conflict, "mifapay order rejected: "+withCode.Msg) + // MiFaPay 已接收请求但按网关规则拒单时,本服务还没有进入任何账务状态流转;这类失败是三方依赖语义, + // 不能映射成业务 CONFLICT,否则 H5 只能看到“conflict”,排障也会误判成幂等或商品状态冲突。 + return walletservice.MifaPayCreateOrderResponse{}, mifapayGatewayRejectionError{ + cause: xerr.New(xerr.Unavailable, "mifapay order rejected: "+withCode.Code+": "+withCode.Msg), + rawJSON: string(raw), + } } if err := c.verify(outer.Data, outer.Sign); err != nil { return walletservice.MifaPayCreateOrderResponse{}, err @@ -173,6 +183,23 @@ func (c *Client) ParseNotification(notification ledger.MifaPayNotification) (wal }, nil } +type mifapayGatewayRejectionError struct { + cause error + rawJSON string +} + +func (e mifapayGatewayRejectionError) Error() string { + return e.cause.Error() +} + +func (e mifapayGatewayRejectionError) Unwrap() error { + return e.cause +} + +func (e mifapayGatewayRejectionError) ProviderPayloadJSON() string { + return e.rawJSON +} + func (c *Client) signedEnvelope(data any) ([]byte, error) { dataJSON, err := json.Marshal(data) if err != nil { @@ -258,6 +285,43 @@ func productInfo(req walletservice.MifaPayCreateOrderRequest) string { return string(body) } +type payerIdentity struct { + email string + firstName string + lastName string + phone string +} + +func rechargePayer(req walletservice.MifaPayCreateOrderRequest) payerIdentity { + account := strings.TrimSpace(req.PayerAccount) + if account == "" { + account = strconv.FormatInt(normalizedPayerUserID(req.TargetUserID), 10) + } + name := strings.TrimSpace(req.PayerName) + if name == "" { + name = account + } + email := strings.TrimSpace(req.PayerEmail) + if email == "" { + email = fmt.Sprintf("mifapay-payer-%d@haiyihy.com", normalizedPayerUserID(req.TargetUserID)) + } + // H5 付款人参数必须来自 gateway 已解析出的目标用户资料:昵称同时写 firstName/lastName,账号写 phone, + // 邮箱按“账号@app_code.com”生成。兜底只服务旧内部调用方,防止 MiFaPay 因空字段拒单,不代表用户资料事实。 + return payerIdentity{ + email: email, + firstName: name, + lastName: name, + phone: account, + } +} + +func normalizedPayerUserID(targetUserID int64) int64 { + if targetUserID <= 0 { + return 1 + } + return targetUserID +} + func firstNonEmpty(values ...string) string { for _, value := range values { if value = strings.TrimSpace(value); value != "" { diff --git a/services/wallet-service/internal/client/mifapay/client_test.go b/services/wallet-service/internal/client/mifapay/client_test.go new file mode 100644 index 00000000..af9e993d --- /dev/null +++ b/services/wallet-service/internal/client/mifapay/client_test.go @@ -0,0 +1,108 @@ +package mifapay + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "hyapp/pkg/xerr" + "hyapp/services/wallet-service/internal/config" + walletservice "hyapp/services/wallet-service/internal/service/wallet" +) + +func TestCreateOrderMapsGatewayRejectionToUnavailable(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/paygateway/mbpay/order/en_v2" { + t.Fatalf("unexpected mifapay path: %s", request.URL.Path) + } + raw, err := io.ReadAll(request.Body) + if err != nil { + t.Fatalf("read mifapay request body: %v", err) + } + var outer envelope + if err := json.Unmarshal(raw, &outer); err != nil { + t.Fatalf("decode mifapay request envelope: %v", err) + } + var data struct { + Email string `json:"email"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Phone string `json:"phone"` + } + if err := json.Unmarshal([]byte(outer.Data), &data); err != nil { + t.Fatalf("decode mifapay request data: %v", err) + } + if data.Email != "163055@lalu.com" || data.FirstName != "normal55" || data.LastName != "normal55" || data.Phone != "163055" { + t.Fatalf("mifapay payer fields mismatch: %+v", data) + } + // MiFaPay 会用 200 HTTP 承载业务拒单;签名错误属于三方网关拒绝请求,不是钱包状态冲突。 + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{"code":"100002","msg":"Signature error"}`)) + })) + defer server.Close() + + cfg := testMifaPayConfig(t, server.URL) + client, err := New(cfg) + if err != nil { + t.Fatalf("new mifapay client failed: %v", err) + } + + _, err = client.CreateOrder(context.Background(), walletservice.MifaPayCreateOrderRequest{ + OrderID: "h5r_test", + TargetUserID: 10001, + ProductName: "coin", + AmountMinor: 100, + CurrencyCode: "SAR", + CountryCode: "SA", + PayWay: "Card", + PayType: "MADA", + NotifyURL: "https://api.example.com/api/v1/payment/mifapay/notify", + ReturnURL: "https://h5.example.com/recharge/index.html", + ClientIP: "127.0.0.1", + Language: "en", + ProviderAmountMinor: 100, + PayerName: "normal55", + PayerAccount: "163055", + PayerEmail: "163055@lalu.com", + }) + if !xerr.IsCode(err, xerr.Unavailable) { + t.Fatalf("expected MiFaPay rejection to map to UNAVAILABLE, got %v", err) + } + var payloadErr interface{ ProviderPayloadJSON() string } + if !errors.As(err, &payloadErr) || payloadErr.ProviderPayloadJSON() != `{"code":"100002","msg":"Signature error"}` { + t.Fatalf("mifapay rejection should expose raw provider payload, got %T %v", err, err) + } +} + +func testMifaPayConfig(t *testing.T, apiBaseURL string) config.MifaPayConfig { + t.Helper() + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate rsa key failed: %v", err) + } + privateDER, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + t.Fatalf("marshal private key failed: %v", err) + } + publicDER, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey) + if err != nil { + t.Fatalf("marshal public key failed: %v", err) + } + return config.MifaPayConfig{ + MerAccount: "merchant-test", + MerNo: "10000001", + PrivateKey: base64.StdEncoding.EncodeToString(privateDER), + PlatformPublicKey: base64.StdEncoding.EncodeToString(publicDER), + APIBaseURL: apiBaseURL, + HTTPTimeout: time.Second, + } +} diff --git a/services/wallet-service/internal/config/config.go b/services/wallet-service/internal/config/config.go index d7ce6bc3..febf5f8f 100644 --- a/services/wallet-service/internal/config/config.go +++ b/services/wallet-service/internal/config/config.go @@ -112,7 +112,21 @@ type GooglePlayConfig struct { // MifaPayConfig 保存 MiFaPay 商户签名、验签和网关配置。 type MifaPayConfig struct { - Enabled bool `yaml:"enabled"` + Enabled bool `yaml:"enabled"` + Environment string `yaml:"environment"` + EnvironmentTest MifaPayEndpointConfig `yaml:"environment_test"` + EnvironmentProd MifaPayEndpointConfig `yaml:"environment_prod"` + MerAccount string `yaml:"mer_account"` + MerNo string `yaml:"mer_no"` + MerchantPublicKey string `yaml:"merchant_public_key"` + PrivateKey string `yaml:"private_key"` + PlatformPublicKey string `yaml:"platform_public_key"` + APIBaseURL string `yaml:"api_base_url"` + HTTPTimeout time.Duration `yaml:"http_timeout"` +} + +// MifaPayEndpointConfig 保存某一个 MiFaPay 环境的商户和网关配置;密钥仍由配置文件或环境注入,不在代码里伪造。 +type MifaPayEndpointConfig struct { MerAccount string `yaml:"mer_account"` MerNo string `yaml:"mer_no"` MerchantPublicKey string `yaml:"merchant_public_key"` @@ -301,17 +315,7 @@ func Load(path string) (Config, error) { return Config{}, errors.New("google_play service account is required") } } - cfg.MifaPay.MerAccount = strings.TrimSpace(cfg.MifaPay.MerAccount) - cfg.MifaPay.MerNo = strings.TrimSpace(cfg.MifaPay.MerNo) - cfg.MifaPay.PrivateKey = strings.TrimSpace(cfg.MifaPay.PrivateKey) - cfg.MifaPay.PlatformPublicKey = strings.TrimSpace(cfg.MifaPay.PlatformPublicKey) - cfg.MifaPay.APIBaseURL = strings.TrimRight(strings.TrimSpace(cfg.MifaPay.APIBaseURL), "/") - if cfg.MifaPay.APIBaseURL == "" { - cfg.MifaPay.APIBaseURL = Default().MifaPay.APIBaseURL - } - if cfg.MifaPay.HTTPTimeout <= 0 { - cfg.MifaPay.HTTPTimeout = Default().MifaPay.HTTPTimeout - } + cfg.MifaPay = normalizeMifaPayConfig(cfg.MifaPay) // MiFaPay 功能开关默认打开;商户密钥属于环境敏感配置,允许本地缺省启动,启动装配时再按凭证完整性决定是否接入真实网关。 cfg.TronGrid.APIBaseURL = strings.TrimRight(strings.TrimSpace(cfg.TronGrid.APIBaseURL), "/") if cfg.TronGrid.APIBaseURL == "" { @@ -365,6 +369,59 @@ func Load(path string) (Config, error) { return cfg, nil } +func normalizeMifaPayConfig(cfg MifaPayConfig) MifaPayConfig { + cfg.Environment = strings.ToLower(strings.TrimSpace(cfg.Environment)) + // 本地联调可以同时保留 test/prod 两套商户配置;显式 environment 决定真实注入哪套,避免测试网和正式网字段互相覆盖。 + switch cfg.Environment { + case "test": + cfg = applyMifaPayEndpointConfig(cfg, cfg.EnvironmentTest) + case "prod", "production": + cfg.Environment = "prod" + cfg = applyMifaPayEndpointConfig(cfg, cfg.EnvironmentProd) + default: + cfg.Environment = "" + } + cfg.MerAccount = strings.TrimSpace(cfg.MerAccount) + cfg.MerNo = strings.TrimSpace(cfg.MerNo) + cfg.MerchantPublicKey = strings.TrimSpace(cfg.MerchantPublicKey) + cfg.PrivateKey = strings.TrimSpace(cfg.PrivateKey) + cfg.PlatformPublicKey = strings.TrimSpace(cfg.PlatformPublicKey) + cfg.APIBaseURL = strings.TrimRight(strings.TrimSpace(cfg.APIBaseURL), "/") + if cfg.APIBaseURL == "" { + cfg.APIBaseURL = Default().MifaPay.APIBaseURL + } + if cfg.HTTPTimeout <= 0 { + cfg.HTTPTimeout = Default().MifaPay.HTTPTimeout + } + return cfg +} + +func applyMifaPayEndpointConfig(cfg MifaPayConfig, endpoint MifaPayEndpointConfig) MifaPayConfig { + // 环境块只在字段真实配置时覆盖扁平字段;这样旧的 flat 配置仍能启动,新配置也能按 test/prod 切换。 + if value := strings.TrimSpace(endpoint.MerAccount); value != "" { + cfg.MerAccount = value + } + if value := strings.TrimSpace(endpoint.MerNo); value != "" { + cfg.MerNo = value + } + if value := strings.TrimSpace(endpoint.MerchantPublicKey); value != "" { + cfg.MerchantPublicKey = value + } + if value := strings.TrimSpace(endpoint.PrivateKey); value != "" { + cfg.PrivateKey = value + } + if value := strings.TrimSpace(endpoint.PlatformPublicKey); value != "" { + cfg.PlatformPublicKey = value + } + if value := strings.TrimSpace(endpoint.APIBaseURL); value != "" { + cfg.APIBaseURL = value + } + if endpoint.HTTPTimeout > 0 { + cfg.HTTPTimeout = endpoint.HTTPTimeout + } + return cfg +} + func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) { defaults := defaultRocketMQConfig() cfg.NameServers = normalizeStringSlice(cfg.NameServers) diff --git a/services/wallet-service/internal/config/config_test.go b/services/wallet-service/internal/config/config_test.go index e54e2657..58acce7b 100644 --- a/services/wallet-service/internal/config/config_test.go +++ b/services/wallet-service/internal/config/config_test.go @@ -48,20 +48,84 @@ func TestLoadTencentExampleSplitsRealtimeOutboxAndRaisesThroughput(t *testing.T) } func TestLoadMifaPayMerchantConfig(t *testing.T) { - for _, path := range []string{ - "../../configs/config.yaml", - "../../configs/config.docker.yaml", - "../../configs/config.tencent.example.yaml", - } { - cfg, err := Load(path) + cases := []struct { + path string + env string + merNo string + merAccount string + wantKey bool + }{ + { + path: "../../configs/config.yaml", + env: "test", + merNo: "10001925", + merAccount: "d00f5fc97a114a0db291eceaf9841739", + wantKey: true, + }, + { + path: "../../configs/config.docker.yaml", + merNo: "10001910", + merAccount: "6000055d34f445418124ab7c0beb81a6", + }, + { + path: "../../configs/config.tencent.example.yaml", + merNo: "10001910", + merAccount: "6000055d34f445418124ab7c0beb81a6", + }, + } + for _, tc := range cases { + cfg, err := Load(tc.path) if err != nil { - t.Fatalf("Load %s failed: %v", path, err) + t.Fatalf("Load %s failed: %v", tc.path, err) } - if cfg.MifaPay.MerNo != "10001910" || cfg.MifaPay.MerAccount != "6000055d34f445418124ab7c0beb81a6" { - t.Fatalf("mifapay merchant identity mismatch in %s: %+v", path, cfg.MifaPay) + if cfg.MifaPay.Environment != tc.env || cfg.MifaPay.MerNo != tc.merNo || cfg.MifaPay.MerAccount != tc.merAccount { + t.Fatalf("mifapay merchant identity mismatch in %s: env=%q mer_no=%q mer_account=%q", tc.path, cfg.MifaPay.Environment, cfg.MifaPay.MerNo, cfg.MifaPay.MerAccount) } - assertRSAPublicKey(t, path+" merchant_public_key", cfg.MifaPay.MerchantPublicKey) - assertRSAPublicKey(t, path+" platform_public_key", cfg.MifaPay.PlatformPublicKey) + if tc.wantKey { + assertRSAPrivateKey(t, tc.path+" private_key", cfg.MifaPay.PrivateKey) + } + if strings.TrimSpace(cfg.MifaPay.MerchantPublicKey) != "" { + assertRSAPublicKey(t, tc.path+" merchant_public_key", cfg.MifaPay.MerchantPublicKey) + // MiFaPay 只保存商户公钥来验请求签名;本地配置同时持有私钥时,提前校验密钥对避免运行后才出现 Signature error。 + if tc.wantKey { + assertRSAPrivateKeyMatchesPublicKey(t, tc.path+" mifapay keypair", cfg.MifaPay.PrivateKey, cfg.MifaPay.MerchantPublicKey) + } + } + assertRSAPublicKey(t, tc.path+" platform_public_key", cfg.MifaPay.PlatformPublicKey) + } +} + +func TestLoadMifaPayTestEndpointKeyPair(t *testing.T) { + cfg, err := Load("../../configs/config.yaml") + if err != nil { + t.Fatalf("Load local config failed: %v", err) + } + testCfg := normalizeMifaPayConfig(MifaPayConfig{ + Environment: "test", + EnvironmentTest: cfg.MifaPay.EnvironmentTest, + }) + if testCfg.Environment != "test" || testCfg.MerNo != "10001925" || testCfg.MerAccount != "d00f5fc97a114a0db291eceaf9841739" { + t.Fatalf("mifapay test endpoint identity mismatch: %+v", testCfg) + } + assertRSAPrivateKey(t, "mifapay test private_key", testCfg.PrivateKey) + assertRSAPublicKey(t, "mifapay test merchant_public_key", testCfg.MerchantPublicKey) + // test 环境用于本地联调;请求签名只用私钥,merchant_public_key 作为后台上传值必须和私钥保持一致。 + assertRSAPrivateKeyMatchesPublicKey(t, "mifapay test keypair", testCfg.PrivateKey, testCfg.MerchantPublicKey) +} + +func assertRSAPrivateKey(t *testing.T, name string, value string) { + t.Helper() + clean := strings.NewReplacer("\n", "", "\r", "", " ", "").Replace(strings.TrimSpace(value)) + raw, err := base64.StdEncoding.DecodeString(clean) + if err != nil { + t.Fatalf("%s should be base64 DER private key: %v", name, err) + } + key, err := x509.ParsePKCS8PrivateKey(raw) + if err != nil { + t.Fatalf("%s should parse as PKCS8 private key: %v", name, err) + } + if _, ok := key.(*rsa.PrivateKey); !ok { + t.Fatalf("%s should be RSA private key, got %T", name, key) } } @@ -80,3 +144,36 @@ func assertRSAPublicKey(t *testing.T, name string, value string) { t.Fatalf("%s should be RSA public key, got %T", name, key) } } + +func assertRSAPrivateKeyMatchesPublicKey(t *testing.T, name string, privateValue string, publicValue string) { + t.Helper() + privateClean := strings.NewReplacer("\n", "", "\r", "", " ", "").Replace(strings.TrimSpace(privateValue)) + privateRaw, err := base64.StdEncoding.DecodeString(privateClean) + if err != nil { + t.Fatalf("%s private key should be base64 DER: %v", name, err) + } + privateParsed, err := x509.ParsePKCS8PrivateKey(privateRaw) + if err != nil { + t.Fatalf("%s private key should parse as PKCS8: %v", name, err) + } + privateKey, ok := privateParsed.(*rsa.PrivateKey) + if !ok { + t.Fatalf("%s private key should be RSA, got %T", name, privateParsed) + } + publicClean := strings.NewReplacer("\n", "", "\r", "", " ", "").Replace(strings.TrimSpace(publicValue)) + publicRaw, err := base64.StdEncoding.DecodeString(publicClean) + if err != nil { + t.Fatalf("%s public key should be base64 DER: %v", name, err) + } + publicParsed, err := x509.ParsePKIXPublicKey(publicRaw) + if err != nil { + t.Fatalf("%s public key should parse as PKIX: %v", name, err) + } + publicKey, ok := publicParsed.(*rsa.PublicKey) + if !ok { + t.Fatalf("%s public key should be RSA, got %T", name, publicParsed) + } + if privateKey.N.Cmp(publicKey.N) != 0 || privateKey.E != publicKey.E { + t.Fatalf("%s private key does not match merchant public key", name) + } +} diff --git a/services/wallet-service/internal/domain/ledger/ledger.go b/services/wallet-service/internal/domain/ledger/ledger.go index 9709bcf5..890aa6fc 100644 --- a/services/wallet-service/internal/domain/ledger/ledger.go +++ b/services/wallet-service/internal/domain/ledger/ledger.go @@ -720,6 +720,9 @@ type CreateExternalRechargeOrderCommand struct { NotifyURL string ClientIP string Language string + PayerName string + PayerAccount string + PayerEmail string } type SubmitExternalRechargeTxCommand struct { diff --git a/services/wallet-service/internal/service/wallet/external_recharge.go b/services/wallet-service/internal/service/wallet/external_recharge.go index 380afff0..bb50619c 100644 --- a/services/wallet-service/internal/service/wallet/external_recharge.go +++ b/services/wallet-service/internal/service/wallet/external_recharge.go @@ -3,6 +3,7 @@ package wallet import ( "context" "encoding/json" + "errors" "math" "strconv" "strings" @@ -36,6 +37,9 @@ type MifaPayCreateOrderRequest struct { ClientIP string Language string ProviderAmountMinor int64 + PayerName string + PayerAccount string + PayerEmail string } type MifaPayCreateOrderResponse struct { @@ -212,14 +216,43 @@ func (s *Service) createMifaPayRechargeOrder(ctx context.Context, command ledger ClientIP: command.ClientIP, Language: command.Language, ProviderAmountMinor: providerAmountMinor, + PayerName: command.PayerName, + PayerAccount: command.PayerAccount, + PayerEmail: command.PayerEmail, }) if err != nil { - _, _ = s.repository.MarkExternalRechargeOrderFailed(ctx, command.AppCode, order.OrderID, err.Error(), "") - return ledger.ExternalRechargeOrder{}, err + _, _ = s.repository.MarkExternalRechargeOrderFailed(ctx, command.AppCode, order.OrderID, err.Error(), mifaPayProviderPayloadJSON(err)) + return ledger.ExternalRechargeOrder{}, normalizeMifaPayOrderError(err) } return s.repository.MarkExternalRechargeOrderRedirected(ctx, command.AppCode, order.OrderID, resp.ProviderOrderID, resp.PayURL, resp.RawJSON) } +func normalizeMifaPayOrderError(err error) error { + if err == nil { + return nil + } + if _, ok := xerr.As(err); ok { + return err + } + // MiFaPay HTTP、JSON、RSA 验签和响应结构错误都是外部支付依赖不可用;不能把普通 Go error 透到 gRPC 边界, + // 否则 gateway 只能降级成 INTERNAL_ERROR,前端拿不到“上游支付失败”的可重试语义。 + return xerr.New(xerr.Unavailable, "mifapay order upstream response is invalid") +} + +type mifaPayProviderPayloadError interface { + ProviderPayloadJSON() string +} + +func mifaPayProviderPayloadJSON(err error) string { + var payloadErr mifaPayProviderPayloadError + if errors.As(err, &payloadErr) { + // MiFaPay 网关拒单的原始响应是和支付订单排障强相关的三方事实,落库后可直接给支付技术支持按 code/msg 查链路。 + // 普通网络错误、JSON 解析错误或本地 RSA 错误没有三方响应体,这里保持空值,避免写入误导性的本地错误文本。 + return strings.TrimSpace(payloadErr.ProviderPayloadJSON()) + } + return "" +} + // SubmitH5RechargeTx 把用户提交的 tx_hash 交给 TRON 适配器校验,通过后立即入账。 func (s *Service) SubmitH5RechargeTx(ctx context.Context, command ledger.SubmitExternalRechargeTxCommand) (ledger.ExternalRechargeOrder, error) { command.AppCode = appcode.Normalize(command.AppCode) @@ -309,13 +342,67 @@ func normalizeCreateExternalRechargeOrderCommand(command ledger.CreateExternalRe command.ReturnURL = strings.TrimSpace(command.ReturnURL) command.NotifyURL = strings.TrimSpace(command.NotifyURL) command.ClientIP = strings.TrimSpace(command.ClientIP) - command.Language = strings.TrimSpace(command.Language) - if command.Language == "" { - command.Language = "en" - } + command.Language = normalizeMifaPayLanguage(command.Language) + command.PayerName = strings.TrimSpace(command.PayerName) + command.PayerAccount = strings.TrimSpace(command.PayerAccount) + command.PayerEmail = strings.TrimSpace(command.PayerEmail) return command } +func normalizeMifaPayLanguage(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "en" + } + if comma := strings.IndexByte(value, ','); comma >= 0 { + value = value[:comma] + } + if semicolon := strings.IndexByte(value, ';'); semicolon >= 0 { + value = value[:semicolon] + } + value = strings.TrimSpace(value) + if !validMifaPayLanguageTag(value) { + return "en" + } + // wallet-service 是 MiFaPay 的最后一道协议边界;即使未来有非 gateway 调用方传入浏览器语言列表, + // 这里也只向三方支付发送单个 BCP47 风格标签,避免订单落库后被 MiFaPay 参数格式校验拒掉。 + return value +} + +func validMifaPayLanguageTag(value string) bool { + if value == "" { + return false + } + parts := strings.Split(value, "-") + if len(parts) == 0 || len(parts) > 4 || len(parts[0]) < 2 || len(parts[0]) > 3 || !allASCIILetters(parts[0]) { + return false + } + for _, part := range parts[1:] { + if len(part) < 2 || len(part) > 8 || !allASCIILettersOrDigits(part) { + return false + } + } + return true +} + +func allASCIILetters(value string) bool { + for _, char := range value { + if (char < 'A' || char > 'Z') && (char < 'a' || char > 'z') { + return false + } + } + return value != "" +} + +func allASCIILettersOrDigits(value string) bool { + for _, char := range value { + if (char < 'A' || char > 'Z') && (char < 'a' || char > 'z') && (char < '0' || char > '9') { + return false + } + } + return value != "" +} + func validateCreateExternalRechargeOrderCommand(command ledger.CreateExternalRechargeOrderCommand) error { if command.CommandID == "" || command.TargetUserID <= 0 || command.TargetRegionID <= 0 || command.ProductID <= 0 { return xerr.New(xerr.InvalidArgument, "external recharge command is incomplete") diff --git a/services/wallet-service/internal/service/wallet/service_test.go b/services/wallet-service/internal/service/wallet/service_test.go index 132ad8a8..fc2bc9f2 100644 --- a/services/wallet-service/internal/service/wallet/service_test.go +++ b/services/wallet-service/internal/service/wallet/service_test.go @@ -2226,6 +2226,52 @@ func TestH5MifaPayOrderRejectsPaymentMethodFromAnotherCountry(t *testing.T) { } } +// TestH5MifaPayOrderRejectsUpstreamFailureAsUnavailable 锁定 MiFaPay 网关拒单的边界:本地订单要落失败便于排障, +// 对外错误必须保持上游依赖失败语义,不能伪装成商品、幂等或状态冲突。 +func TestH5MifaPayOrderRejectsUpstreamFailureAsUnavailable(t *testing.T) { + repository := mysqltest.NewRepository(t) + svc := walletservice.New(repository) + mifaPay := &fakeMifaPayClient{err: fakeMifaPayProviderPayloadError{ + cause: xerr.New(xerr.Unavailable, "mifapay order rejected: 100999: System exception"), + payload: `{"code":"100999","msg":"System exception"}`, + }} + svc.SetMifaPayClient(mifaPay) + svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{MifaPayNotifyURL: "https://api.example.com/api/v1/payment/mifapay/notify"}) + product := repository.CreateH5RechargeProduct(ledger.RechargeAudienceNormal, 7250, 1_500_000, 120000) + saCardMethodID := repository.ThirdPartyPaymentMethodID("SA", "Card", "MADA") + + _, err := svc.CreateH5RechargeOrder(context.Background(), ledger.CreateExternalRechargeOrderCommand{ + AppCode: appcode.Default, + CommandID: "cmd-h5-mifapay-system-error", + TargetUserID: 42501, + TargetRegionID: 7250, + TargetCountryCode: "SA", + AudienceType: ledger.RechargeAudienceNormal, + ProductID: product.ProductID, + ProviderCode: ledger.PaymentProviderMifaPay, + PaymentMethodID: saCardMethodID, + Language: "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6", + PayerName: "normal55", + PayerAccount: "163055", + PayerEmail: "163055@lalu.com", + }) + if !xerr.IsCode(err, xerr.Unavailable) { + t.Fatalf("expected UNAVAILABLE for MiFaPay gateway rejection, got %v", err) + } + if mifaPay.createReq.PayerName != "normal55" || mifaPay.createReq.PayerAccount != "163055" || mifaPay.createReq.PayerEmail != "163055@lalu.com" { + t.Fatalf("mifapay payer snapshot mismatch: %+v", mifaPay.createReq) + } + if mifaPay.createReq.Language != "zh-CN" { + t.Fatalf("mifapay language should be normalized before upstream call: %+v", mifaPay.createReq) + } + if got := repository.CountRows("external_recharge_orders", "command_id = ? AND status = ? AND failure_reason LIKE ? AND JSON_UNQUOTE(JSON_EXTRACT(provider_payload, '$.code')) = ?", "cmd-h5-mifapay-system-error", ledger.ExternalRechargeStatusFailed, "UNAVAILABLE:%System exception%", "100999"); got != 1 { + t.Fatalf("mifapay upstream rejection should persist one failed external order, got %d", got) + } + if got := repository.CountRows("wallet_transactions", "command_id = ?", "cmd-h5-mifapay-system-error"); got != 0 { + t.Fatalf("failed mifapay order must not write wallet transaction, got %d", got) + } +} + // TestH5USDTRechargeCreditsNormalUserCoinWallet 验证 H5 普通用户 USDT 充值只进入普通 COIN 钱包,并写普通充值记录。 func TestH5USDTRechargeCreditsNormalUserCoinWallet(t *testing.T) { repository := mysqltest.NewRepository(t) @@ -4844,6 +4890,23 @@ type fakeMifaPayClient struct { err error } +type fakeMifaPayProviderPayloadError struct { + cause error + payload string +} + +func (e fakeMifaPayProviderPayloadError) Error() string { + return e.cause.Error() +} + +func (e fakeMifaPayProviderPayloadError) Unwrap() error { + return e.cause +} + +func (e fakeMifaPayProviderPayloadError) ProviderPayloadJSON() string { + return e.payload +} + func (f *fakeMifaPayClient) CreateOrder(_ context.Context, req walletservice.MifaPayCreateOrderRequest) (walletservice.MifaPayCreateOrderResponse, error) { f.createReq = req if f.err != nil { diff --git a/services/wallet-service/internal/storage/mysql/third_party_payment_repository.go b/services/wallet-service/internal/storage/mysql/third_party_payment_repository.go index ec006759..a6a3d68f 100644 --- a/services/wallet-service/internal/storage/mysql/third_party_payment_repository.go +++ b/services/wallet-service/internal/storage/mysql/third_party_payment_repository.go @@ -32,6 +32,64 @@ type seedPaymentMethod struct { SortOrder int } +// defaultMifaPayLogoURL 按 MiFaPay 的国家、payWay、payType 精确回填 COS logo;key 全部归一化,兼容存量库里的大小写差异。 +func defaultMifaPayLogoURL(countryCode string, payWay string, payType string) string { + logos := map[string]string{ + "AE|card|creditcard": "https://media.haiyihy.com/admin/payment/mifapay/logos/ae/card-creditcard.png", + "BD|ewallet|nagad": "https://media.haiyihy.com/admin/payment/mifapay/logos/bd/ewallet-nagad.png", + "BD|ewallet|bkash": "https://media.haiyihy.com/admin/payment/mifapay/logos/bd/ewallet-bkash.png", + "BH|card|creditcard": "https://media.haiyihy.com/admin/payment/mifapay/logos/bh/card-creditcard.png", + "EG|card|creditcard": "https://media.haiyihy.com/admin/payment/mifapay/logos/eg/card-creditcard.png", + "EG|ewallet|fawrypay": "https://media.haiyihy.com/admin/payment/mifapay/logos/eg/ewallet-fawrypay.png", + "EG|ewallet|meeza": "https://media.haiyihy.com/admin/payment/mifapay/logos/eg/ewallet-meeza.png", + "EG|ewallet|opay": "https://media.haiyihy.com/admin/payment/mifapay/logos/eg/ewallet-opay.png", + "EG|ewallet|orange": "https://media.haiyihy.com/admin/payment/mifapay/logos/eg/ewallet-orange.png", + "EG|ewallet|vodafone": "https://media.haiyihy.com/admin/payment/mifapay/logos/eg/ewallet-vodafone.png", + "GLOBAL|banktransfer|unionpay": "https://media.haiyihy.com/admin/payment/mifapay/logos/global/banktransfer-unionpay.png", + "GLOBAL|card|creditcard": "https://media.haiyihy.com/admin/payment/mifapay/logos/global/card-creditcard.png", + "GLOBAL|ewallet|applepay": "https://media.haiyihy.com/admin/payment/mifapay/logos/global/ewallet-applepay.png", + "GLOBAL|ewallet|googlepay": "https://media.haiyihy.com/admin/payment/mifapay/logos/global/ewallet-googlepay.png", + "ID|banktransfer|bca": "https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-bca.png", + "ID|banktransfer|bni": "https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-bni.png", + "ID|banktransfer|bri": "https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-bri.png", + "ID|banktransfer|mandiri": "https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-mandiri.png", + "ID|banktransfer|permata": "https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-permata.png", + "ID|banktransfer|qris": "https://media.haiyihy.com/admin/payment/mifapay/logos/id/banktransfer-qris.png", + "ID|ewallet|dana": "https://media.haiyihy.com/admin/payment/mifapay/logos/id/ewallet-dana.png", + "ID|ewallet|linkaja": "https://media.haiyihy.com/admin/payment/mifapay/logos/id/ewallet-linkaja.png", + "ID|ewallet|ovo": "https://media.haiyihy.com/admin/payment/mifapay/logos/id/ewallet-ovo.png", + "ID|ewallet|shopeepay": "https://media.haiyihy.com/admin/payment/mifapay/logos/id/ewallet-shopeepay.png", + "ID|qr|qris": "https://media.haiyihy.com/admin/payment/mifapay/logos/id/qr-qris.png", + "IN|ewallet|bhim": "https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-bhim.png", + "IN|ewallet|googlepay": "https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-googlepay.png", + "IN|ewallet|mobikwik": "https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-mobikwik.png", + "IN|ewallet|paytm": "https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-paytm.png", + "IN|ewallet|phonepe": "https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-phonepe.png", + "IN|ewallet|upi": "https://media.haiyihy.com/admin/payment/mifapay/logos/in/ewallet-upi.png", + "KW|card|kent": "https://media.haiyihy.com/admin/payment/mifapay/logos/kw/card-kent.png", + "KW|card|knet": "https://media.haiyihy.com/admin/payment/mifapay/logos/kw/card-knet.png", + "MY|banktransfer|fpx": "https://media.haiyihy.com/admin/payment/mifapay/logos/my/banktransfer-fpx.png", + "MY|ewallet|tng": "https://media.haiyihy.com/admin/payment/mifapay/logos/my/ewallet-tng.png", + "OM|card|creditcard": "https://media.haiyihy.com/admin/payment/mifapay/logos/om/card-creditcard.png", + "PH|banktransfer|qrph": "https://media.haiyihy.com/admin/payment/mifapay/logos/ph/banktransfer-qrph.png", + "PH|ewallet|gcash": "https://media.haiyihy.com/admin/payment/mifapay/logos/ph/ewallet-gcash.png", + "PH|ewallet|paymaya": "https://media.haiyihy.com/admin/payment/mifapay/logos/ph/ewallet-paymaya.png", + "PH|qr|qrph": "https://media.haiyihy.com/admin/payment/mifapay/logos/ph/qr-qrph.png", + "PK|ewallet|easypaisa": "https://media.haiyihy.com/admin/payment/mifapay/logos/pk/ewallet-easypaisa.png", + "PK|ewallet|jazzcash": "https://media.haiyihy.com/admin/payment/mifapay/logos/pk/ewallet-jazzcash.png", + "QA|card|creditcard": "https://media.haiyihy.com/admin/payment/mifapay/logos/qa/card-creditcard.png", + "SA|card|mada": "https://media.haiyihy.com/admin/payment/mifapay/logos/sa/card-mada.png", + "SA|ewallet|applepay": "https://media.haiyihy.com/admin/payment/mifapay/logos/sa/ewallet-applepay.png", + "SA|ewallet|stcpay": "https://media.haiyihy.com/admin/payment/mifapay/logos/sa/ewallet-stcpay.png", + "TH|banktransfer|promptpay": "https://media.haiyihy.com/admin/payment/mifapay/logos/th/banktransfer-promptpay.png", + "TH|ewallet|truemoney": "https://media.haiyihy.com/admin/payment/mifapay/logos/th/ewallet-truemoney.png", + "VN|banktransfer|vn_banktransfer": "https://media.haiyihy.com/admin/payment/mifapay/logos/vn/banktransfer-vn-banktransfer.png", + "VN|qr|vietqr": "https://media.haiyihy.com/admin/payment/mifapay/logos/vn/qr-vietqr.png", + } + key := strings.ToUpper(strings.TrimSpace(countryCode)) + "|" + strings.ToLower(strings.TrimSpace(payWay)) + "|" + strings.ToLower(strings.TrimSpace(payType)) + return logos[key] +} + func seedThirdPartyPaymentDefaults(ctx context.Context, db *sql.DB) error { nowMS := time.Now().UnixMilli() if _, err := db.ExecContext(ctx, ` @@ -90,16 +148,22 @@ func seedThirdPartyPaymentDefaults(ctx context.Context, db *sql.DB) error { INSERT INTO third_party_payment_methods ( app_code, provider_code, country_code, country_name, currency_code, pay_way, pay_type, method_name, logo_url, status, usd_to_currency_rate, sort_order, created_at_ms, updated_at_ms - ) VALUES ('lalu', 'mifapay', ?, ?, ?, ?, ?, ?, '', 'active', ?, ?, ?, ?) + ) VALUES ('lalu', 'mifapay', ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?) ON DUPLICATE KEY UPDATE country_name = VALUES(country_name), currency_code = VALUES(currency_code), method_name = VALUES(method_name), + logo_url = CASE + WHEN logo_url = '' THEN VALUES(logo_url) + WHEN logo_url LIKE 'https://placehold.co/%' THEN VALUES(logo_url) + WHEN logo_url LIKE '%/image-preview' THEN VALUES(logo_url) + ELSE logo_url + END, sort_order = VALUES(sort_order), usd_to_currency_rate = IF(usd_to_currency_rate <= 0, VALUES(usd_to_currency_rate), usd_to_currency_rate), updated_at_ms = VALUES(updated_at_ms)`, strings.ToUpper(method.CountryCode), method.CountryName, strings.ToUpper(method.CurrencyCode), - method.PayWay, method.PayType, method.MethodName, rate, method.SortOrder, nowMS, nowMS, + method.PayWay, method.PayType, method.MethodName, defaultMifaPayLogoURL(method.CountryCode, method.PayWay, method.PayType), rate, method.SortOrder, nowMS, nowMS, ); err != nil { return err } diff --git a/services/wallet-service/internal/transport/grpc/server.go b/services/wallet-service/internal/transport/grpc/server.go index 2c599b3a..64722077 100644 --- a/services/wallet-service/internal/transport/grpc/server.go +++ b/services/wallet-service/internal/transport/grpc/server.go @@ -466,6 +466,9 @@ func (s *Server) CreateH5RechargeOrder(ctx context.Context, req *walletv1.Create NotifyURL: req.GetNotifyUrl(), ClientIP: req.GetClientIp(), Language: req.GetLanguage(), + PayerName: req.GetPayerName(), + PayerAccount: req.GetPayerAccount(), + PayerEmail: req.GetPayerEmail(), }) if err != nil { return nil, xerr.ToGRPCError(err)