增加相关的支付和新app

This commit is contained in:
zhx 2026-06-15 19:17:09 +08:00
parent 152bdf232e
commit ad54880768
44 changed files with 2011 additions and 75 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View 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));

View 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');

View 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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -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 组装支付后台 handlerwallet 负责账单和商品事实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"`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,7 +8,8 @@ import (
hostservice "hyapp/services/user-service/internal/service/host"
)
// CreateBDLeader 处理后台创建 BD Leader权限和操作审计在 admin-serveruser-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"))

View File

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

View File

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

View File

@ -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 != "" {

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

View File

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

View File

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

View File

@ -720,6 +720,9 @@ type CreateExternalRechargeOrderCommand struct {
NotifyURL string
ClientIP string
Language string
PayerName string
PayerAccount string
PayerEmail string
}
type SubmitExternalRechargeTxCommand struct {

View File

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

View File

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

View File

@ -32,6 +32,64 @@ type seedPaymentMethod struct {
SortOrder int
}
// defaultMifaPayLogoURL 按 MiFaPay 的国家、payWay、payType 精确回填 COS logokey 全部归一化,兼容存量库里的大小写差异。
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
}

View File

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