增加相关的支付和新app
This commit is contained in:
parent
152bdf232e
commit
ad54880768
@ -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" +
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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');
|
||||
|
||||
44
scripts/mysql/045_manager_profiles.sql
Normal file
44
scripts/mysql/045_manager_profiles.sql
Normal file
@ -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));
|
||||
66
scripts/mysql/046_mifapay_payment_method_cos_logos.sql
Normal file
66
scripts/mysql/046_mifapay_payment_method_cos_logos.sql
Normal file
@ -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');
|
||||
15
scripts/mysql/047_add_huwaa_app.sql
Normal file
15
scripts/mysql/047_add_huwaa_app.sql
Normal file
@ -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);
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
274
server/admin/internal/modules/payment/exchange_rate.go
Normal file
274
server/admin/internal/modules/payment/exchange_rate.go
Normal file
@ -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)
|
||||
}
|
||||
@ -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"`
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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},
|
||||
|
||||
@ -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';
|
||||
@ -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
|
||||
|
||||
@ -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, ""),
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 != "" {
|
||||
|
||||
108
services/wallet-service/internal/client/mifapay/client_test.go
Normal file
108
services/wallet-service/internal/client/mifapay/client_test.go
Normal file
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -720,6 +720,9 @@ type CreateExternalRechargeOrderCommand struct {
|
||||
NotifyURL string
|
||||
ClientIP string
|
||||
Language string
|
||||
PayerName string
|
||||
PayerAccount string
|
||||
PayerEmail string
|
||||
}
|
||||
|
||||
type SubmitExternalRechargeTxCommand struct {
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user