This commit is contained in:
zhx 2026-06-22 16:55:22 +08:00
parent 338fc140d8
commit 7c6c3f38e5
35 changed files with 4003 additions and 2026 deletions

View File

@ -50,6 +50,7 @@ run:
./scripts/resolve-compose-container-conflicts.sh
docker compose up -d mysql redis
./scripts/apply-local-mysql-initdb.sh
./scripts/apply-local-rocketmq-topics.sh
docker compose stop $(GO_RUN_SERVICES) >/dev/null 2>&1 || true
./scripts/print-compose-addresses.sh
TZ=UTC go run ./cmd/dev-run -services "$(DEV_RUN_SERVICE_LIST)"
@ -69,6 +70,7 @@ admin-deps:
./scripts/resolve-compose-container-conflicts.sh
docker compose up -d mysql redis
./scripts/apply-local-mysql-initdb.sh
./scripts/apply-local-rocketmq-topics.sh
docker compose up --build -d user-service
./scripts/print-compose-addresses.sh
@ -92,11 +94,13 @@ up:
@if [ -z "$(SERVICE_ID)" ]; then \
docker compose up -d mysql redis; \
./scripts/apply-local-mysql-initdb.sh; \
./scripts/apply-local-rocketmq-topics.sh; \
docker compose up --build -d gateway-service room-service wallet-service user-service activity-service cron-service robot-service game-service notice-service statistics-service; \
elif [ "$(SERVICE_ID)" = "mysql" ] || [ "$(SERVICE_ID)" = "redis" ]; then \
docker compose up -d $(SERVICE_ID); \
else \
./scripts/apply-local-mysql-initdb.sh; \
./scripts/apply-local-rocketmq-topics.sh; \
docker compose up -d $(SERVICE_ID); \
fi
./scripts/print-compose-addresses.sh
@ -114,6 +118,7 @@ rebuild: check-service
./scripts/resolve-compose-container-conflicts.sh
@if [ "$(SERVICE_ID)" != "mysql" ] && [ "$(SERVICE_ID)" != "redis" ]; then \
./scripts/apply-local-mysql-initdb.sh; \
./scripts/apply-local-rocketmq-topics.sh; \
fi
docker compose up -d --build $(SERVICE_ID)
./scripts/print-compose-addresses.sh
@ -174,6 +179,7 @@ up-service: check-service
./scripts/resolve-compose-container-conflicts.sh
@if [ "$(SERVICE_ID)" != "mysql" ] && [ "$(SERVICE_ID)" != "redis" ]; then \
./scripts/apply-local-mysql-initdb.sh; \
./scripts/apply-local-rocketmq-topics.sh; \
fi
docker compose up -d $(SERVICE_ID)
./scripts/print-compose-addresses.sh

File diff suppressed because it is too large Load Diff

View File

@ -1338,6 +1338,19 @@ message HandleMifapayNotifyResponse {
string order_id = 3;
}
message HandleV5PayNotifyRequest {
string request_id = 1;
string app_code = 2;
string raw_json = 3;
map<string, string> fields = 4;
}
message HandleV5PayNotifyResponse {
bool accepted = 1;
string response_text = 2;
string order_id = 3;
}
message DiamondExchangeRule {
string exchange_type = 1;
string from_asset_type = 2;
@ -2004,6 +2017,7 @@ service WalletService {
rpc SubmitH5RechargeTx(SubmitH5RechargeTxRequest) returns (H5RechargeOrderResponse);
rpc GetH5RechargeOrder(GetH5RechargeOrderRequest) returns (H5RechargeOrderResponse);
rpc HandleMifapayNotify(HandleMifapayNotifyRequest) returns (HandleMifapayNotifyResponse);
rpc HandleV5PayNotify(HandleV5PayNotifyRequest) returns (HandleV5PayNotifyResponse);
rpc GetDiamondExchangeConfig(GetDiamondExchangeConfigRequest) returns (GetDiamondExchangeConfigResponse);
rpc ListWalletTransactions(ListWalletTransactionsRequest) returns (ListWalletTransactionsResponse);
rpc ListVipPackages(ListVipPackagesRequest) returns (ListVipPackagesResponse);

View File

@ -260,6 +260,7 @@ const (
WalletService_SubmitH5RechargeTx_FullMethodName = "/hyapp.wallet.v1.WalletService/SubmitH5RechargeTx"
WalletService_GetH5RechargeOrder_FullMethodName = "/hyapp.wallet.v1.WalletService/GetH5RechargeOrder"
WalletService_HandleMifapayNotify_FullMethodName = "/hyapp.wallet.v1.WalletService/HandleMifapayNotify"
WalletService_HandleV5PayNotify_FullMethodName = "/hyapp.wallet.v1.WalletService/HandleV5PayNotify"
WalletService_GetDiamondExchangeConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/GetDiamondExchangeConfig"
WalletService_ListWalletTransactions_FullMethodName = "/hyapp.wallet.v1.WalletService/ListWalletTransactions"
WalletService_ListVipPackages_FullMethodName = "/hyapp.wallet.v1.WalletService/ListVipPackages"
@ -352,6 +353,7 @@ type WalletServiceClient interface {
SubmitH5RechargeTx(ctx context.Context, in *SubmitH5RechargeTxRequest, opts ...grpc.CallOption) (*H5RechargeOrderResponse, error)
GetH5RechargeOrder(ctx context.Context, in *GetH5RechargeOrderRequest, opts ...grpc.CallOption) (*H5RechargeOrderResponse, error)
HandleMifapayNotify(ctx context.Context, in *HandleMifapayNotifyRequest, opts ...grpc.CallOption) (*HandleMifapayNotifyResponse, error)
HandleV5PayNotify(ctx context.Context, in *HandleV5PayNotifyRequest, opts ...grpc.CallOption) (*HandleV5PayNotifyResponse, error)
GetDiamondExchangeConfig(ctx context.Context, in *GetDiamondExchangeConfigRequest, opts ...grpc.CallOption) (*GetDiamondExchangeConfigResponse, error)
ListWalletTransactions(ctx context.Context, in *ListWalletTransactionsRequest, opts ...grpc.CallOption) (*ListWalletTransactionsResponse, error)
ListVipPackages(ctx context.Context, in *ListVipPackagesRequest, opts ...grpc.CallOption) (*ListVipPackagesResponse, error)
@ -977,6 +979,16 @@ func (c *walletServiceClient) HandleMifapayNotify(ctx context.Context, in *Handl
return out, nil
}
func (c *walletServiceClient) HandleV5PayNotify(ctx context.Context, in *HandleV5PayNotifyRequest, opts ...grpc.CallOption) (*HandleV5PayNotifyResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(HandleV5PayNotifyResponse)
err := c.cc.Invoke(ctx, WalletService_HandleV5PayNotify_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) GetDiamondExchangeConfig(ctx context.Context, in *GetDiamondExchangeConfigRequest, opts ...grpc.CallOption) (*GetDiamondExchangeConfigResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetDiamondExchangeConfigResponse)
@ -1292,6 +1304,7 @@ type WalletServiceServer interface {
SubmitH5RechargeTx(context.Context, *SubmitH5RechargeTxRequest) (*H5RechargeOrderResponse, error)
GetH5RechargeOrder(context.Context, *GetH5RechargeOrderRequest) (*H5RechargeOrderResponse, error)
HandleMifapayNotify(context.Context, *HandleMifapayNotifyRequest) (*HandleMifapayNotifyResponse, error)
HandleV5PayNotify(context.Context, *HandleV5PayNotifyRequest) (*HandleV5PayNotifyResponse, error)
GetDiamondExchangeConfig(context.Context, *GetDiamondExchangeConfigRequest) (*GetDiamondExchangeConfigResponse, error)
ListWalletTransactions(context.Context, *ListWalletTransactionsRequest) (*ListWalletTransactionsResponse, error)
ListVipPackages(context.Context, *ListVipPackagesRequest) (*ListVipPackagesResponse, error)
@ -1504,6 +1517,9 @@ func (UnimplementedWalletServiceServer) GetH5RechargeOrder(context.Context, *Get
func (UnimplementedWalletServiceServer) HandleMifapayNotify(context.Context, *HandleMifapayNotifyRequest) (*HandleMifapayNotifyResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method HandleMifapayNotify not implemented")
}
func (UnimplementedWalletServiceServer) HandleV5PayNotify(context.Context, *HandleV5PayNotifyRequest) (*HandleV5PayNotifyResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method HandleV5PayNotify not implemented")
}
func (UnimplementedWalletServiceServer) GetDiamondExchangeConfig(context.Context, *GetDiamondExchangeConfigRequest) (*GetDiamondExchangeConfigResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetDiamondExchangeConfig not implemented")
}
@ -2662,6 +2678,24 @@ func _WalletService_HandleMifapayNotify_Handler(srv interface{}, ctx context.Con
return interceptor(ctx, in, info, handler)
}
func _WalletService_HandleV5PayNotify_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(HandleV5PayNotifyRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).HandleV5PayNotify(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_HandleV5PayNotify_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).HandleV5PayNotify(ctx, req.(*HandleV5PayNotifyRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_GetDiamondExchangeConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetDiamondExchangeConfigRequest)
if err := dec(in); err != nil {
@ -3355,6 +3389,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "HandleMifapayNotify",
Handler: _WalletService_HandleMifapayNotify_Handler,
},
{
MethodName: "HandleV5PayNotify",
Handler: _WalletService_HandleV5PayNotify_Handler,
},
{
MethodName: "GetDiamondExchangeConfig",
Handler: _WalletService_GetDiamondExchangeConfig_Handler,

View File

@ -0,0 +1,12 @@
brokerClusterName = hyapp-local
brokerName = broker-a
brokerId = 0
deleteWhen = 04
fileReservedTime = 48
brokerRole = ASYNC_MASTER
flushDiskType = ASYNC_FLUSH
autoCreateTopicEnable = true
# Local RocketMQ clients run in two places: Docker containers and host go run
# processes. Advertising the Docker host address keeps both paths reachable.
brokerIP1 = host.docker.internal
listenPort = 10911

View File

@ -27,7 +27,7 @@ services:
- "10911:10911"
- "19011:10911"
volumes:
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/rocketmq/broker.conf:/home/rocketmq/broker.conf:ro
- ./deploy/rocketmq/broker.local.conf:/home/rocketmq/broker.conf:ro
- rocketmq-store:/home/rocketmq/store
depends_on:
- rocketmq-namesrv

View File

@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
# Local services start RocketMQ consumers before any business event is produced,
# so relying on producer-side auto topic creation makes a fresh broker fail at
# startup. Keep the fixed owner-outbox topics explicit and idempotent here.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${PROJECT_ROOT}"
"${SCRIPT_DIR}/resolve-compose-container-conflicts.sh" rocketmq-namesrv rocketmq-broker
docker compose up -d rocketmq-namesrv rocketmq-broker >/dev/null
MQADMIN="/home/rocketmq/rocketmq-5.3.1/bin/mqadmin"
NAMESRV="rocketmq-namesrv:9876"
CLUSTER="hyapp-local"
TOPICS=(
"hyapp_wallet_outbox"
"hyapp_wallet_realtime_outbox"
"hyapp_room_outbox"
"hyapp_robot_room_outbox"
"hyapp_room_rocket_launch"
"hyapp_user_outbox"
"hyapp_game_outbox"
)
# The broker process can accept a container start before it has registered with
# NameServer. Wait for the cluster view so updateTopic targets the real broker.
for _ in {1..60}; do
if docker compose exec -T rocketmq-broker "${MQADMIN}" clusterList -n "${NAMESRV}" 2>/dev/null | grep -q "${CLUSTER}"; then
break
fi
sleep 1
done
if ! docker compose exec -T rocketmq-broker "${MQADMIN}" clusterList -n "${NAMESRV}" 2>/dev/null | grep -q "${CLUSTER}"; then
printf 'rocketmq broker did not register in cluster %s before topic init\n' "${CLUSTER}" >&2
exit 1
fi
for topic in "${TOPICS[@]}"; do
printf 'applying rocketmq topic: %s\n' "${topic}"
docker compose exec -T rocketmq-broker "${MQADMIN}" updateTopic -n "${NAMESRV}" -c "${CLUSTER}" -t "${topic}" -r 8 -w 8 >/dev/null
done

View File

@ -1,5 +1,5 @@
UPDATE user_invite_validity_policies
SET eligible_recharge_types = JSON_ARRAY('coin_seller_transfer', 'google_play', 'google', 'mifapay', 'usdt_trc20'),
SET eligible_recharge_types = JSON_ARRAY('coin_seller_transfer', 'google_play', 'google', 'mifapay', 'v5pay', 'usdt_trc20'),
updated_at_ms = UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000
WHERE app_code = 'lalu'
AND region_id = 0

View File

@ -0,0 +1,75 @@
-- Seed V5Pay as another provider under the existing third_party_payment_* model.
-- pay_type stores the V5Pay productType used by wallet-service when creating cashier orders.
SET @now_ms = UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000;
INSERT INTO third_party_payment_channels (
app_code, provider_code, provider_name, status, sort_order, created_at_ms, updated_at_ms
) VALUES ('lalu', 'v5pay', 'V5Pay', 'active', 20, @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
provider_name = VALUES(provider_name),
status = VALUES(status),
sort_order = VALUES(sort_order),
updated_at_ms = VALUES(updated_at_ms);
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', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'UB_ONLINE', 'UnionBank Online', '', 'active', 1, 2010, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'INSTAPAY', 'InstaPay', '', 'active', 1, 2020, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'PESONET', 'PESONet', '', 'active', 1, 2030, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'Ewallet', 'GCASH_ONLINE', 'GCash Online', '', 'active', 1, 2040, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'Ewallet', 'COINS_ONLINE', 'Coins.ph Online', '', 'active', 1, 2050, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'BPI_ONLINE', 'BPI Online', '', 'active', 1, 2060, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'RCBC_ONLINE', 'RCBC Online', '', 'active', 1, 2070, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'UCPB_ONLINE', 'UCPB Online', '', 'active', 1, 2080, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'ROBINSONSBANK_ONLINE', 'Robinsons Bank Online', '', 'active', 1, 2090, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'PSBANK_ONLINE', 'PSBank Online', '', 'active', 1, 2100, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'METROBANK_ONLINE', 'Metrobank Online', '', 'active', 1, 2110, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'LANDBANK_ONLINE', 'Landbank Online', '', 'active', 1, 2120, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'CHINABANK_ONLINE', 'China Bank Online', '', 'active', 1, 2130, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'BDO_ONLINE', 'BDO Online', '', 'active', 1, 2140, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'BOC_ONLINE', 'Bank of Commerce Online', '', 'active', 1, 2150, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'Ewallet', 'GRABPAY_ONLINE', 'GrabPay Online', '', 'active', 1, 2160, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'QR', 'INSTAPAY_QR', 'InstaPay QR', '', 'active', 1, 2170, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'QR', 'ALIPAY_QR', 'Alipay QR', '', 'active', 1, 2180, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'Ewallet', 'ALIPAY_PLUS', 'Alipay+', '', 'active', 1, 2190, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'Billing', 'BILL', 'Bills Payment', '', 'active', 1, 2200, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'QR', 'QRPH_GCASH', 'QRPh GCash', '', 'active', 1, 2210, @now_ms, @now_ms),
('lalu', 'v5pay', 'SA', 'Saudi Arabia', 'SAR', 'Ewallet', 'STCPAY', 'STC Pay', '', 'active', 1, 2310, @now_ms, @now_ms),
('lalu', 'v5pay', 'SA', 'Saudi Arabia', 'SAR', 'Card', 'MADA', 'MADA', '', 'active', 1, 2320, @now_ms, @now_ms),
('lalu', 'v5pay', 'SA', 'Saudi Arabia', 'SAR', 'Ewallet', 'APPLEPAY', 'Apple Pay', '', 'active', 1, 2330, @now_ms, @now_ms),
('lalu', 'v5pay', 'EG', 'Egypt', 'EGP', 'Card', 'CARD', 'Egypt Card', '', 'active', 1, 2410, @now_ms, @now_ms),
('lalu', 'v5pay', 'AE', 'United Arab Emirates', 'AED', 'Card', 'CARD', 'UAE Card', '', 'active', 1, 2510, @now_ms, @now_ms),
('lalu', 'v5pay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'BNI_VA', 'BNI Virtual Account', '', 'active', 1, 2610, @now_ms, @now_ms),
('lalu', 'v5pay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'BRI_VA', 'BRI Virtual Account', '', 'active', 1, 2620, @now_ms, @now_ms),
('lalu', 'v5pay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'CIMB_VA', 'CIMB Virtual Account', '', 'active', 1, 2630, @now_ms, @now_ms),
('lalu', 'v5pay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'MANDIRI_VA', 'Mandiri Virtual Account', '', 'active', 1, 2640, @now_ms, @now_ms),
('lalu', 'v5pay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'MAYBANK_VA', 'Maybank Virtual Account', '', 'active', 1, 2650, @now_ms, @now_ms),
('lalu', 'v5pay', 'ID', 'Indonesia', 'IDR', 'QR', 'NOBU_BANK_QRIS', 'Nobu Bank QRIS', '', 'active', 1, 2660, @now_ms, @now_ms),
('lalu', 'v5pay', 'ID', 'Indonesia', 'IDR', 'Ewallet', 'OVO', 'OVO', '', 'active', 1, 2670, @now_ms, @now_ms),
('lalu', 'v5pay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'PERMATA_VA', 'Permata Virtual Account', '', 'active', 1, 2680, @now_ms, @now_ms),
('lalu', 'v5pay', 'ID', 'Indonesia', 'IDR', 'Ewallet', 'SHOPEEPAY_JUMPAPP', 'ShopeePay Jump App', '', 'active', 1, 2690, @now_ms, @now_ms),
('lalu', 'v5pay', 'ID', 'Indonesia', 'IDR', 'Ewallet', 'GOPAY', 'GoPay', '', 'active', 1, 2700, @now_ms, @now_ms),
('lalu', 'v5pay', 'ID', 'Indonesia', 'IDR', 'Ewallet', 'ALIPAY_CN_ALIPAYPLUS', 'Alipay+ Indonesia', '', 'active', 1, 2710, @now_ms, @now_ms),
('lalu', 'v5pay', 'IN', 'India', 'INR', 'BankTransfer', 'BANK_ONLINE', 'Bank Online', '', 'active', 1, 2810, @now_ms, @now_ms),
('lalu', 'v5pay', 'IN', 'India', 'INR', 'UPI', 'UPI', 'UPI', '', 'active', 1, 2820, @now_ms, @now_ms),
('lalu', 'v5pay', 'IN', 'India', 'INR', 'BankTransfer', 'ONLINE_BANKING', 'Online Banking', '', 'active', 1, 2830, @now_ms, @now_ms),
('lalu', 'v5pay', 'PK', 'Pakistan', 'PKR', 'Ewallet', 'EASYPAISA', 'Easypaisa', '', 'active', 1, 2910, @now_ms, @now_ms),
('lalu', 'v5pay', 'PK', 'Pakistan', 'PKR', 'Ewallet', 'JAZZCASH', 'JazzCash', '', 'active', 1, 2920, @now_ms, @now_ms),
('lalu', 'v5pay', 'PK', 'Pakistan', 'PKR', 'BankTransfer', 'ALFA', 'Bank Alfalah', '', 'active', 1, 2930, @now_ms, @now_ms),
('lalu', 'v5pay', 'PK', 'Pakistan', 'PKR', 'Ewallet', 'HBL_KONNECT', 'HBL Konnect', '', 'active', 1, 2940, @now_ms, @now_ms),
('lalu', 'v5pay', 'PK', 'Pakistan', 'PKR', 'Billing', 'BILL_PK', 'Pakistan Bill Payment', '', 'active', 1, 2950, @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
country_name = VALUES(country_name),
currency_code = VALUES(currency_code),
method_name = VALUES(method_name),
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);
UPDATE invite_reward_policies
SET eligible_recharge_types = JSON_ARRAY('coin_seller_transfer', 'google_play', 'google', 'mifapay', 'v5pay', 'usdt_trc20'),
updated_at_ms = @now_ms
WHERE JSON_CONTAINS(eligible_recharge_types, JSON_QUOTE('mifapay'))
AND NOT JSON_CONTAINS(eligible_recharge_types, JSON_QUOTE('v5pay'));

View File

@ -0,0 +1,2 @@
ALTER TABLE external_recharge_orders
ADD INDEX idx_external_recharge_orders_reconcile (app_code, status, provider_code, updated_at_ms, created_at_ms);

View File

@ -38,10 +38,30 @@ func TestListThirdPartyPaymentChannelsForwardsIncludeDisabledMethods(t *testing.
Status: "active",
UsdToCurrencyRate: "1.00000000",
}},
}, {
AppCode: "lalu",
ProviderCode: "v5pay",
ProviderName: "V5Pay",
Status: "active",
SortOrder: 20,
Methods: []*walletv1.ThirdPartyPaymentMethod{{
MethodId: 2310,
AppCode: "lalu",
ProviderCode: "v5pay",
ProviderName: "V5Pay",
CountryCode: "SA",
CountryName: "Saudi Arabia",
CurrencyCode: "SAR",
PayWay: "Ewallet",
PayType: "STCPAY",
MethodName: "STC Pay",
Status: "active",
UsdToCurrencyRate: "1.00000000",
}},
}},
}}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil))
request := httptest.NewRequest(http.MethodGet, "/admin/payment/third-party-channels?provider_code=mifapay&status=active", nil)
request := httptest.NewRequest(http.MethodGet, "/admin/payment/third-party-channels?status=active", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
@ -51,7 +71,7 @@ func TestListThirdPartyPaymentChannelsForwardsIncludeDisabledMethods(t *testing.
}
if wallet.lastThirdPartyChannels == nil ||
wallet.lastThirdPartyChannels.GetAppCode() != "lalu" ||
wallet.lastThirdPartyChannels.GetProviderCode() != "mifapay" ||
wallet.lastThirdPartyChannels.GetProviderCode() != "" ||
wallet.lastThirdPartyChannels.GetStatus() != "active" ||
!wallet.lastThirdPartyChannels.GetIncludeDisabledMethods() {
t.Fatalf("third party channels request mismatch: %+v", wallet.lastThirdPartyChannels)
@ -62,8 +82,10 @@ func TestListThirdPartyPaymentChannelsForwardsIncludeDisabledMethods(t *testing.
}
items := response.Data["items"].([]any)
channel := items[0].(map[string]any)
v5Channel := items[1].(map[string]any)
methods := channel["methods"].([]any)
if response.Code != 0 || channel["providerCode"] != "mifapay" || len(methods) != 1 || methods[0].(map[string]any)["payType"] != "MADA" {
v5Methods := v5Channel["methods"].([]any)
if response.Code != 0 || channel["providerCode"] != "mifapay" || v5Channel["providerCode"] != "v5pay" || len(methods) != 1 || len(v5Methods) != 1 || methods[0].(map[string]any)["payType"] != "MADA" || v5Methods[0].(map[string]any)["payType"] != "STCPAY" {
t.Fatalf("third party channels response mismatch: %+v", response)
}
}
@ -119,6 +141,14 @@ func TestSyncThirdPartyPaymentRatesUpdatesAllMethodsFromFetchedCurrencyRates(t *
{MethodId: 810, CurrencyCode: "SAR", Status: "active"},
{MethodId: 811, CurrencyCode: "BHD", Status: "disabled"},
},
}, {
AppCode: "lalu",
ProviderCode: "v5pay",
ProviderName: "V5Pay",
Status: "active",
Methods: []*walletv1.ThirdPartyPaymentMethod{
{MethodId: 2310, CurrencyCode: "SAR", Status: "active"},
},
}},
}}
handler := New(wallet, nil, nil)
@ -138,7 +168,7 @@ func TestSyncThirdPartyPaymentRatesUpdatesAllMethodsFromFetchedCurrencyRates(t *
if wallet.lastThirdPartyChannels == nil || !wallet.lastThirdPartyChannels.GetIncludeDisabledMethods() {
t.Fatalf("sync must read active and disabled methods: %+v", wallet.lastThirdPartyChannels)
}
if len(wallet.updateRateRequests) != 2 {
if len(wallet.updateRateRequests) != 3 {
t.Fatalf("sync should update every method with a fetched currency rate: %+v", wallet.updateRateRequests)
}
got := map[int64]string{}
@ -148,14 +178,14 @@ func TestSyncThirdPartyPaymentRatesUpdatesAllMethodsFromFetchedCurrencyRates(t *
}
got[req.GetMethodId()] = req.GetUsdToCurrencyRate()
}
if got[810] != "3.75000000" || got[811] != "0.37600000" {
if got[810] != "3.75000000" || got[811] != "0.37600000" || got[2310] != "3.75000000" {
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 {
if response.Code != 0 || response.Data["updatedCount"].(float64) != 3 {
t.Fatalf("sync response mismatch: %+v", response)
}
}

View File

@ -22,6 +22,7 @@ type WalletClient interface {
SubmitH5RechargeTx(ctx context.Context, req *walletv1.SubmitH5RechargeTxRequest) (*walletv1.H5RechargeOrderResponse, error)
GetH5RechargeOrder(ctx context.Context, req *walletv1.GetH5RechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error)
HandleMifapayNotify(ctx context.Context, req *walletv1.HandleMifapayNotifyRequest) (*walletv1.HandleMifapayNotifyResponse, error)
HandleV5PayNotify(ctx context.Context, req *walletv1.HandleV5PayNotifyRequest) (*walletv1.HandleV5PayNotifyResponse, error)
ConfirmGooglePayment(ctx context.Context, req *walletv1.ConfirmGooglePaymentRequest) (*walletv1.ConfirmGooglePaymentResponse, error)
GetDiamondExchangeConfig(ctx context.Context, req *walletv1.GetDiamondExchangeConfigRequest) (*walletv1.GetDiamondExchangeConfigResponse, error)
ListWalletTransactions(ctx context.Context, req *walletv1.ListWalletTransactionsRequest) (*walletv1.ListWalletTransactionsResponse, error)
@ -111,6 +112,10 @@ func (c *grpcWalletClient) HandleMifapayNotify(ctx context.Context, req *walletv
return c.client.HandleMifapayNotify(ctx, req)
}
func (c *grpcWalletClient) HandleV5PayNotify(ctx context.Context, req *walletv1.HandleV5PayNotifyRequest) (*walletv1.HandleV5PayNotifyResponse, error) {
return c.client.HandleV5PayNotify(ctx, req)
}
func (c *grpcWalletClient) ConfirmGooglePayment(ctx context.Context, req *walletv1.ConfirmGooglePaymentRequest) (*walletv1.ConfirmGooglePaymentResponse, error) {
return c.client.ConfirmGooglePayment(ctx, req)
}

View File

@ -234,6 +234,7 @@ type WalletHandlers struct {
SubmitH5RechargeTx http.HandlerFunc
GetH5RechargeOrder http.HandlerFunc
HandleMifapayNotify http.HandlerFunc
HandleV5PayNotify http.HandlerFunc
ConfirmGooglePayment http.HandlerFunc
GetDiamondExchangeConfig http.HandlerFunc
ListCoinTransactions http.HandlerFunc
@ -567,6 +568,7 @@ func (r routes) registerWalletRoutes() {
r.public("/recharge/h5/orders/{order_id}/tx", http.MethodPost, h.SubmitH5RechargeTx)
r.public("/recharge/h5/orders/{order_id}", http.MethodGet, h.GetH5RechargeOrder)
r.public("/payment/mifapay/notify", http.MethodPost, h.HandleMifapayNotify)
r.public("/payment/v5pay/notify", http.MethodPost, h.HandleV5PayNotify)
r.profile("/wallet/payments/google/confirm", http.MethodPost, h.ConfirmGooglePayment)
r.profile("/wallet/diamond-exchange/config", http.MethodGet, h.GetDiamondExchangeConfig)
r.profile("/wallet/coin-transactions", http.MethodGet, h.ListCoinTransactions)

View File

@ -509,6 +509,8 @@ type fakeWalletClient struct {
h5GetOrderResp *walletv1.H5RechargeOrderResponse
lastMifaPayNotify *walletv1.HandleMifapayNotifyRequest
mifaPayNotifyResp *walletv1.HandleMifapayNotifyResponse
lastV5PayNotify *walletv1.HandleV5PayNotifyRequest
v5PayNotifyResp *walletv1.HandleV5PayNotifyResponse
lastGoogleConfirm *walletv1.ConfirmGooglePaymentRequest
googleConfirmResp *walletv1.ConfirmGooglePaymentResponse
lastDiamondExchange *walletv1.GetDiamondExchangeConfigRequest
@ -1708,6 +1710,17 @@ func (f *fakeWalletClient) HandleMifapayNotify(_ context.Context, req *walletv1.
return &walletv1.HandleMifapayNotifyResponse{Accepted: true, ResponseText: "SUCCESS", OrderId: "h5-order-1"}, nil
}
func (f *fakeWalletClient) HandleV5PayNotify(_ context.Context, req *walletv1.HandleV5PayNotifyRequest) (*walletv1.HandleV5PayNotifyResponse, error) {
f.lastV5PayNotify = req
if f.err != nil {
return nil, f.err
}
if f.v5PayNotifyResp != nil {
return f.v5PayNotifyResp, nil
}
return &walletv1.HandleV5PayNotifyResponse{Accepted: true, ResponseText: "success", OrderId: "h5-order-1"}, nil
}
func (f *fakeWalletClient) ConfirmGooglePayment(_ context.Context, req *walletv1.ConfirmGooglePaymentRequest) (*walletv1.ConfirmGooglePaymentResponse, error) {
f.lastGoogleConfirm = req
if f.err != nil {
@ -2113,6 +2126,32 @@ func TestRoutesWriteUnifiedEnvelopeAndGenerateRequestID(t *testing.T) {
}
}
func TestV5PayNotifyReturnsPlainSuccess(t *testing.T) {
walletClient := &fakeWalletClient{}
handler := NewHandlerWithClients(nil, nil, nil)
handler.SetWalletClient(walletClient)
router := handler.Routes(auth.NewVerifier("secret"))
body := []byte(`{"orderNo":"order-1","productType":"INSTAPAY_QR","amount":"20.00","businessType":"0","fee":"0.00","orderStatus":"2","sign":"stub-sign"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/payment/v5pay/notify", bytes.NewReader(body))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("v5pay notify status mismatch: got %d body=%q", recorder.Code, recorder.Body.String())
}
if strings.TrimSpace(recorder.Body.String()) != "success" {
t.Fatalf("v5pay notify must return plain success, got %q", recorder.Body.String())
}
if contentType := recorder.Header().Get("Content-Type"); !strings.HasPrefix(contentType, "text/plain") {
t.Fatalf("v5pay notify must return text/plain, got %q", contentType)
}
if walletClient.lastV5PayNotify == nil || walletClient.lastV5PayNotify.GetFields()["orderNo"] != "order-1" || walletClient.lastV5PayNotify.GetFields()["amount"] != "20.00" {
t.Fatalf("v5pay notify should forward string fields to wallet-service: %+v", walletClient.lastV5PayNotify)
}
}
func TestRoomCommandsPropagateClientCommandID(t *testing.T) {
tests := []struct {
name string
@ -7309,6 +7348,42 @@ func TestH5RechargeOptionsUsesTokenCountryAndCoinSellerAudience(t *testing.T) {
}
}
func TestH5RechargeContextRejectsMissingIdentityWithoutToken(t *testing.T) {
walletClient := &fakeWalletClient{}
identityClient := &fakeUserIdentityClient{resolveByDisplay: map[string]int64{"163066": 66}}
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{66: {
UserId: 66,
DisplayUserId: "163066",
Username: "normal66",
Status: userv1.UserStatus_USER_STATUS_ACTIVE,
Country: "PH",
RegionId: 7200,
}}}
hostClient := &fakeUserHostClient{roleSummary: &userv1.UserRoleSummary{UserId: 66}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, identityClient, profileClient)
handler.SetWalletClient(walletClient)
handler.SetUserHostClient(hostClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/recharge/h5/context", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if identityClient.lastResolve != nil {
t.Fatalf("missing h5 identity must be rejected before user-service lookup: %+v", identityClient.lastResolve)
}
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
if response.Code != httpkit.CodeInvalidArgument || response.Message != "invalid argument" {
t.Fatalf("missing h5 identity response mismatch: %+v", response)
}
}
func TestH5RechargeOptionsNormalizesSaudiCountryNameToPaymentCountryCode(t *testing.T) {
walletClient := &fakeWalletClient{h5OptionsResp: &walletv1.H5RechargeOptionsResponse{
PaymentMethods: []*walletv1.ThirdPartyPaymentMethod{{

View File

@ -3,12 +3,16 @@ package walletapi
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"hyapp/services/gateway-service/internal/transport/http/useridinput"
@ -18,6 +22,7 @@ const (
h5RechargeAudienceNormal = "normal"
h5RechargeAudienceCoinSeller = "coin_seller"
h5PaymentProviderMifaPay = "mifapay"
h5PaymentProviderV5Pay = "v5pay"
h5PaymentProviderUSDTTRC20 = "usdt_trc20"
)
@ -148,7 +153,7 @@ func (h *Handler) getH5RechargeContext(writer http.ResponseWriter, request *http
})
}
// listH5RechargeOptions 返回已确认账号的商品档位、USDT 和可下单的 MiFaPay 方式。
// listH5RechargeOptions 返回已确认账号的商品档位、USDT 和可下单的本地三方支付方式。
func (h *Handler) listH5RechargeOptions(writer http.ResponseWriter, request *http.Request) {
target, ok := h.resolveH5RechargeTarget(writer, request, request.URL.Query().Get("display_user_id"))
if !ok {
@ -169,7 +174,7 @@ func (h *Handler) listH5RechargeOptions(writer http.ResponseWriter, request *htt
httpkit.WriteOK(writer, request, h5RechargeOptionsFromProto(target, resp))
}
// createH5RechargeOrder 创建 MiFaPay 或 USDT 外部充值订单;目标账号始终由 gateway 重新解析。
// createH5RechargeOrder 创建本地三方支付或 USDT 外部充值订单;目标账号始终由 gateway 重新解析。
func (h *Handler) createH5RechargeOrder(writer http.ResponseWriter, request *http.Request) {
var body h5RechargeOrderRequestBody
if !httpkit.Decode(writer, request, &body) {
@ -300,6 +305,58 @@ func (h *Handler) handleMifapayNotify(writer http.ResponseWriter, request *http.
writeMifaPayNotifyText(writer, http.StatusOK, "SUCCESS")
}
// handleV5PayNotify 按 V5Pay 要求返回小写纯文本 success验签和订单校验都下沉到 wallet-service。
func (h *Handler) handleV5PayNotify(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil {
writeV5PayNotifyText(writer, http.StatusBadGateway, "fail")
return
}
raw, err := io.ReadAll(io.LimitReader(request.Body, 2<<20))
if err != nil {
writeV5PayNotifyText(writer, http.StatusBadRequest, "fail")
return
}
fields := map[string]string{}
if len(raw) > 0 {
var payload map[string]any
if err := json.Unmarshal(raw, &payload); err == nil {
fields = v5PayNotifyFields(payload)
} else if values, formErr := url.ParseQuery(string(raw)); formErr == nil {
for key, candidates := range values {
if len(candidates) > 0 {
fields[key] = strings.TrimSpace(candidates[0])
}
}
} else {
writeV5PayNotifyText(writer, http.StatusBadRequest, "fail")
return
}
}
if len(fields) == 0 {
if err := request.ParseForm(); err != nil {
writeV5PayNotifyText(writer, http.StatusBadRequest, "fail")
return
}
for key, values := range request.Form {
if len(values) > 0 {
fields[key] = strings.TrimSpace(values[0])
}
}
}
resp, err := h.walletClient.HandleV5PayNotify(request.Context(), &walletv1.HandleV5PayNotifyRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
RawJson: strings.TrimSpace(string(raw)),
Fields: fields,
})
if err != nil || !resp.GetAccepted() {
logV5PayNotifyRejected(request, err, resp, fields)
writeV5PayNotifyText(writer, http.StatusBadRequest, "fail")
return
}
writeV5PayNotifyText(writer, http.StatusOK, firstNonEmptyString(resp.GetResponseText(), "success"))
}
func (h *Handler) resolveH5RechargeTarget(writer http.ResponseWriter, request *http.Request, displayUserID string) (h5RechargeTargetContext, bool) {
if h.walletClient == nil || h.userProfileClient == nil || h.userIdentityClient == nil || h.userHostClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
@ -308,6 +365,10 @@ func (h *Handler) resolveH5RechargeTarget(writer http.ResponseWriter, request *h
userID := auth.UserIDFromContext(request.Context())
resolvedByToken := userID > 0
if !resolvedByToken {
if strings.TrimSpace(displayUserID) == "" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return h5RechargeTargetContext{}, false
}
// 无 token 入口的输入是“用户标识”,可能是 active 靓号、当前短号、默认数字 ID 或真实 user_id。
// 所有传给 wallet-service 的目标都必须先在 gateway 统一收敛成内部 user_id避免各业务入口自行解析导致靓号报错。
resolvedUserID, err := useridinput.Resolve(request.Context(), h.userIdentityClient, httpkit.UserMeta(request, ""), displayUserID)
@ -675,6 +736,8 @@ func normalizeH5PaymentProvider(value string, paymentMethodID int64) string {
switch value {
case "usdt", "usdt-trc20", "usdt_trc20", "trc20":
return h5PaymentProviderUSDTTRC20
case "v5", "v5pay", "v5_pay":
return h5PaymentProviderV5Pay
case "mifa", "mifapay", "third_party":
return h5PaymentProviderMifaPay
case "":
@ -685,6 +748,40 @@ func normalizeH5PaymentProvider(value string, paymentMethodID int64) string {
return value
}
func v5PayNotifyFields(payload map[string]any) map[string]string {
fields := make(map[string]string, len(payload))
for key, value := range payload {
switch typed := value.(type) {
case nil:
fields[key] = ""
case string:
fields[key] = strings.TrimSpace(typed)
default:
fields[key] = strings.TrimSpace(fmt.Sprint(typed))
}
}
return fields
}
func logV5PayNotifyRejected(request *http.Request, err error, resp *walletv1.HandleV5PayNotifyResponse, fields map[string]string) {
attrs := []slog.Attr{
slog.String("request_id", httpkit.RequestIDFromContext(request.Context())),
slog.String("app_code", appcode.FromContext(request.Context())),
slog.String("order_no", firstNonEmptyString(fields["orderNo"], fields["order_no"])),
slog.String("product_type", firstNonEmptyString(fields["productType"], fields["product_type"])),
slog.String("order_status", firstNonEmptyString(fields["orderStatus"], fields["order_status"])),
slog.Int("field_count", len(fields)),
}
if resp != nil {
attrs = append(attrs, slog.Bool("accepted", resp.GetAccepted()), slog.String("response_text", resp.GetResponseText()), slog.String("wallet_order_id", resp.GetOrderId()))
}
if err != nil {
attrs = append(attrs, slog.String("error", err.Error()))
}
// V5Pay 回调失败必须有足够排障信息,但不能把完整 payload/sign 写进 gateway 日志,避免泄露签名材料。
logx.Warn(request.Context(), "v5pay_notify_rejected", attrs...)
}
func h5RechargeAuthMode(resolvedByToken bool) string {
if resolvedByToken {
return "token"
@ -715,3 +812,9 @@ func writeMifaPayNotifyText(writer http.ResponseWriter, statusCode int, body str
writer.WriteHeader(statusCode)
_, _ = writer.Write([]byte(body))
}
func writeV5PayNotifyText(writer http.ResponseWriter, statusCode int, body string) {
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
writer.WriteHeader(statusCode)
_, _ = writer.Write([]byte(body))
}

View File

@ -52,6 +52,7 @@ func (h *Handler) WalletHandlers() httproutes.WalletHandlers {
SubmitH5RechargeTx: h.submitH5RechargeTx,
GetH5RechargeOrder: h.getH5RechargeOrder,
HandleMifapayNotify: h.handleMifapayNotify,
HandleV5PayNotify: h.handleV5PayNotify,
ConfirmGooglePayment: h.confirmGooglePayment,
GetDiamondExchangeConfig: h.getDiamondExchangeConfig,
ListCoinTransactions: h.listCoinTransactions,

View File

@ -545,7 +545,7 @@ func (r *Repository) ConsumeRecharge(ctx context.Context, event RechargeEvent) e
switch strings.ToLower(strings.TrimSpace(event.RechargeType)) {
case "google", "google_play":
googleUSD = event.USDMinor
case "mifapay":
case "mifapay", "v5pay":
mifapayUSD = event.USDMinor
case "usdt_trc20":
// USDT-TRC20 是站外 H5 充值渠道;当前大盘只单列 MiFaPay/Google/币商转账,

View File

@ -372,7 +372,7 @@ INSERT INTO user_invite_validity_policies (
eligible_recharge_types, effective_from_ms, effective_to_ms, created_by_user_id, created_at_ms, updated_at_ms
) VALUES (
'lalu', 0, 'invite-valid-default-v1', 'active', 80000,
JSON_ARRAY('coin_seller_transfer', 'google_play', 'google', 'mifapay', 'usdt_trc20'), 0, NULL, NULL, 0, 0
JSON_ARRAY('coin_seller_transfer', 'google_play', 'google', 'mifapay', 'v5pay', 'usdt_trc20'), 0, NULL, NULL, 0, 0
) ON DUPLICATE KEY UPDATE
status = VALUES(status),
required_recharge_coin_amount = VALUES(required_recharge_coin_amount),

View File

@ -802,6 +802,8 @@ func normalizeInviteRechargeType(value string) string {
return invitedomain.RechargeTypeGooglePlay
case "mifapay", "mifa_pay":
return invitedomain.RechargeTypeMifaPay
case "v5pay", "v5_pay":
return invitedomain.RechargeTypeV5Pay
case "usdt", "usdt_trc20", "trc20":
return invitedomain.RechargeTypeUSDTTRC20
default:

View File

@ -29,6 +29,8 @@ const (
RechargeTypeGoogle = "google"
// RechargeTypeMifaPay is the third-party checkout provider value for MiFaPay.
RechargeTypeMifaPay = "mifapay"
// RechargeTypeV5Pay is the third-party checkout provider value for V5Pay.
RechargeTypeV5Pay = "v5pay"
// RechargeTypeUSDTTRC20 is the third-party checkout provider value for USDT TRC20.
RechargeTypeUSDTTRC20 = "usdt_trc20"
)

View File

@ -550,6 +550,7 @@ func defaultEligibleRechargeTypes() []string {
invitedomain.RechargeTypeGooglePlay,
invitedomain.RechargeTypeGoogle,
invitedomain.RechargeTypeMifaPay,
invitedomain.RechargeTypeV5Pay,
invitedomain.RechargeTypeUSDTTRC20,
}
}
@ -562,6 +563,8 @@ func normalizeRechargeType(value string) string {
return invitedomain.RechargeTypeGooglePlay
case "mifapay", "mifa_pay":
return invitedomain.RechargeTypeMifaPay
case "v5pay", "v5_pay":
return invitedomain.RechargeTypeV5Pay
case "usdt", "usdt_trc20", "trc20":
return invitedomain.RechargeTypeUSDTTRC20
default:

View File

@ -72,6 +72,31 @@ mifapay:
api_base_url: "https://platform.xqdmipay.com"
http_timeout: "10s"
v5pay:
enabled: true
environment: "test" # 可选值test/prod切换环境时必须保证后台商户公钥和本地私钥是一对。
environment_test:
app_key: "GA2064226629847150667"
merchant_no: "M2064226507994230883"
secret_key: "mTFoZVczrZX8jCYA"
api_base_url: "https://api-uat.v5pay.com"
mer_account: ""
mer_no: ""
merchant_public_key: "-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6yjOFNyhXnKZAzgDYgjj 4K/qfsoRUSFQPd4mL2HzluV9+e30sXUG9Up0kTI4Enu5Yi8wlwAvhe8kN/a8rkn2 SEWYOrK0HuTZDUPgVuM6AAbtb2FZlKJfU88+v8NnH2MCgCAJAEGwHPN1t08faUlN jHDFTcuxRLakT/RyrLbXk9NOQr3h/aC4AH7AAWQfsD1WzPqaVbE59mFEo1btqe0O 6w9Ysf1e3y4q7pdECfd4+hkTrKahU95KO66Xyisogbgc3SRZXDqzzN0cDo4NVNZw F0kpiQRM4179t1OLBGmLqMM37V+ONRxGxhcGSJ62REAstNRXc/n34AVCSpA92gCB awIDAQAB -----END PUBLIC KEY-----"
private_key: "-----BEGIN PRIVATE KEY----- MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDrKM4U3KFecpkD OANiCOPgr+p+yhFRIVA93iYvYfOW5X357fSxdQb1SnSRMjgSe7liLzCXAC+F7yQ3 9ryuSfZIRZg6srQe5NkNQ+BW4zoABu1vYVmUol9Tzz6/w2cfYwKAIAkAQbAc83W3 Tx9pSU2McMVNy7FEtqRP9HKstteT005CveH9oLgAfsABZB+wPVbM+ppVsTn2YUSj Vu2p7Q7rD1ix/V7fLirul0QJ93j6GROspqFT3ko7rpfKKyiBuBzdJFlcOrPM3RwO jg1U1nAXSSmJBEzjXv23U4sEaYuowzftX441HEbGFwZInrZEQCy01Fdz+ffgBUJK kD3aAIFrAgMBAAECggEAAV1VwM8Jv3uojqYf3IBGGwyT4eBE56fq7sj5hyapQrPN nnDDWqPd6MUU7+Jb0YoKDw5/pWJFyrmsbjsbNWNOSUITul3e5+gwQdomFNFk8BFN bGsjGXBnX2jUkBYcSy7VM2G8NTx5JXN2OE+YISstoFLnmjMRGF+ujJygoIbysYCY 2rVjIda/VSkPwHvB55HjkeWpx+IdH2guDIA6MH3DgyAguTenBzgCxTBUhbTpIfE5 q2GBbTbJu7+qP8fzoVdWMb3vfpO7A73P+gTNul4I7d/+3mS+WSQSn+xaXKtcBmcS /br82rEnPVo0Ujsz8tJqLowoChFmPDnrZXFvurNKQQKBgQD4z3z5n3j6wdgf81fj 9nbHthRLEXjSygXyUm2KxOR6YKqBdj5FUfkHFy/8TKGv+xfdx46IMApHF4Z/G3Eb 5KCN0mCmnPOqHuzGxKgHmoOHzwCDUkn/c+vDAleh2AmYURhtR02uyoLRh5C8nmbo 47ZW1dcgeEZDiouiv4jHn42PKwKBgQDx9FYVp0X7pAgW50SVkry0QcHW8Vvfyc4w 8M0wDyMZoVJLywJzo4etB7xKvnUPcIbSzXu4QpTWsD2jhd2mxTvz09CqZEAcJIe2 ictESITKUgcfQQTK2qtTe09TC5Pr9plcc1QNz27CP8Ye1BJjeTNVMIysKab6y7YO o3FMzf22wQKBgH5tSA67ZsjcOMq7ZtyR/vcp4RPn8+H//5eLJNhPhd1dQyZ3g9rL dWOR97MW6U23o//wh89Rwdcv8AqkUgwfVynZTX074WB/SEcT5GHAtLcMizEvXiRs rc6wYQD++pkoE+ZrReDcTC7V2MjPIHGUS7HCBQcdtm5ZduyisIjm3RJzAoGBAK6o r9/PsXA29/P4nITQuVHySghskOLaXfLsUWKI+tCMqMi30pxBhzZ76AuYsw4jL+BK ny3jOmhELyq0/s1dgarA09e8qJ7oK+O0UryHEPyv/B4upFP/SD/qOtf6+pwSlqCr TBjwDcdBnDYy5tdR7aFoe5PoOXa/yTuF9sr3xFfBAoGAZG3xIHOWJ6ewMmyIhkt0 2jzCFsE8iSAD+epr4rUcewgtZTZa24qS2PtholY8z2lQOZxPVLENk6HMEWhKi45i ccS10Yt8CI1Ndrf8oO8wxXacKzWFkK1Xz5OYBzzuTGEksyhTugnnPu0r8Ccx2v9V cmNQ0NcQqHsMoZHgRgc11bA= -----END PRIVATE KEY-----"
platform_public_key: "-----BEGIN PUBLIC KEY-----MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkopxwO+kdlf0Wx6LWD+wQVWa3LWmpIS0N0yGxEdIDLRmavZ9Yg/KYHw4pd8uJPwspj/2kOFLrCc9qxW+ennaAtDgxX+eh5eh1HxU2M7whZdEpH3oli92Xz4kQbNTwr7lnLnMzsSKtBsoM2Yvq4OoYxZADKHdoPG0FNTuT1TcWncL+6IsVnQqlzFUzv09W5C0sTWuINfPfREyhwoPOnzQw9eAJqZ7ET+ewNS7LKRXg+tmByffq+Xb9LGNrTe6bDgSwxf4suUGa/AKPupIncdW80sxOIYE6ji7yAXSakv3JUobPh5gZQH9+P6W0tlO16/ebdgGhp2ERfwr2f4QlWJpDQIDAQAB-----END PUBLIC KEY-----"
http_timeout: "10s"
environment_prod:
app_key: ""
merchant_no: ""
secret_key: ""
api_base_url: "https://api.v5pay.com"
mer_account: ""
mer_no: ""
merchant_public_key: ""
private_key: ""
platform_public_key: ""
http_timeout: "10s"
tron_grid:
enabled: true
@ -86,6 +111,13 @@ external_recharge:
usdt_trc20_address: "cccccccccccc"
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"
v5pay_notify_url: "http://127.0.0.1:13000/api/v1/payment/v5pay/notify"
v5pay_return_url: "http://127.0.0.1:7001/recharge/index.html"
external_recharge_reconcile_worker:
enabled: true
app_code: "lalu"
poll_interval: "30s"
batch_size: 50
outbox_worker:
enabled: true
poll_interval: "1s"

View File

@ -601,6 +601,7 @@ CREATE TABLE IF NOT EXISTS external_recharge_orders (
UNIQUE KEY uk_external_recharge_orders_command (app_code, command_id),
KEY idx_external_recharge_orders_user_time (app_code, target_user_id, created_at_ms),
KEY idx_external_recharge_orders_provider_order (app_code, provider_code, provider_order_id),
KEY idx_external_recharge_orders_reconcile (app_code, status, provider_code, updated_at_ms, created_at_ms),
KEY idx_external_recharge_orders_tx (app_code, provider_code, tx_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='H5 外部充值订单表';
@ -652,6 +653,60 @@ 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);
INSERT IGNORE INTO third_party_payment_channels (app_code, provider_code, provider_name, status, sort_order, created_at_ms, updated_at_ms)
VALUES ('lalu', 'v5pay', 'V5Pay', 'active', 20, @now_ms, @now_ms);
-- V5Pay 的 pay_type 直接保存 productType运行时还会按 V5Pay productTypes 接口过滤当前 appKey 真实可用的产品。
INSERT IGNORE 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', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'UB_ONLINE', 'UnionBank Online', '', 'active', 1, 2010, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'INSTAPAY', 'InstaPay', '', 'active', 1, 2020, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'PESONET', 'PESONet', '', 'active', 1, 2030, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'Ewallet', 'GCASH_ONLINE', 'GCash Online', '', 'active', 1, 2040, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'Ewallet', 'COINS_ONLINE', 'Coins.ph Online', '', 'active', 1, 2050, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'BPI_ONLINE', 'BPI Online', '', 'active', 1, 2060, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'RCBC_ONLINE', 'RCBC Online', '', 'active', 1, 2070, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'UCPB_ONLINE', 'UCPB Online', '', 'active', 1, 2080, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'ROBINSONSBANK_ONLINE', 'Robinsons Bank Online', '', 'active', 1, 2090, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'PSBANK_ONLINE', 'PSBank Online', '', 'active', 1, 2100, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'METROBANK_ONLINE', 'Metrobank Online', '', 'active', 1, 2110, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'LANDBANK_ONLINE', 'Landbank Online', '', 'active', 1, 2120, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'CHINABANK_ONLINE', 'China Bank Online', '', 'active', 1, 2130, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'BDO_ONLINE', 'BDO Online', '', 'active', 1, 2140, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'BankTransfer', 'BOC_ONLINE', 'Bank of Commerce Online', '', 'active', 1, 2150, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'Ewallet', 'GRABPAY_ONLINE', 'GrabPay Online', '', 'active', 1, 2160, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'QR', 'INSTAPAY_QR', 'InstaPay QR', '', 'active', 1, 2170, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'QR', 'ALIPAY_QR', 'Alipay QR', '', 'active', 1, 2180, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'Ewallet', 'ALIPAY_PLUS', 'Alipay+', '', 'active', 1, 2190, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'Billing', 'BILL', 'Bills Payment', '', 'active', 1, 2200, @now_ms, @now_ms),
('lalu', 'v5pay', 'PH', 'Philippines', 'PHP', 'QR', 'QRPH_GCASH', 'QRPh GCash', '', 'active', 1, 2210, @now_ms, @now_ms),
('lalu', 'v5pay', 'SA', 'Saudi Arabia', 'SAR', 'Ewallet', 'STCPAY', 'STC Pay', '', 'active', 1, 2310, @now_ms, @now_ms),
('lalu', 'v5pay', 'SA', 'Saudi Arabia', 'SAR', 'Card', 'MADA', 'MADA', '', 'active', 1, 2320, @now_ms, @now_ms),
('lalu', 'v5pay', 'SA', 'Saudi Arabia', 'SAR', 'Ewallet', 'APPLEPAY', 'Apple Pay', '', 'active', 1, 2330, @now_ms, @now_ms),
('lalu', 'v5pay', 'EG', 'Egypt', 'EGP', 'Card', 'CARD', 'Egypt Card', '', 'active', 1, 2410, @now_ms, @now_ms),
('lalu', 'v5pay', 'AE', 'United Arab Emirates', 'AED', 'Card', 'CARD', 'UAE Card', '', 'active', 1, 2510, @now_ms, @now_ms),
('lalu', 'v5pay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'BNI_VA', 'BNI Virtual Account', '', 'active', 1, 2610, @now_ms, @now_ms),
('lalu', 'v5pay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'BRI_VA', 'BRI Virtual Account', '', 'active', 1, 2620, @now_ms, @now_ms),
('lalu', 'v5pay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'CIMB_VA', 'CIMB Virtual Account', '', 'active', 1, 2630, @now_ms, @now_ms),
('lalu', 'v5pay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'MANDIRI_VA', 'Mandiri Virtual Account', '', 'active', 1, 2640, @now_ms, @now_ms),
('lalu', 'v5pay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'MAYBANK_VA', 'Maybank Virtual Account', '', 'active', 1, 2650, @now_ms, @now_ms),
('lalu', 'v5pay', 'ID', 'Indonesia', 'IDR', 'QR', 'NOBU_BANK_QRIS', 'Nobu Bank QRIS', '', 'active', 1, 2660, @now_ms, @now_ms),
('lalu', 'v5pay', 'ID', 'Indonesia', 'IDR', 'Ewallet', 'OVO', 'OVO', '', 'active', 1, 2670, @now_ms, @now_ms),
('lalu', 'v5pay', 'ID', 'Indonesia', 'IDR', 'BankTransfer', 'PERMATA_VA', 'Permata Virtual Account', '', 'active', 1, 2680, @now_ms, @now_ms),
('lalu', 'v5pay', 'ID', 'Indonesia', 'IDR', 'Ewallet', 'SHOPEEPAY_JUMPAPP', 'ShopeePay Jump App', '', 'active', 1, 2690, @now_ms, @now_ms),
('lalu', 'v5pay', 'ID', 'Indonesia', 'IDR', 'Ewallet', 'GOPAY', 'GoPay', '', 'active', 1, 2700, @now_ms, @now_ms),
('lalu', 'v5pay', 'ID', 'Indonesia', 'IDR', 'Ewallet', 'ALIPAY_CN_ALIPAYPLUS', 'Alipay+ Indonesia', '', 'active', 1, 2710, @now_ms, @now_ms),
('lalu', 'v5pay', 'IN', 'India', 'INR', 'BankTransfer', 'BANK_ONLINE', 'Bank Online', '', 'active', 1, 2810, @now_ms, @now_ms),
('lalu', 'v5pay', 'IN', 'India', 'INR', 'UPI', 'UPI', 'UPI', '', 'active', 1, 2820, @now_ms, @now_ms),
('lalu', 'v5pay', 'IN', 'India', 'INR', 'BankTransfer', 'ONLINE_BANKING', 'Online Banking', '', 'active', 1, 2830, @now_ms, @now_ms),
('lalu', 'v5pay', 'PK', 'Pakistan', 'PKR', 'Ewallet', 'EASYPAISA', 'Easypaisa', '', 'active', 1, 2910, @now_ms, @now_ms),
('lalu', 'v5pay', 'PK', 'Pakistan', 'PKR', 'Ewallet', 'JAZZCASH', 'JazzCash', '', 'active', 1, 2920, @now_ms, @now_ms),
('lalu', 'v5pay', 'PK', 'Pakistan', 'PKR', 'BankTransfer', 'ALFA', 'Bank Alfalah', '', 'active', 1, 2930, @now_ms, @now_ms),
('lalu', 'v5pay', 'PK', 'Pakistan', 'PKR', 'Ewallet', 'HBL_KONNECT', 'HBL Konnect', '', 'active', 1, 2940, @now_ms, @now_ms),
('lalu', 'v5pay', 'PK', 'Pakistan', 'PKR', 'Billing', 'BILL_PK', 'Pakistan Bill Payment', '', 'active', 1, 2950, @now_ms, @now_ms);
-- MiFaPay logo 使用已上传 COS 的固定对象地址;仅覆盖空值、早期占位图和 Apifox 临时预览图,避免误改运营手工替换的正式图。
UPDATE third_party_payment_methods m
JOIN (

View File

@ -25,6 +25,7 @@ import (
"hyapp/services/wallet-service/internal/client/googleplay"
"hyapp/services/wallet-service/internal/client/mifapay"
"hyapp/services/wallet-service/internal/client/trongrid"
"hyapp/services/wallet-service/internal/client/v5pay"
"hyapp/services/wallet-service/internal/config"
walletservice "hyapp/services/wallet-service/internal/service/wallet"
mysqlstorage "hyapp/services/wallet-service/internal/storage/mysql"
@ -33,26 +34,27 @@ import (
// App 装配 wallet-service gRPC 入口。
type App struct {
server *grpc.Server
listener net.Listener
health *grpchealth.ServingChecker
healthHTTP *healthhttp.Server
mysqlRepo *mysqlstorage.Repository
walletSvc *walletservice.Service
activityConn *grpc.ClientConn
outboxProducer *rocketmqx.Producer
realtimeOutboxProducer *rocketmqx.Producer
projectionConsumer *rocketmqx.Consumer
outboxWorkerCfg config.OutboxWorkerConfig
realtimeOutboxWorkerCfg config.OutboxWorkerConfig
projectionWorkerCfg config.ProjectionWorkerConfig
walletOutboxTopic string
realtimeWalletOutboxTopic string
nodeID string
redPacketExpiryWorkerCfg config.RedPacketExpiryWorkerConfig
stopWorker context.CancelFunc
workers sync.WaitGroup
closeOnce sync.Once
server *grpc.Server
listener net.Listener
health *grpchealth.ServingChecker
healthHTTP *healthhttp.Server
mysqlRepo *mysqlstorage.Repository
walletSvc *walletservice.Service
activityConn *grpc.ClientConn
outboxProducer *rocketmqx.Producer
realtimeOutboxProducer *rocketmqx.Producer
projectionConsumer *rocketmqx.Consumer
outboxWorkerCfg config.OutboxWorkerConfig
realtimeOutboxWorkerCfg config.OutboxWorkerConfig
projectionWorkerCfg config.ProjectionWorkerConfig
externalRechargeReconcileWorkerCfg config.ExternalRechargeReconcileWorkerConfig
walletOutboxTopic string
realtimeWalletOutboxTopic string
nodeID string
redPacketExpiryWorkerCfg config.RedPacketExpiryWorkerConfig
stopWorker context.CancelFunc
workers sync.WaitGroup
closeOnce sync.Once
}
// New 初始化 wallet-service。
@ -106,6 +108,8 @@ func New(cfg config.Config) (*App, error) {
USDTTRC20Address: cfg.ExternalRecharge.USDTTRC20Address,
MifaPayNotifyURL: cfg.ExternalRecharge.MifaPayNotifyURL,
MifaPayReturnURL: cfg.ExternalRecharge.MifaPayReturnURL,
V5PayNotifyURL: cfg.ExternalRecharge.V5PayNotifyURL,
V5PayReturnURL: cfg.ExternalRecharge.V5PayReturnURL,
})
if cfg.GooglePlay.Enabled {
googleClient, err := googleplay.New(cfg.GooglePlay)
@ -134,6 +138,14 @@ func New(cfg config.Config) (*App, error) {
logx.Warn(context.Background(), "mifapay_enabled_without_credentials")
}
}
if cfg.V5Pay.Enabled {
if v5payConfigReady(cfg.V5Pay) {
svc.SetV5PayClient(v5pay.New(cfg.V5Pay))
} else {
// V5Pay 使用 merchantNo/appKey/secretKey 做 MD5 签名;缺任一项时不能发真实下单请求,但本地服务仍可启动。
logx.Warn(context.Background(), "v5pay_enabled_without_credentials")
}
}
if cfg.TronGrid.Enabled {
svc.SetTronUSDTClient(trongrid.New(cfg.TronGrid))
}
@ -162,23 +174,24 @@ func New(cfg config.Config) (*App, error) {
}
return &App{
server: server,
listener: listener,
health: health,
healthHTTP: healthHTTP,
mysqlRepo: repository,
walletSvc: svc,
activityConn: activityConn,
outboxProducer: outboxProducer,
realtimeOutboxProducer: realtimeOutboxProducer,
projectionConsumer: projectionConsumer,
outboxWorkerCfg: cfg.OutboxWorker,
realtimeOutboxWorkerCfg: cfg.RealtimeOutboxWorker,
projectionWorkerCfg: cfg.ProjectionWorker,
walletOutboxTopic: cfg.RocketMQ.WalletOutbox.Topic,
realtimeWalletOutboxTopic: cfg.RocketMQ.RealtimeOutbox.Topic,
nodeID: cfg.NodeID,
redPacketExpiryWorkerCfg: cfg.RedPacketExpiryWorker,
server: server,
listener: listener,
health: health,
healthHTTP: healthHTTP,
mysqlRepo: repository,
walletSvc: svc,
activityConn: activityConn,
outboxProducer: outboxProducer,
realtimeOutboxProducer: realtimeOutboxProducer,
projectionConsumer: projectionConsumer,
outboxWorkerCfg: cfg.OutboxWorker,
realtimeOutboxWorkerCfg: cfg.RealtimeOutboxWorker,
projectionWorkerCfg: cfg.ProjectionWorker,
externalRechargeReconcileWorkerCfg: cfg.ExternalRechargeReconcileWorker,
walletOutboxTopic: cfg.RocketMQ.WalletOutbox.Topic,
realtimeWalletOutboxTopic: cfg.RocketMQ.RealtimeOutbox.Topic,
nodeID: cfg.NodeID,
redPacketExpiryWorkerCfg: cfg.RedPacketExpiryWorker,
}, nil
}
@ -238,6 +251,28 @@ func (a *App) runBackgroundWorkers() {
if a.realtimeOutboxWorkerCfg.Enabled && a.realtimeOutboxProducer != nil {
a.startWalletOutboxWorkers(ctx, "wallet-realtime-outbox", a.realtimeOutboxWorkerCfg, a.realtimeOutboxProducer, a.realtimeWalletOutboxTopic, a.realtimeOutboxWorkerCfg.EventTypes, nil)
}
if a.externalRechargeReconcileWorkerCfg.Enabled {
workerID := "external-recharge-reconcile-" + a.nodeID
a.workers.Add(1)
go func() {
defer a.workers.Done()
ticker := time.NewTicker(a.externalRechargeReconcileWorkerCfg.PollInterval)
defer ticker.Stop()
for {
// 三方回调是主链路,但用户杀 App、本地/测试回调不可达、或支付平台短暂回调失败时,
// 服务端必须自己查单补偿;这里只扫 redirected 订单,入账仍由 repository 的幂等事务兜住。
_, err := a.walletSvc.ReconcileExternalRechargeOrders(ctx, a.externalRechargeReconcileWorkerCfg.AppCode, a.externalRechargeReconcileWorkerCfg.BatchSize)
if err != nil && !errors.Is(err, context.Canceled) {
logx.Error(ctx, "external_recharge_reconcile_failed", err, slog.String("worker_id", workerID))
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
}
if a.redPacketExpiryWorkerCfg.Enabled {
redPacketWorkerID := "red-packet-expiry-" + a.nodeID
a.workers.Add(1)
@ -506,6 +541,12 @@ func mifapayConfigReady(cfg config.MifaPayConfig) bool {
strings.TrimSpace(cfg.PlatformPublicKey) != ""
}
func v5payConfigReady(cfg config.V5PayConfig) bool {
return strings.TrimSpace(cfg.MerchantNo) != "" &&
strings.TrimSpace(cfg.AppKey) != "" &&
strings.TrimSpace(cfg.SecretKey) != ""
}
func shutdownProducers(producers ...*rocketmqx.Producer) {
for _, producer := range producers {
if producer != nil {

View File

@ -0,0 +1,410 @@
package v5pay
import (
"bytes"
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strconv"
"strings"
"time"
"hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/config"
"hyapp/services/wallet-service/internal/domain/ledger"
walletservice "hyapp/services/wallet-service/internal/service/wallet"
)
type Client struct {
cfg config.V5PayConfig
httpClient *http.Client
}
func New(cfg config.V5PayConfig) *Client {
if cfg.HTTPTimeout <= 0 {
cfg.HTTPTimeout = 10 * time.Second
}
return &Client{
cfg: cfg,
httpClient: &http.Client{Timeout: cfg.HTTPTimeout},
}
}
// CreateOrder 调 V5Pay 收银台接口并返回 checkoutUrlproductType 由后台支付方式配置决定。
func (c *Client) CreateOrder(ctx context.Context, req walletservice.V5PayCreateOrderRequest) (walletservice.V5PayCreateOrderResponse, error) {
if c == nil || !c.ready() {
return walletservice.V5PayCreateOrderResponse{}, xerr.New(xerr.Unavailable, "v5pay client is not configured")
}
body := map[string]any{
"merchantNo": c.cfg.MerchantNo,
"appKey": c.cfg.AppKey,
"sysCountryCode": strings.ToUpper(strings.TrimSpace(req.CountryCode)),
"currency": strings.ToUpper(strings.TrimSpace(req.CurrencyCode)),
"orderNo": req.OrderID,
"amount": formatV5PayAmount(req.ProviderAmountMinor),
"productType": strings.TrimSpace(req.ProductType),
"callbackUrl": strings.TrimSpace(req.NotifyURL),
"redirectUrl": strings.TrimSpace(req.ReturnURL),
"merchantParam": req.OrderID,
"language": normalizeV5PayLanguage(req.Language),
"tradeSummary": productInfo(req),
"email": payerEmail(req),
"merchantCustomerId": strconv.FormatInt(req.TargetUserID, 10),
}
body["sign"] = signFields(body, c.cfg.SecretKey)
raw, err := json.Marshal(body)
if err != nil {
return walletservice.V5PayCreateOrderResponse{}, err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.APIBaseURL+"/cgi/cashier/v2/payin", bytes.NewReader(raw))
if err != nil {
return walletservice.V5PayCreateOrderResponse{}, err
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return walletservice.V5PayCreateOrderResponse{}, err
}
defer resp.Body.Close()
rawResp, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if err != nil {
return walletservice.V5PayCreateOrderResponse{}, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return walletservice.V5PayCreateOrderResponse{}, xerr.New(xerr.Unavailable, "v5pay order request failed")
}
fields, err := decodeObject(rawResp)
if err != nil {
return walletservice.V5PayCreateOrderResponse{}, err
}
if code := fields["code"]; code != "" && code != "1000" {
return walletservice.V5PayCreateOrderResponse{}, v5payGatewayRejectionError{
cause: xerr.New(xerr.Unavailable, "v5pay order rejected: "+code+": "+fields["message"]),
rawJSON: string(rawResp),
}
}
if !verifyFields(fields, c.cfg.SecretKey) {
return walletservice.V5PayCreateOrderResponse{}, xerr.New(xerr.PermissionDenied, "v5pay signature is invalid")
}
if strings.TrimSpace(fields["checkoutUrl"]) == "" {
return walletservice.V5PayCreateOrderResponse{}, xerr.New(xerr.Unavailable, "v5pay order response is incomplete")
}
return walletservice.V5PayCreateOrderResponse{
OrderID: req.OrderID,
ProviderOrderID: req.OrderID,
PayURL: fields["checkoutUrl"],
RawJSON: string(rawResp),
}, nil
}
// QueryOrder 主动查询 V5Pay 订单状态H5 返回页依赖它补偿异步回调延迟或回调丢失的窗口期。
func (c *Client) QueryOrder(ctx context.Context, req walletservice.V5PayQueryOrderRequest) (walletservice.V5PayQueryOrderResponse, error) {
if c == nil || !c.ready() {
return walletservice.V5PayQueryOrderResponse{}, xerr.New(xerr.Unavailable, "v5pay client is not configured")
}
body := map[string]any{
"merchantNo": c.cfg.MerchantNo,
"appKey": c.cfg.AppKey,
"sysCountryCode": strings.ToUpper(strings.TrimSpace(req.CountryCode)),
"currency": strings.ToUpper(strings.TrimSpace(req.CurrencyCode)),
"orderNo": req.OrderID,
}
body["sign"] = signFields(body, c.cfg.SecretKey)
raw, err := json.Marshal(body)
if err != nil {
return walletservice.V5PayQueryOrderResponse{}, err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.APIBaseURL+"/cgi/payment/v1/payin/query", bytes.NewReader(raw))
if err != nil {
return walletservice.V5PayQueryOrderResponse{}, err
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return walletservice.V5PayQueryOrderResponse{}, err
}
defer resp.Body.Close()
rawResp, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if err != nil {
return walletservice.V5PayQueryOrderResponse{}, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return walletservice.V5PayQueryOrderResponse{}, xerr.New(xerr.Unavailable, "v5pay order query request failed")
}
fields, err := decodeObject(rawResp)
if err != nil {
return walletservice.V5PayQueryOrderResponse{}, err
}
if code := fields["code"]; code != "" && code != "1000" {
return walletservice.V5PayQueryOrderResponse{}, v5payGatewayRejectionError{
cause: xerr.New(xerr.Unavailable, "v5pay order query rejected: "+code+": "+fields["message"]),
rawJSON: string(rawResp),
}
}
if !verifyFields(fields, c.cfg.SecretKey) {
return walletservice.V5PayQueryOrderResponse{}, xerr.New(xerr.PermissionDenied, "v5pay signature is invalid")
}
return walletservice.V5PayQueryOrderResponse{
OrderID: fields["orderNo"],
ProviderOrderID: fields["transactionId"],
Status: fields["status"],
Amount: fields["amount"],
Currency: fields["currency"],
ProductType: fields["productType"],
RawJSON: string(rawResp),
}, nil
}
// ListProductTypes 查询当前商户应用在指定国家和币种下实际开通的 V5Pay 产品H5 展示前用它过滤静态配置。
func (c *Client) ListProductTypes(ctx context.Context, req walletservice.V5PayProductTypesRequest) (walletservice.V5PayProductTypesResponse, error) {
if c == nil || !c.ready() {
return walletservice.V5PayProductTypesResponse{}, xerr.New(xerr.Unavailable, "v5pay client is not configured")
}
query := map[string]any{
"merchantNo": c.cfg.MerchantNo,
"appKey": c.cfg.AppKey,
"sysCountryCode": strings.ToUpper(strings.TrimSpace(req.CountryCode)),
"currency": strings.ToUpper(strings.TrimSpace(req.CurrencyCode)),
"nonce": time.Now().UnixMilli(),
}
query["sign"] = signFields(query, c.cfg.SecretKey)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, c.cfg.APIBaseURL+"/cgi/payment/v1/productTypes", nil)
if err != nil {
return walletservice.V5PayProductTypesResponse{}, err
}
values := httpReq.URL.Query()
for key, value := range query {
values.Set(key, valueToString(value))
}
httpReq.URL.RawQuery = values.Encode()
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return walletservice.V5PayProductTypesResponse{}, err
}
defer resp.Body.Close()
rawResp, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if err != nil {
return walletservice.V5PayProductTypesResponse{}, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return walletservice.V5PayProductTypesResponse{}, xerr.New(xerr.Unavailable, "v5pay product types request failed")
}
var payload struct {
Code string `json:"code"`
Message string `json:"message"`
MerchantNo string `json:"merchantNo"`
AppKey string `json:"appKey"`
Nonce any `json:"nonce"`
Sign string `json:"sign"`
PayinList []struct {
ProductType string `json:"productType"`
ProductName string `json:"productName"`
Currency string `json:"currency"`
SupportCashierMode int `json:"supportCashierMode"`
} `json:"payinList"`
}
if err := json.Unmarshal(rawResp, &payload); err != nil {
return walletservice.V5PayProductTypesResponse{}, err
}
if payload.Code != "" && payload.Code != "1000" {
return walletservice.V5PayProductTypesResponse{}, v5payGatewayRejectionError{
cause: xerr.New(xerr.Unavailable, "v5pay product types rejected: "+payload.Code+": "+payload.Message),
rawJSON: string(rawResp),
}
}
signFields := map[string]string{
"code": payload.Code,
"message": payload.Message,
"merchantNo": payload.MerchantNo,
"appKey": payload.AppKey,
"nonce": valueToString(payload.Nonce),
"sign": payload.Sign,
}
if !verifyFields(signFields, c.cfg.SecretKey) {
return walletservice.V5PayProductTypesResponse{}, xerr.New(xerr.PermissionDenied, "v5pay signature is invalid")
}
products := make([]walletservice.V5PayProductType, 0, len(payload.PayinList))
for _, product := range payload.PayinList {
products = append(products, walletservice.V5PayProductType{
ProductType: product.ProductType,
ProductName: product.ProductName,
Currency: product.Currency,
SupportCashierMode: product.SupportCashierMode,
})
}
return walletservice.V5PayProductTypesResponse{PayinList: products, RawJSON: string(rawResp)}, nil
}
// ParseNotification 验签 V5Pay 回调并返回内层业务字段;验签失败不允许业务层入账。
func (c *Client) ParseNotification(notification ledger.V5PayNotification) (walletservice.V5PayNotifyData, error) {
if c == nil || !c.ready() {
return walletservice.V5PayNotifyData{}, xerr.New(xerr.Unavailable, "v5pay client is not configured")
}
fields := make(map[string]string, len(notification.Fields))
for key, value := range notification.Fields {
fields[key] = value
}
if !verifyFields(fields, c.cfg.SecretKey) {
return walletservice.V5PayNotifyData{}, xerr.New(xerr.PermissionDenied, "v5pay signature is invalid")
}
if strings.TrimSpace(fields["orderNo"]) == "" {
return walletservice.V5PayNotifyData{}, xerr.New(xerr.InvalidArgument, "v5pay callback order_no is required")
}
return walletservice.V5PayNotifyData{
OrderID: fields["orderNo"],
ProviderOrderID: fields["transactionId"],
OrderStatus: fields["status"],
Amount: fields["amount"],
Currency: fields["currency"],
ProductType: fields["productType"],
RawJSON: firstNonEmpty(notification.RawJSON, mustJSON(fields)),
}, nil
}
func (c *Client) ready() bool {
return strings.TrimSpace(c.cfg.MerchantNo) != "" &&
strings.TrimSpace(c.cfg.AppKey) != "" &&
strings.TrimSpace(c.cfg.SecretKey) != "" &&
strings.TrimSpace(c.cfg.APIBaseURL) != ""
}
type v5payGatewayRejectionError struct {
cause error
rawJSON string
}
func (e v5payGatewayRejectionError) Error() string {
return e.cause.Error()
}
func (e v5payGatewayRejectionError) Unwrap() error {
return e.cause
}
func (e v5payGatewayRejectionError) ProviderPayloadJSON() string {
return e.rawJSON
}
func signFields(fields map[string]any, secretKey string) string {
normalized := make(map[string]string, len(fields))
for key, value := range fields {
normalized[key] = valueToString(value)
}
return signStringFields(normalized, secretKey)
}
func verifyFields(fields map[string]string, secretKey string) bool {
want := strings.ToLower(strings.TrimSpace(fields["sign"]))
return want != "" && want == signStringFields(fields, secretKey)
}
func signStringFields(fields map[string]string, secretKey string) string {
keys := make([]string, 0, len(fields))
for key, value := range fields {
if key == "sign" || strings.TrimSpace(value) == "" {
continue
}
keys = append(keys, key)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, key := range keys {
parts = append(parts, key+"="+fields[key])
}
sum := md5.Sum([]byte(strings.Join(parts, "&") + secretKey))
return hex.EncodeToString(sum[:])
}
func decodeObject(raw []byte) (map[string]string, error) {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
var payload map[string]any
if err := decoder.Decode(&payload); err != nil {
return nil, err
}
fields := make(map[string]string, len(payload))
for key, value := range payload {
fields[key] = valueToString(value)
}
return fields, nil
}
func valueToString(value any) string {
switch typed := value.(type) {
case nil:
return ""
case string:
return typed
case json.Number:
return typed.String()
case float64:
return strconv.FormatFloat(typed, 'f', -1, 64)
case bool:
if typed {
return "true"
}
return "false"
default:
return fmt.Sprint(typed)
}
}
func formatV5PayAmount(minor int64) string {
return fmt.Sprintf("%d.%02d", minor/100, minor%100)
}
func productInfo(req walletservice.V5PayCreateOrderRequest) string {
name := strings.TrimSpace(req.ProductName)
if name == "" {
name = "coin package"
}
return fmt.Sprintf("%s %d coins", name, req.CoinAmount)
}
func payerEmail(req walletservice.V5PayCreateOrderRequest) string {
if strings.TrimSpace(req.PayerEmail) != "" {
return strings.TrimSpace(req.PayerEmail)
}
return fmt.Sprintf("v5pay-payer-%d@haiyihy.com", req.TargetUserID)
}
func normalizeV5PayLanguage(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "zh", "zh-cn":
return "zh-CN"
case "zh-tw", "zh-hk", "zh-tc":
return "zh-TC"
case "es", "es-es":
return "es-ES"
case "pt", "pt-pt":
return "pt-PT"
case "ru", "ru-ru":
return "ru-RU"
case "ja", "jp", "ja-jp", "jp-jp":
return "jp-JP"
default:
return "en-US"
}
}
func mustJSON(value any) string {
raw, err := json.Marshal(value)
if err != nil {
return ""
}
return string(raw)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}

View File

@ -29,10 +29,14 @@ type Config struct {
GooglePlay GooglePlayConfig `yaml:"google_play"`
// MifaPay 控制 H5 MiFaPay 站外支付下单和回调验签。
MifaPay MifaPayConfig `yaml:"mifapay"`
// V5Pay 控制 H5 V5Pay 站外支付下单、查询和回调验签。
V5Pay V5PayConfig `yaml:"v5pay"`
// TronGrid 控制 USDT-TRC20 tx_hash 链上校验。
TronGrid TronGridConfig `yaml:"tron_grid"`
// ExternalRecharge 控制 H5 外部充值公开地址和回跳地址。
ExternalRecharge ExternalRechargeConfig `yaml:"external_recharge"`
// ExternalRechargeReconcileWorker 控制三方支付回调丢失后的服务端查单补偿。
ExternalRechargeReconcileWorker ExternalRechargeReconcileWorkerConfig `yaml:"external_recharge_reconcile_worker"`
// OutboxWorker 控制 wallet_outbox 到 MQ 的补偿投递。
OutboxWorker OutboxWorkerConfig `yaml:"outbox_worker"`
// RealtimeOutboxWorker 控制红包等实时 UI 事件到独立 MQ topic 的补偿投递。
@ -136,6 +140,30 @@ type MifaPayEndpointConfig struct {
HTTPTimeout time.Duration `yaml:"http_timeout"`
}
// V5PayConfig 保存 V5Pay 商户签名、验签和网关配置。
type V5PayConfig struct {
Enabled bool `yaml:"enabled"`
Environment string `yaml:"environment"`
EnvironmentTest V5PayEndpointConfig `yaml:"environment_test"`
EnvironmentProd V5PayEndpointConfig `yaml:"environment_prod"`
MerchantNo string `yaml:"merchant_no"`
MerNo string `yaml:"mer_no"` // Deprecated: 兼容早期截图里的 mer_no 写法。
AppKey string `yaml:"app_key"`
SecretKey string `yaml:"secret_key"`
APIBaseURL string `yaml:"api_base_url"`
HTTPTimeout time.Duration `yaml:"http_timeout"`
}
// V5PayEndpointConfig 保存 V5Pay 某一环境的商户应用配置secret_key 只能来自环境或配置注入。
type V5PayEndpointConfig struct {
MerchantNo string `yaml:"merchant_no"`
MerNo string `yaml:"mer_no"` // Deprecated: 兼容早期截图里的 mer_no 写法。
AppKey string `yaml:"app_key"`
SecretKey string `yaml:"secret_key"`
APIBaseURL string `yaml:"api_base_url"`
HTTPTimeout time.Duration `yaml:"http_timeout"`
}
// TronGridConfig 保存 TRON HTTP 兼容接口配置base_url 可替换成自有代理。
type TronGridConfig struct {
Enabled bool `yaml:"enabled"`
@ -151,6 +179,16 @@ type ExternalRechargeConfig struct {
USDTTRC20Address string `yaml:"usdt_trc20_address"`
MifaPayNotifyURL string `yaml:"mifapay_notify_url"`
MifaPayReturnURL string `yaml:"mifapay_return_url"`
V5PayNotifyURL string `yaml:"v5pay_notify_url"`
V5PayReturnURL string `yaml:"v5pay_return_url"`
}
// ExternalRechargeReconcileWorkerConfig 保存 H5 外部充值查单补偿策略。
type ExternalRechargeReconcileWorkerConfig struct {
Enabled bool `yaml:"enabled"`
AppCode string `yaml:"app_code"`
PollInterval time.Duration `yaml:"poll_interval"`
BatchSize int `yaml:"batch_size"`
}
// Default 返回本地开发默认配置。
@ -182,6 +220,11 @@ func Default() Config {
APIBaseURL: "https://platformtest.xqdmipay.com",
HTTPTimeout: 10 * time.Second,
},
V5Pay: V5PayConfig{
Enabled: true,
APIBaseURL: "https://api-uat.v5pay.com",
HTTPTimeout: 10 * time.Second,
},
TronGrid: TronGridConfig{
Enabled: true,
APIBaseURL: "https://api.trongrid.io",
@ -191,6 +234,12 @@ func Default() Config {
ExternalRecharge: ExternalRechargeConfig{
USDTTRC20Enabled: true,
},
ExternalRechargeReconcileWorker: ExternalRechargeReconcileWorkerConfig{
Enabled: true,
AppCode: "lalu",
PollInterval: 30 * time.Second,
BatchSize: 50,
},
OutboxWorker: OutboxWorkerConfig{
Enabled: false,
PollInterval: time.Second,
@ -317,6 +366,8 @@ func Load(path string) (Config, error) {
}
cfg.MifaPay = normalizeMifaPayConfig(cfg.MifaPay)
// MiFaPay 功能开关默认打开;商户密钥属于环境敏感配置,允许本地缺省启动,启动装配时再按凭证完整性决定是否接入真实网关。
cfg.V5Pay = normalizeV5PayConfig(cfg.V5Pay)
// V5Pay 功能开关默认打开merchantNo/appKey/secretKey 是环境敏感配置,缺失时不注入真实网关。
cfg.TronGrid.APIBaseURL = strings.TrimRight(strings.TrimSpace(cfg.TronGrid.APIBaseURL), "/")
if cfg.TronGrid.APIBaseURL == "" {
cfg.TronGrid.APIBaseURL = Default().TronGrid.APIBaseURL
@ -332,7 +383,10 @@ func Load(path string) (Config, error) {
cfg.ExternalRecharge.USDTTRC20Address = strings.TrimSpace(cfg.ExternalRecharge.USDTTRC20Address)
cfg.ExternalRecharge.MifaPayNotifyURL = strings.TrimSpace(cfg.ExternalRecharge.MifaPayNotifyURL)
cfg.ExternalRecharge.MifaPayReturnURL = strings.TrimSpace(cfg.ExternalRecharge.MifaPayReturnURL)
cfg.ExternalRecharge.V5PayNotifyURL = strings.TrimSpace(cfg.ExternalRecharge.V5PayNotifyURL)
cfg.ExternalRecharge.V5PayReturnURL = strings.TrimSpace(cfg.ExternalRecharge.V5PayReturnURL)
// USDT 功能开关默认打开;未配置收款地址时 H5 不展示 USDT 下单入口,但配置本身不被静默改成关闭。
cfg.ExternalRechargeReconcileWorker = normalizeExternalRechargeReconcileWorkerConfig(cfg.ExternalRechargeReconcileWorker, Default().ExternalRechargeReconcileWorker)
cfg.OutboxWorker = normalizeOutboxWorkerConfig(cfg.OutboxWorker, Default().OutboxWorker)
if cfg.OutboxWorker.Enabled && !cfg.RocketMQ.WalletOutbox.Enabled {
return Config{}, errors.New("outbox_worker requires rocketmq.wallet_outbox.enabled")
@ -369,6 +423,22 @@ func Load(path string) (Config, error) {
return cfg, nil
}
func normalizeExternalRechargeReconcileWorkerConfig(cfg ExternalRechargeReconcileWorkerConfig, defaults ExternalRechargeReconcileWorkerConfig) ExternalRechargeReconcileWorkerConfig {
if cfg.AppCode = strings.TrimSpace(cfg.AppCode); cfg.AppCode == "" {
cfg.AppCode = defaults.AppCode
}
if cfg.PollInterval <= 0 {
cfg.PollInterval = defaults.PollInterval
}
if cfg.BatchSize <= 0 {
cfg.BatchSize = defaults.BatchSize
}
if cfg.BatchSize > 500 {
cfg.BatchSize = 500
}
return cfg
}
func normalizeMifaPayConfig(cfg MifaPayConfig) MifaPayConfig {
cfg.Environment = strings.ToLower(strings.TrimSpace(cfg.Environment))
// 本地联调可以同时保留 test/prod 两套商户配置;显式 environment 决定真实注入哪套,避免测试网和正式网字段互相覆盖。
@ -396,6 +466,54 @@ func normalizeMifaPayConfig(cfg MifaPayConfig) MifaPayConfig {
return cfg
}
func normalizeV5PayConfig(cfg V5PayConfig) V5PayConfig {
cfg.Environment = strings.ToLower(strings.TrimSpace(cfg.Environment))
// V5Pay 和 MiFaPay 一样支持 test/prod 两套环境;只在环境块里真实配置了字段时覆盖 flat 字段。
switch cfg.Environment {
case "test":
cfg = applyV5PayEndpointConfig(cfg, cfg.EnvironmentTest)
case "prod", "production":
cfg.Environment = "prod"
cfg = applyV5PayEndpointConfig(cfg, cfg.EnvironmentProd)
default:
cfg.Environment = ""
}
cfg.MerchantNo = firstNonEmpty(strings.TrimSpace(cfg.MerchantNo), strings.TrimSpace(cfg.MerNo))
cfg.MerNo = strings.TrimSpace(cfg.MerNo)
cfg.AppKey = strings.TrimSpace(cfg.AppKey)
cfg.SecretKey = strings.TrimSpace(cfg.SecretKey)
cfg.APIBaseURL = strings.TrimRight(strings.TrimSpace(cfg.APIBaseURL), "/")
if cfg.APIBaseURL == "" {
cfg.APIBaseURL = Default().V5Pay.APIBaseURL
}
if cfg.HTTPTimeout <= 0 {
cfg.HTTPTimeout = Default().V5Pay.HTTPTimeout
}
return cfg
}
func applyV5PayEndpointConfig(cfg V5PayConfig, endpoint V5PayEndpointConfig) V5PayConfig {
if value := firstNonEmpty(strings.TrimSpace(endpoint.MerchantNo), strings.TrimSpace(endpoint.MerNo)); value != "" {
cfg.MerchantNo = value
}
if value := strings.TrimSpace(endpoint.MerNo); value != "" {
cfg.MerNo = value
}
if value := strings.TrimSpace(endpoint.AppKey); value != "" {
cfg.AppKey = value
}
if value := strings.TrimSpace(endpoint.SecretKey); value != "" {
cfg.SecretKey = value
}
if value := strings.TrimSpace(endpoint.APIBaseURL); value != "" {
cfg.APIBaseURL = value
}
if endpoint.HTTPTimeout > 0 {
cfg.HTTPTimeout = endpoint.HTTPTimeout
}
return cfg
}
func applyMifaPayEndpointConfig(cfg MifaPayConfig, endpoint MifaPayEndpointConfig) MifaPayConfig {
// 环境块只在字段真实配置时覆盖扁平字段;这样旧的 flat 配置仍能启动,新配置也能按 test/prod 切换。
if value := strings.TrimSpace(endpoint.MerAccount); value != "" {
@ -422,6 +540,15 @@ func applyMifaPayEndpointConfig(cfg MifaPayConfig, endpoint MifaPayEndpointConfi
return cfg
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) {
defaults := defaultRocketMQConfig()
cfg.NameServers = normalizeStringSlice(cfg.NameServers)

View File

@ -20,6 +20,9 @@ func TestLoadLocalEnablesOutboxMQ(t *testing.T) {
if !cfg.OutboxWorker.Enabled || !cfg.RealtimeOutboxWorker.Enabled || !cfg.ProjectionWorker.Enabled {
t.Fatalf("local config must enable wallet outbox workers: outbox=%+v realtime=%+v projection=%+v", cfg.OutboxWorker, cfg.RealtimeOutboxWorker, cfg.ProjectionWorker)
}
if !cfg.ExternalRechargeReconcileWorker.Enabled || cfg.ExternalRechargeReconcileWorker.BatchSize != 50 || cfg.ExternalRechargeReconcileWorker.AppCode != "lalu" {
t.Fatalf("local config must enable external recharge reconcile worker: %+v", cfg.ExternalRechargeReconcileWorker)
}
if cfg.OutboxWorker.Concurrency != 1 || cfg.RealtimeOutboxWorker.Concurrency != 2 {
t.Fatalf("local outbox worker defaults mismatch: outbox=%+v realtime=%+v", cfg.OutboxWorker, cfg.RealtimeOutboxWorker)
}
@ -64,11 +67,13 @@ func TestLoadMifaPayMerchantConfig(t *testing.T) {
},
{
path: "../../configs/config.docker.yaml",
merNo: "10001910",
merAccount: "6000055d34f445418124ab7c0beb81a6",
env: "test",
merNo: "10001925",
merAccount: "d00f5fc97a114a0db291eceaf9841739",
},
{
path: "../../configs/config.tencent.example.yaml",
env: "prod",
merNo: "10001910",
merAccount: "6000055d34f445418124ab7c0beb81a6",
},

View File

@ -56,6 +56,8 @@ const (
PaymentProviderGooglePlay = "google_play"
// PaymentProviderMifaPay 是 MiFaPay 三方收银台支付渠道。
PaymentProviderMifaPay = "mifapay"
// PaymentProviderV5Pay 是 V5Pay 三方收银台支付渠道。
PaymentProviderV5Pay = "v5pay"
// PaymentProviderUSDTTRC20 是用户提交 TRC20 链上交易哈希的 USDT 充值渠道。
PaymentProviderUSDTTRC20 = "usdt_trc20"
// ThirdPartyPaymentStatusActive 表示渠道或支付方式可用于 H5 下单。
@ -752,6 +754,12 @@ type MifaPayNotification struct {
Sign string
}
// V5PayNotification 保存 V5Pay 回调原始字段;验签必须使用所有返回字段,避免未来新增字段绕过签名校验。
type V5PayNotification struct {
Fields map[string]string
RawJSON string
}
// DiamondExchangeRule 是钻石兑换金币或余额的固定规则投影。
type DiamondExchangeRule struct {
ExchangeType string
@ -1314,6 +1322,8 @@ func NormalizePaymentProvider(provider string) string {
switch strings.ToLower(strings.TrimSpace(provider)) {
case PaymentProviderMifaPay:
return PaymentProviderMifaPay
case PaymentProviderV5Pay:
return PaymentProviderV5Pay
case PaymentProviderUSDTTRC20:
return PaymentProviderUSDTTRC20
case PaymentProviderGooglePlay:

View File

@ -20,6 +20,8 @@ type ExternalRechargeConfig struct {
USDTTRC20Address string
MifaPayNotifyURL string
MifaPayReturnURL string
V5PayNotifyURL string
V5PayReturnURL string
}
// MifaPayCreateOrderRequest 是 wallet-service 传给 MiFaPay client 的已校验下单快照。
@ -76,6 +78,76 @@ type MifaPayNotifyData struct {
RawJSON string
}
// V5PayCreateOrderRequest 是 wallet-service 传给 V5Pay client 的已校验下单快照。
type V5PayCreateOrderRequest struct {
OrderID string
TargetUserID int64
ProductName string
CoinAmount int64
AmountMinor int64
CurrencyCode string
CountryCode string
ProductType string
NotifyURL string
ReturnURL string
Language string
ProviderAmountMinor int64
PayerName string
PayerAccount string
PayerEmail string
}
type V5PayCreateOrderResponse struct {
OrderID string
ProviderOrderID string
PayURL string
RawJSON string
}
type V5PayQueryOrderRequest struct {
OrderID string
ProviderOrderID string
CountryCode string
CurrencyCode string
}
type V5PayQueryOrderResponse struct {
OrderID string
ProviderOrderID string
Status string
Amount string
Currency string
ProductType string
RawJSON string
}
type V5PayNotifyData struct {
OrderID string
ProviderOrderID string
OrderStatus string
Amount string
Currency string
ProductType string
RawJSON string
}
type V5PayProductTypesRequest struct {
CountryCode string
CurrencyCode string
}
type V5PayProductType struct {
ProductType string
ProductName string
Currency string
SupportCashierMode int
}
type V5PayProductTypesResponse struct {
PayinList []V5PayProductType
RawJSON string
}
// MifaPayClient 隔离 MiFaPay RSA 签名、验签和 HTTP 协议细节service 只处理订单状态。
type MifaPayClient interface {
CreateOrder(ctx context.Context, req MifaPayCreateOrderRequest) (MifaPayCreateOrderResponse, error)
@ -83,6 +155,14 @@ type MifaPayClient interface {
ParseNotification(notification ledger.MifaPayNotification) (MifaPayNotifyData, error)
}
// V5PayClient 隔离 V5Pay MD5 签名、验签和 HTTP 协议细节service 只处理订单状态。
type V5PayClient interface {
CreateOrder(ctx context.Context, req V5PayCreateOrderRequest) (V5PayCreateOrderResponse, error)
QueryOrder(ctx context.Context, req V5PayQueryOrderRequest) (V5PayQueryOrderResponse, error)
ListProductTypes(ctx context.Context, req V5PayProductTypesRequest) (V5PayProductTypesResponse, error)
ParseNotification(notification ledger.V5PayNotification) (V5PayNotifyData, error)
}
// TronUSDTClient 校验用户提交的 TRC20 tx_hash 是否真实打到平台共享地址。
type TronUSDTClient interface {
VerifyUSDTTransfer(ctx context.Context, txHash string, toAddress string, amountMinor int64) (string, error)
@ -92,6 +172,8 @@ func normalizeExternalRechargeConfig(config ExternalRechargeConfig) ExternalRech
config.USDTTRC20Address = strings.TrimSpace(config.USDTTRC20Address)
config.MifaPayNotifyURL = strings.TrimSpace(config.MifaPayNotifyURL)
config.MifaPayReturnURL = strings.TrimSpace(config.MifaPayReturnURL)
config.V5PayNotifyURL = strings.TrimSpace(config.V5PayNotifyURL)
config.V5PayReturnURL = strings.TrimSpace(config.V5PayReturnURL)
return config
}
@ -152,14 +234,69 @@ func (s *Service) ListH5RechargeOptions(ctx context.Context, query ledger.H5Rech
if err != nil {
return ledger.H5RechargeOptions{}, err
}
if s.mifaPay == nil {
if s.mifaPay == nil && s.v5Pay == nil {
options.PaymentMethods = nil
} else {
filtered := options.PaymentMethods[:0]
for _, method := range options.PaymentMethods {
// H5 只展示当前运行时已装配 client 的支付公司;后台仍可维护全部 provider 的开关和汇率。
if method.ProviderCode == ledger.PaymentProviderMifaPay && s.mifaPay != nil {
filtered = append(filtered, method)
}
if method.ProviderCode == ledger.PaymentProviderV5Pay && s.v5Pay != nil {
filtered = append(filtered, method)
}
}
options.PaymentMethods = s.filterV5PayRuntimeProductTypes(ctx, filtered)
}
options.USDTTRC20Enabled = s.externalRecharge.USDTTRC20Enabled && s.externalRecharge.USDTTRC20Address != ""
options.USDTTRC20Address = s.externalRecharge.USDTTRC20Address
return options, nil
}
func (s *Service) filterV5PayRuntimeProductTypes(ctx context.Context, methods []ledger.ThirdPartyPaymentMethod) []ledger.ThirdPartyPaymentMethod {
if s.v5Pay == nil || len(methods) == 0 {
return methods
}
type productKey struct {
country string
currency string
}
allowedByKey := map[productKey]map[string]bool{}
filtered := methods[:0]
for _, method := range methods {
if method.ProviderCode != ledger.PaymentProviderV5Pay {
filtered = append(filtered, method)
continue
}
key := productKey{country: strings.ToUpper(strings.TrimSpace(method.CountryCode)), currency: strings.ToUpper(strings.TrimSpace(method.CurrencyCode))}
allowed, ok := allowedByKey[key]
if !ok {
allowed = map[string]bool{}
products, err := s.v5Pay.ListProductTypes(ctx, V5PayProductTypesRequest{CountryCode: key.country, CurrencyCode: key.currency})
if err == nil {
for _, product := range products.PayinList {
// V5Pay 的 appKey 可按国家/币种/产品单独开通H5 只展示当前应用实际可走收银台的产品,避免用户点到 1013 app invalid。
if product.SupportCashierMode == 0 {
continue
}
if product.Currency != "" && !strings.EqualFold(product.Currency, key.currency) {
continue
}
if productType := strings.ToUpper(strings.TrimSpace(product.ProductType)); productType != "" {
allowed[productType] = true
}
}
}
allowedByKey[key] = allowed
}
if allowed[strings.ToUpper(strings.TrimSpace(method.PayType))] {
filtered = append(filtered, method)
}
}
return filtered
}
// CreateH5RechargeOrder 创建 H5 外部充值订单;只有回调或链上校验成功后才会入账。
func (s *Service) CreateH5RechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand) (ledger.ExternalRechargeOrder, error) {
command = normalizeCreateExternalRechargeOrderCommand(command)
@ -182,6 +319,8 @@ func (s *Service) CreateH5RechargeOrder(ctx context.Context, command ledger.Crea
return s.createUSDTRechargeOrder(ctx, command, product)
case ledger.PaymentProviderMifaPay:
return s.createMifaPayRechargeOrder(ctx, command, product)
case ledger.PaymentProviderV5Pay:
return s.createV5PayRechargeOrder(ctx, command, product)
default:
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.InvalidArgument, "payment provider is invalid")
}
@ -245,16 +384,57 @@ func (s *Service) createMifaPayRechargeOrder(ctx context.Context, command ledger
return s.repository.MarkExternalRechargeOrderRedirected(ctx, command.AppCode, order.OrderID, resp.ProviderOrderID, resp.PayURL, resp.RawJSON)
}
func (s *Service) createV5PayRechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand, product ledger.RechargeProduct) (ledger.ExternalRechargeOrder, error) {
if s.v5Pay == nil {
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "v5pay client is not configured")
}
method, err := s.repository.GetThirdPartyPaymentMethod(ctx, command.AppCode, command.PaymentMethodID)
if err != nil {
return ledger.ExternalRechargeOrder{}, err
}
if err := validateV5PayMethodForOrder(method, command); err != nil {
return ledger.ExternalRechargeOrder{}, err
}
providerAmountMinor, err := calculateProviderAmountMinor(amountMicroToUSDMinor(product.AmountMicro), method.USDToCurrencyRate)
if err != nil {
return ledger.ExternalRechargeOrder{}, err
}
order, err := s.repository.CreateExternalRechargeOrder(ctx, command, product, &method, providerAmountMinor, "")
if err != nil || order.IdempotentReplay {
return order, err
}
notifyURL := firstNonEmpty(command.NotifyURL, s.externalRecharge.V5PayNotifyURL)
returnURL := firstNonEmpty(command.ReturnURL, s.externalRecharge.V5PayReturnURL)
if notifyURL == "" {
_, _ = s.repository.MarkExternalRechargeOrderFailed(ctx, command.AppCode, order.OrderID, "v5pay notify_url is not configured", "")
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "v5pay notify_url is not configured")
}
resp, err := s.v5Pay.CreateOrder(ctx, V5PayCreateOrderRequest{
OrderID: order.OrderID,
TargetUserID: order.TargetUserID,
ProductName: order.ProductName,
CoinAmount: order.CoinAmount,
AmountMinor: order.USDMinorAmount,
CurrencyCode: order.CurrencyCode,
CountryCode: order.CountryCode,
ProductType: order.PayType,
NotifyURL: notifyURL,
ReturnURL: v5PayProviderReturnURL(returnURL),
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(), providerPayloadJSON(err))
return ledger.ExternalRechargeOrder{}, normalizeExternalProviderOrderError(err, "v5pay")
}
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")
return normalizeExternalProviderOrderError(err, "mifapay")
}
type mifaPayProviderPayloadError interface {
@ -262,10 +442,26 @@ type mifaPayProviderPayloadError interface {
}
func mifaPayProviderPayloadJSON(err error) string {
return providerPayloadJSON(err)
}
func normalizeExternalProviderOrderError(err error, provider string) error {
if err == nil {
return nil
}
if _, ok := xerr.As(err); ok {
return err
}
// HTTP、JSON、签名和响应结构错误都是外部支付依赖不可用不能把普通 Go error 透到 gRPC 边界,
// 否则 gateway 只能降级成 INTERNAL_ERROR前端拿不到“上游支付失败”的可重试语义。
return xerr.New(xerr.Unavailable, provider+" order upstream response is invalid")
}
func providerPayloadJSON(err error) string {
var payloadErr mifaPayProviderPayloadError
if errors.As(err, &payloadErr) {
// MiFaPay 网关拒单的原始响应是和支付订单排障强相关的三方事实,落库后可直接给支付技术支持按 code/msg 查链路。
// 普通网络错误、JSON 解析错误或本地 RSA 错误没有三方响应体,这里保持空值,避免写入误导性的本地错误文本。
// 支付网关拒单的原始响应是和支付订单排障强相关的三方事实,落库后可直接给支付技术支持按 code/msg 查链路。
// 普通网络错误、JSON 解析错误或本地签名错误没有三方响应体,这里保持空值,避免写入误导性的本地错误文本。
return strings.TrimSpace(payloadErr.ProviderPayloadJSON())
}
return ""
@ -322,9 +518,41 @@ func (s *Service) GetH5RechargeOrder(ctx context.Context, appCode string, orderI
if refreshed, err := s.refreshMifaPayRechargeOrder(ctx, order); err == nil {
order = refreshed
}
if refreshed, err := s.refreshV5PayRechargeOrder(ctx, order); err == nil {
order = refreshed
}
return order, nil
}
func (s *Service) ReconcileExternalRechargeOrders(ctx context.Context, appCode string, limit int) (int, error) {
if s.repository == nil {
return 0, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
if limit <= 0 {
return 0, nil
}
ctx = appcode.WithContext(ctx, appcode.Normalize(appCode))
orders, err := s.repository.ListExternalRechargeOrdersForReconcile(ctx, appcode.FromContext(ctx), limit)
if err != nil {
return 0, err
}
processed := 0
for _, order := range orders {
// 补偿任务只负责把“已经跳转到三方收银台但回调/回跳未完成”的订单拉回服务端状态机。
// 单笔三方查询失败不能中断整批;失败订单会保留 redirected下一轮继续查成功/终态失败则复用幂等落账。
if _, err := s.refreshMifaPayRechargeOrder(ctx, order); err != nil {
processed++
continue
}
if _, err := s.refreshV5PayRechargeOrder(ctx, order); err != nil {
processed++
continue
}
processed++
}
return processed, nil
}
func (s *Service) refreshMifaPayRechargeOrder(ctx context.Context, order ledger.ExternalRechargeOrder) (ledger.ExternalRechargeOrder, error) {
if s.mifaPay == nil || order.ProviderCode != ledger.PaymentProviderMifaPay || order.Status == ledger.ExternalRechargeStatusCredited || order.Status == ledger.ExternalRechargeStatusFailed {
return order, nil
@ -363,6 +591,46 @@ func (s *Service) refreshMifaPayRechargeOrder(ctx context.Context, order ledger.
}
}
func (s *Service) refreshV5PayRechargeOrder(ctx context.Context, order ledger.ExternalRechargeOrder) (ledger.ExternalRechargeOrder, error) {
if s.v5Pay == nil || order.ProviderCode != ledger.PaymentProviderV5Pay || order.Status == ledger.ExternalRechargeStatusCredited || order.Status == ledger.ExternalRechargeStatusFailed {
return order, nil
}
query, err := s.v5Pay.QueryOrder(ctx, V5PayQueryOrderRequest{
OrderID: order.OrderID,
ProviderOrderID: order.ProviderOrderID,
CountryCode: order.CountryCode,
CurrencyCode: order.CurrencyCode,
})
if err != nil {
// H5 轮询不能因为 V5Pay 查询短暂失败而中断;异步回调仍然是主链路,查询只是用户返回页的补偿刷新。
return order, err
}
providerOrderID := firstNonEmpty(query.ProviderOrderID, order.ProviderOrderID)
data := V5PayNotifyData{
OrderID: firstNonEmpty(query.OrderID, order.OrderID),
ProviderOrderID: providerOrderID,
OrderStatus: query.Status,
Amount: query.Amount,
Currency: query.Currency,
ProductType: query.ProductType,
RawJSON: query.RawJSON,
}
switch strings.TrimSpace(query.Status) {
case "2":
if !v5PayOrderDataMatchesOrder(data, order) {
// 主动查询和回调使用同一笔本地订单快照校验金额、币种和产品编码;不匹配时拒绝入账,避免三方串单或通道异常。
return s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "v5pay query order data mismatch", query.RawJSON)
}
return s.repository.CreditExternalRechargeOrder(ctx, order.AppCode, order.OrderID, providerOrderID, "", query.RawJSON)
case "3", "5", "6":
return s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "v5pay order status "+query.Status, query.RawJSON)
case "0", "1", "":
return order, nil
default:
return order, nil
}
}
// HandleMifapayNotify 只在验签、订单号、币种和金额全部匹配后入账;重复回调返回 SUCCESS。
func (s *Service) HandleMifapayNotify(ctx context.Context, appCode string, notification ledger.MifaPayNotification) (ledger.ExternalRechargeOrder, bool, error) {
if s.mifaPay == nil {
@ -392,6 +660,39 @@ func (s *Service) HandleMifapayNotify(ctx context.Context, appCode string, notif
return credited, err == nil, err
}
// HandleV5PayNotify 只在验签、订单号、币种、金额和 productType 全部匹配后入账;重复回调返回 success。
func (s *Service) HandleV5PayNotify(ctx context.Context, appCode string, notification ledger.V5PayNotification) (ledger.ExternalRechargeOrder, bool, error) {
if s.v5Pay == nil {
return ledger.ExternalRechargeOrder{}, false, xerr.New(xerr.Unavailable, "v5pay client is not configured")
}
if s.repository == nil {
return ledger.ExternalRechargeOrder{}, false, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, appcode.Normalize(appCode))
data, err := s.v5Pay.ParseNotification(notification)
if err != nil {
return ledger.ExternalRechargeOrder{}, false, err
}
order, err := s.repository.GetExternalRechargeOrder(ctx, appcode.FromContext(ctx), data.OrderID)
if err != nil {
return ledger.ExternalRechargeOrder{}, false, err
}
switch strings.TrimSpace(data.OrderStatus) {
case "2":
if !v5PayOrderDataMatchesOrder(data, order) {
order, err = s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "v5pay callback amount currency or product mismatch", data.RawJSON)
return order, false, err
}
credited, err := s.repository.CreditExternalRechargeOrder(ctx, order.AppCode, order.OrderID, data.ProviderOrderID, "", data.RawJSON)
return credited, err == nil, err
case "3", "5", "6":
order, err = s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "v5pay order status "+data.OrderStatus, data.RawJSON)
return order, true, err
default:
return order, true, nil
}
}
func normalizeCreateExternalRechargeOrderCommand(command ledger.CreateExternalRechargeOrderCommand) ledger.CreateExternalRechargeOrderCommand {
command.AppCode = appcode.Normalize(command.AppCode)
command.CommandID = strings.TrimSpace(command.CommandID)
@ -504,6 +805,22 @@ func validateMifaPayMethodForOrder(method ledger.ThirdPartyPaymentMethod, comman
return nil
}
func validateV5PayMethodForOrder(method ledger.ThirdPartyPaymentMethod, command ledger.CreateExternalRechargeOrderCommand) error {
if method.ProviderCode != ledger.PaymentProviderV5Pay || method.Status != ledger.ThirdPartyPaymentStatusActive {
return xerr.New(xerr.Conflict, "v5pay method is not active")
}
if method.CountryCode != "GLOBAL" && !strings.EqualFold(method.CountryCode, command.TargetCountryCode) {
return xerr.New(xerr.Conflict, "v5pay method country does not match")
}
if strings.TrimSpace(method.PayType) == "" {
return xerr.New(xerr.InvalidArgument, "v5pay product type is required")
}
if _, err := calculateProviderAmountMinor(100, method.USDToCurrencyRate); err != nil {
return err
}
return nil
}
func calculateProviderAmountMinor(usdMinor int64, rate string) (int64, error) {
if usdMinor <= 0 {
return 0, xerr.New(xerr.InvalidArgument, "usd amount is invalid")
@ -552,6 +869,51 @@ func mifaPayQueryMatchesOrder(data MifaPayNotifyData, order ledger.ExternalRecha
return err == nil && amount == order.ProviderAmountMinor
}
func v5PayOrderDataMatchesOrder(data V5PayNotifyData, order ledger.ExternalRechargeOrder) bool {
if data.OrderID != "" && !strings.EqualFold(data.OrderID, order.OrderID) {
return false
}
if data.Currency != "" && !strings.EqualFold(data.Currency, order.CurrencyCode) {
return false
}
if data.ProductType != "" && !strings.EqualFold(data.ProductType, order.PayType) {
return false
}
amount, err := parseProviderAmountMinor(data.Amount)
return err == nil && amount == order.ProviderAmountMinor
}
func parseProviderAmountMinor(value string) (int64, error) {
value = strings.TrimSpace(value)
if value == "" {
return 0, strconv.ErrSyntax
}
if !strings.Contains(value, ".") {
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return 0, err
}
return parsed * 100, nil
}
parts := strings.SplitN(value, ".", 2)
whole, err := strconv.ParseInt(firstNonEmpty(parts[0], "0"), 10, 64)
if err != nil {
return 0, err
}
fraction := parts[1]
if len(fraction) > 2 {
fraction = fraction[:2]
}
for len(fraction) < 2 {
fraction += "0"
}
minor, err := strconv.ParseInt(fraction, 10, 64)
if err != nil {
return 0, err
}
return whole*100 + minor, nil
}
func mifaPayProviderReturnURL(returnURL string) string {
returnURL = strings.TrimSpace(returnURL)
if returnURL == "" {
@ -568,6 +930,10 @@ func mifaPayProviderReturnURL(returnURL string) string {
return parsed.String()
}
func v5PayProviderReturnURL(returnURL string) string {
return mifaPayProviderReturnURL(returnURL)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {

View File

@ -69,6 +69,7 @@ type Repository interface {
CreditExternalRechargeOrder(ctx context.Context, appCode string, orderID string, providerOrderID string, txHash string, payloadJSON string) (ledger.ExternalRechargeOrder, error)
MarkExternalRechargeOrderFailed(ctx context.Context, appCode string, orderID string, reason string, payloadJSON string) (ledger.ExternalRechargeOrder, error)
GetExternalRechargeOrder(ctx context.Context, appCode string, orderID string) (ledger.ExternalRechargeOrder, error)
ListExternalRechargeOrdersForReconcile(ctx context.Context, appCode string, limit int) ([]ledger.ExternalRechargeOrder, error)
GetDiamondExchangeConfig(ctx context.Context, userID int64) ([]ledger.DiamondExchangeRule, error)
ListWalletTransactions(ctx context.Context, query ledger.ListWalletTransactionsQuery) ([]ledger.WalletTransaction, int64, error)
ListVipPackages(ctx context.Context, userID int64) (ledger.UserVip, []ledger.VipLevel, error)
@ -133,6 +134,7 @@ type Service struct {
activity ActivityBadgeClient
googlePlay GooglePlayClient
mifaPay MifaPayClient
v5Pay V5PayClient
tronUSDT TronUSDTClient
externalRecharge ExternalRechargeConfig
now func() time.Time
@ -157,6 +159,11 @@ func (s *Service) SetMifaPayClient(client MifaPayClient) {
s.mifaPay = client
}
// SetV5PayClient 注入 V5Pay 下单、查询和验签客户端;未配置时 H5 V5Pay 下单返回依赖不可用。
func (s *Service) SetV5PayClient(client V5PayClient) {
s.v5Pay = client
}
// SetTronUSDTClient 注入 TRON TRC20 交易校验客户端;未配置时 USDT tx 提交只会保留待确认订单。
func (s *Service) SetTronUSDTClient(client TronUSDTClient) {
s.tronUSDT = client

View File

@ -2238,6 +2238,62 @@ func TestH5RechargeOptionsFilterMethodsByUserCountry(t *testing.T) {
}
}
func TestH5RechargeOptionsFilterV5PayByRuntimeProductTypes(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
v5Pay := &fakeV5PayClient{
productTypes: map[string]walletservice.V5PayProductTypesResponse{
"PH|PHP": {
PayinList: []walletservice.V5PayProductType{
{ProductType: "COINS_ONLINE", ProductName: "Coins.ph", Currency: "PHP", SupportCashierMode: 1},
{ProductType: "GCASH_ONLINE", ProductName: "GCash", Currency: "PHP", SupportCashierMode: 1},
{ProductType: "INSTAPAY_QR", ProductName: "QRPH", Currency: "PHP", SupportCashierMode: 1},
{ProductType: "QRPH_GCASH", ProductName: "QRPh GCash", Currency: "PHP", SupportCashierMode: 1},
{ProductType: "TEST_BANK", ProductName: "Test Bank", Currency: "PHP", SupportCashierMode: 1},
{ProductType: "SHOPEE_PAY", ProductName: "Shopee Pay", Currency: "PHP", SupportCashierMode: 1},
},
},
},
productErrs: map[string]error{
"SA|SAR": xerr.New(xerr.Unavailable, "v5pay product types rejected: 1013: app is invalid"),
},
}
svc.SetV5PayClient(v5Pay)
repository.CreateH5RechargeProduct(ledger.RechargeAudienceCoinSeller, 7150, 1_000_000, 800000)
phOptions, err := svc.ListH5RechargeOptions(context.Background(), ledger.H5RechargeOptionsQuery{
AppCode: appcode.Default,
TargetUserID: 41101,
TargetRegionID: 7150,
TargetCountryCode: "PH",
AudienceType: ledger.RechargeAudienceCoinSeller,
})
if err != nil {
t.Fatalf("ListH5RechargeOptions PH failed: %v", err)
}
if !paymentMethodsContain(phOptions.PaymentMethods, "PH", "Ewallet", "COINS_ONLINE") ||
!paymentMethodsContain(phOptions.PaymentMethods, "PH", "Ewallet", "GCASH_ONLINE") ||
!paymentMethodsContain(phOptions.PaymentMethods, "PH", "QR", "INSTAPAY_QR") ||
paymentMethodsContain(phOptions.PaymentMethods, "PH", "BankTransfer", "UB_ONLINE") {
t.Fatalf("PH options must only expose V5Pay runtime-enabled cashier products: %+v", phOptions.PaymentMethods)
}
saOptions, err := svc.ListH5RechargeOptions(context.Background(), ledger.H5RechargeOptionsQuery{
AppCode: appcode.Default,
TargetUserID: 41102,
TargetRegionID: 7150,
TargetCountryCode: "SA",
AudienceType: ledger.RechargeAudienceCoinSeller,
})
if err != nil {
t.Fatalf("ListH5RechargeOptions SA failed: %v", err)
}
if paymentMethodsContain(saOptions.PaymentMethods, "SA", "Ewallet", "STCPAY") ||
paymentMethodsContain(saOptions.PaymentMethods, "SA", "Card", "MADA") {
t.Fatalf("SA V5Pay methods must be hidden when app is invalid for SA/SAR: %+v", saOptions.PaymentMethods)
}
}
// TestH5MifaPayOrderRejectsPaymentMethodFromAnotherCountry 锁定下单阶段再次校验国家,防止前端伪造 method_id 绕过 options 过滤。
func TestH5MifaPayOrderRejectsPaymentMethodFromAnotherCountry(t *testing.T) {
repository := mysqltest.NewRepository(t)
@ -2533,6 +2589,80 @@ func createRedirectedMifaPayOrder(t *testing.T, repository *mysqltest.Repository
return order
}
func TestExternalRechargeReconcileCreditsV5PayOrderWithoutH5Return(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
v5Pay := &fakeV5PayClient{}
svc.SetV5PayClient(v5Pay)
svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{V5PayNotifyURL: "https://api.example.com/api/v1/payment/v5pay/notify"})
order := createRedirectedV5PayOrder(t, repository, svc, "cmd-h5-v5pay-reconcile-success", 42620, 7280)
v5Pay.queryResp = walletservice.V5PayQueryOrderResponse{
OrderID: order.OrderID,
ProviderOrderID: order.ProviderOrderID,
Status: "2",
Amount: "1.00",
Currency: order.CurrencyCode,
ProductType: order.PayType,
RawJSON: `{"orderStatus":"2"}`,
}
processed, err := svc.ReconcileExternalRechargeOrders(context.Background(), appcode.Default, 10)
if err != nil {
t.Fatalf("ReconcileExternalRechargeOrders v5pay failed: %v", err)
}
if processed != 1 {
t.Fatalf("v5pay reconcile should process one redirected order, got %d", processed)
}
if v5Pay.queryReq.ProviderOrderID != order.ProviderOrderID || v5Pay.queryReq.OrderID != order.OrderID || v5Pay.queryReq.CountryCode != "PH" || v5Pay.queryReq.CurrencyCode != "PHP" {
t.Fatalf("v5pay reconcile query input mismatch: %+v", v5Pay.queryReq)
}
credited, err := svc.GetH5RechargeOrder(context.Background(), appcode.Default, order.OrderID, order.TargetUserID)
if err != nil {
t.Fatalf("GetH5RechargeOrder after reconcile failed: %v", err)
}
if credited.Status != ledger.ExternalRechargeStatusCredited || credited.TransactionID == "" {
t.Fatalf("v5pay reconcile should credit successful order: %+v", credited)
}
assertBalance(t, svc, order.TargetUserID, ledger.AssetCoin, 120000)
if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", credited.TransactionID); got != 1 {
t.Fatalf("v5pay reconcile credit should write one recharge record, got %d", got)
}
processed, err = svc.ReconcileExternalRechargeOrders(context.Background(), appcode.Default, 10)
if err != nil {
t.Fatalf("second v5pay reconcile failed: %v", err)
}
if processed != 0 {
t.Fatalf("credited v5pay order must not stay in reconcile queue, got %d", processed)
}
}
func createRedirectedV5PayOrder(t *testing.T, repository *mysqltest.Repository, svc *walletservice.Service, commandID string, targetUserID int64, targetRegionID int64) ledger.ExternalRechargeOrder {
t.Helper()
product := repository.CreateH5RechargeProduct(ledger.RechargeAudienceNormal, targetRegionID, 1_000_000, 120000)
phQRMethodID := repository.ThirdPartyProviderPaymentMethodID(ledger.PaymentProviderV5Pay, "PH", "QR", "INSTAPAY_QR")
order, err := svc.CreateH5RechargeOrder(context.Background(), ledger.CreateExternalRechargeOrderCommand{
AppCode: appcode.Default,
CommandID: commandID,
TargetUserID: targetUserID,
TargetRegionID: targetRegionID,
TargetCountryCode: "PH",
AudienceType: ledger.RechargeAudienceNormal,
ProductID: product.ProductID,
ProviderCode: ledger.PaymentProviderV5Pay,
PaymentMethodID: phQRMethodID,
PayerName: "normal65",
PayerAccount: "163065",
PayerEmail: "normal65@lalu.com",
})
if err != nil {
t.Fatalf("CreateH5RechargeOrder v5pay failed: %v", err)
}
if order.Status != ledger.ExternalRechargeStatusRedirected || order.ProviderOrderID == "" || order.PayURL == "" || order.PayType != "INSTAPAY_QR" {
t.Fatalf("v5pay test order should be redirected: %+v", order)
}
return order
}
// TestH5USDTRechargeCreditsNormalUserCoinWallet 验证 H5 普通用户 USDT 充值只进入普通 COIN 钱包,并写普通充值记录。
func TestH5USDTRechargeCreditsNormalUserCoinWallet(t *testing.T) {
repository := mysqltest.NewRepository(t)
@ -5369,6 +5499,53 @@ func (f *fakeMifaPayClient) ParseNotification(ledger.MifaPayNotification) (walle
return f.notifyData, nil
}
type fakeV5PayClient struct {
createReq walletservice.V5PayCreateOrderRequest
createResp walletservice.V5PayCreateOrderResponse
queryReq walletservice.V5PayQueryOrderRequest
queryResp walletservice.V5PayQueryOrderResponse
notifyData walletservice.V5PayNotifyData
productReqs []walletservice.V5PayProductTypesRequest
productTypes map[string]walletservice.V5PayProductTypesResponse
productErrs map[string]error
err error
}
func (f *fakeV5PayClient) CreateOrder(_ context.Context, req walletservice.V5PayCreateOrderRequest) (walletservice.V5PayCreateOrderResponse, error) {
f.createReq = req
if f.err != nil {
return walletservice.V5PayCreateOrderResponse{}, f.err
}
if f.createResp.PayURL == "" {
return walletservice.V5PayCreateOrderResponse{OrderID: req.OrderID, ProviderOrderID: "v5_" + req.OrderID, PayURL: "https://v5pay.example/" + req.OrderID, RawJSON: `{"ok":true}`}, nil
}
return f.createResp, nil
}
func (f *fakeV5PayClient) QueryOrder(_ context.Context, req walletservice.V5PayQueryOrderRequest) (walletservice.V5PayQueryOrderResponse, error) {
f.queryReq = req
if f.err != nil {
return walletservice.V5PayQueryOrderResponse{}, f.err
}
return f.queryResp, nil
}
func (f *fakeV5PayClient) ListProductTypes(_ context.Context, req walletservice.V5PayProductTypesRequest) (walletservice.V5PayProductTypesResponse, error) {
f.productReqs = append(f.productReqs, req)
key := strings.ToUpper(strings.TrimSpace(req.CountryCode)) + "|" + strings.ToUpper(strings.TrimSpace(req.CurrencyCode))
if err := f.productErrs[key]; err != nil {
return walletservice.V5PayProductTypesResponse{}, err
}
return f.productTypes[key], nil
}
func (f *fakeV5PayClient) ParseNotification(ledger.V5PayNotification) (walletservice.V5PayNotifyData, error) {
if f.err != nil {
return walletservice.V5PayNotifyData{}, f.err
}
return f.notifyData, nil
}
type fakeTronUSDTClient struct {
lastTxHash string
lastToAddress string

View File

@ -39,6 +39,7 @@ const (
bizTypeSalaryTransferToCoinSeller = "salary_transfer_to_coin_seller"
bizTypeGooglePlayRecharge = "google_play_recharge"
bizTypeMifaPayRecharge = "mifapay"
bizTypeV5PayRecharge = "v5pay"
bizTypeUSDTTRC20Recharge = "usdt_trc20"
bizTypeGameDebit = "game_debit"
bizTypeGameCredit = "game_credit"
@ -248,6 +249,7 @@ func ensureExternalRechargeSchema(ctx context.Context, db *sql.DB) error {
UNIQUE KEY uk_external_recharge_command (app_code, command_id),
KEY idx_external_recharge_user_time (app_code, target_user_id, created_at_ms),
KEY idx_external_recharge_provider_order (app_code, provider_code, provider_order_id),
KEY idx_external_recharge_reconcile (app_code, status, provider_code, updated_at_ms, created_at_ms),
KEY idx_external_recharge_tx (app_code, provider_code, tx_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='H5 外部充值订单表'`); err != nil {
return err

View File

@ -99,6 +99,13 @@ func seedThirdPartyPaymentDefaults(ctx context.Context, db *sql.DB) error {
ON DUPLICATE KEY UPDATE provider_name = VALUES(provider_name), status = VALUES(status), sort_order = VALUES(sort_order), updated_at_ms = VALUES(updated_at_ms)`, nowMS, nowMS); err != nil {
return err
}
if _, err := db.ExecContext(ctx, `
INSERT INTO third_party_payment_channels (
app_code, provider_code, provider_name, status, sort_order, created_at_ms, updated_at_ms
) VALUES ('lalu', 'v5pay', 'V5Pay', 'active', 20, ?, ?)
ON DUPLICATE KEY UPDATE provider_name = VALUES(provider_name), status = VALUES(status), sort_order = VALUES(sort_order), updated_at_ms = VALUES(updated_at_ms)`, nowMS, nowMS); err != nil {
return err
}
// MiFaPay 是 H5 的统一三方收银台入口:后台只管理 MiFaPay 自己支持的国家和支付产品,不再关心任何下游通道名称。
// 这里只 seed 可运营的 payWay/payType 清单;商户号、商户标识、商户私钥、平台公钥都属于运行环境密钥,不进入数据库 seed。
methods := []seedPaymentMethod{
@ -168,6 +175,78 @@ func seedThirdPartyPaymentDefaults(ctx context.Context, db *sql.DB) error {
return err
}
}
// V5Pay 的 pay_type 直接保存 productTypeH5 只展示“本国家可用方式”,下单时服务端用 method_id 还原具体通道。
// 这里只 seed 文档列出的可收银台下单产品RefNo 类需要线下凭证流,不接入当前 H5 立即跳转支付链路。
v5Methods := []seedPaymentMethod{
{CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "UB_ONLINE", MethodName: "UnionBank Online", Rate: "1.00000000", SortOrder: 2010},
{CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "INSTAPAY", MethodName: "InstaPay", Rate: "1.00000000", SortOrder: 2020},
{CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "PESONET", MethodName: "PESONet", Rate: "1.00000000", SortOrder: 2030},
{CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "Ewallet", PayType: "GCASH_ONLINE", MethodName: "GCash Online", Rate: "1.00000000", SortOrder: 2040},
{CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "Ewallet", PayType: "COINS_ONLINE", MethodName: "Coins.ph Online", Rate: "1.00000000", SortOrder: 2050},
{CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "BPI_ONLINE", MethodName: "BPI Online", Rate: "1.00000000", SortOrder: 2060},
{CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "RCBC_ONLINE", MethodName: "RCBC Online", Rate: "1.00000000", SortOrder: 2070},
{CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "UCPB_ONLINE", MethodName: "UCPB Online", Rate: "1.00000000", SortOrder: 2080},
{CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "ROBINSONSBANK_ONLINE", MethodName: "Robinsons Bank Online", Rate: "1.00000000", SortOrder: 2090},
{CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "PSBANK_ONLINE", MethodName: "PSBank Online", Rate: "1.00000000", SortOrder: 2100},
{CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "METROBANK_ONLINE", MethodName: "Metrobank Online", Rate: "1.00000000", SortOrder: 2110},
{CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "LANDBANK_ONLINE", MethodName: "Landbank Online", Rate: "1.00000000", SortOrder: 2120},
{CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "CHINABANK_ONLINE", MethodName: "China Bank Online", Rate: "1.00000000", SortOrder: 2130},
{CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "BDO_ONLINE", MethodName: "BDO Online", Rate: "1.00000000", SortOrder: 2140},
{CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "BankTransfer", PayType: "BOC_ONLINE", MethodName: "Bank of Commerce Online", Rate: "1.00000000", SortOrder: 2150},
{CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "Ewallet", PayType: "GRABPAY_ONLINE", MethodName: "GrabPay Online", Rate: "1.00000000", SortOrder: 2160},
{CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "QR", PayType: "INSTAPAY_QR", MethodName: "InstaPay QR", Rate: "1.00000000", SortOrder: 2170},
{CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "QR", PayType: "ALIPAY_QR", MethodName: "Alipay QR", Rate: "1.00000000", SortOrder: 2180},
{CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "Ewallet", PayType: "ALIPAY_PLUS", MethodName: "Alipay+", Rate: "1.00000000", SortOrder: 2190},
{CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "Billing", PayType: "BILL", MethodName: "Bills Payment", Rate: "1.00000000", SortOrder: 2200},
{CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", PayWay: "QR", PayType: "QRPH_GCASH", MethodName: "QRPh GCash", Rate: "1.00000000", SortOrder: 2210},
{CountryCode: "SA", CountryName: "Saudi Arabia", CurrencyCode: "SAR", PayWay: "Ewallet", PayType: "STCPAY", MethodName: "STC Pay", Rate: "1.00000000", SortOrder: 2310},
{CountryCode: "SA", CountryName: "Saudi Arabia", CurrencyCode: "SAR", PayWay: "Card", PayType: "MADA", MethodName: "MADA", Rate: "1.00000000", SortOrder: 2320},
{CountryCode: "SA", CountryName: "Saudi Arabia", CurrencyCode: "SAR", PayWay: "Ewallet", PayType: "APPLEPAY", MethodName: "Apple Pay", Rate: "1.00000000", SortOrder: 2330},
{CountryCode: "EG", CountryName: "Egypt", CurrencyCode: "EGP", PayWay: "Card", PayType: "CARD", MethodName: "Egypt Card", Rate: "1.00000000", SortOrder: 2410},
{CountryCode: "AE", CountryName: "United Arab Emirates", CurrencyCode: "AED", PayWay: "Card", PayType: "CARD", MethodName: "UAE Card", Rate: "1.00000000", SortOrder: 2510},
{CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "BankTransfer", PayType: "BNI_VA", MethodName: "BNI Virtual Account", Rate: "1.00000000", SortOrder: 2610},
{CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "BankTransfer", PayType: "BRI_VA", MethodName: "BRI Virtual Account", Rate: "1.00000000", SortOrder: 2620},
{CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "BankTransfer", PayType: "CIMB_VA", MethodName: "CIMB Virtual Account", Rate: "1.00000000", SortOrder: 2630},
{CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "BankTransfer", PayType: "MANDIRI_VA", MethodName: "Mandiri Virtual Account", Rate: "1.00000000", SortOrder: 2640},
{CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "BankTransfer", PayType: "MAYBANK_VA", MethodName: "Maybank Virtual Account", Rate: "1.00000000", SortOrder: 2650},
{CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "QR", PayType: "NOBU_BANK_QRIS", MethodName: "Nobu Bank QRIS", Rate: "1.00000000", SortOrder: 2660},
{CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "Ewallet", PayType: "OVO", MethodName: "OVO", Rate: "1.00000000", SortOrder: 2670},
{CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "BankTransfer", PayType: "PERMATA_VA", MethodName: "Permata Virtual Account", Rate: "1.00000000", SortOrder: 2680},
{CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "Ewallet", PayType: "SHOPEEPAY_JUMPAPP", MethodName: "ShopeePay Jump App", Rate: "1.00000000", SortOrder: 2690},
{CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "Ewallet", PayType: "GOPAY", MethodName: "GoPay", Rate: "1.00000000", SortOrder: 2700},
{CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", PayWay: "Ewallet", PayType: "ALIPAY_CN_ALIPAYPLUS", MethodName: "Alipay+ Indonesia", Rate: "1.00000000", SortOrder: 2710},
{CountryCode: "IN", CountryName: "India", CurrencyCode: "INR", PayWay: "BankTransfer", PayType: "BANK_ONLINE", MethodName: "Bank Online", Rate: "1.00000000", SortOrder: 2810},
{CountryCode: "IN", CountryName: "India", CurrencyCode: "INR", PayWay: "UPI", PayType: "UPI", MethodName: "UPI", Rate: "1.00000000", SortOrder: 2820},
{CountryCode: "IN", CountryName: "India", CurrencyCode: "INR", PayWay: "BankTransfer", PayType: "ONLINE_BANKING", MethodName: "Online Banking", Rate: "1.00000000", SortOrder: 2830},
{CountryCode: "PK", CountryName: "Pakistan", CurrencyCode: "PKR", PayWay: "Ewallet", PayType: "EASYPAISA", MethodName: "Easypaisa", Rate: "1.00000000", SortOrder: 2910},
{CountryCode: "PK", CountryName: "Pakistan", CurrencyCode: "PKR", PayWay: "Ewallet", PayType: "JAZZCASH", MethodName: "JazzCash", Rate: "1.00000000", SortOrder: 2920},
{CountryCode: "PK", CountryName: "Pakistan", CurrencyCode: "PKR", PayWay: "BankTransfer", PayType: "ALFA", MethodName: "Bank Alfalah", Rate: "1.00000000", SortOrder: 2930},
{CountryCode: "PK", CountryName: "Pakistan", CurrencyCode: "PKR", PayWay: "Ewallet", PayType: "HBL_KONNECT", MethodName: "HBL Konnect", Rate: "1.00000000", SortOrder: 2940},
{CountryCode: "PK", CountryName: "Pakistan", CurrencyCode: "PKR", PayWay: "Billing", PayType: "BILL_PK", MethodName: "Pakistan Bill Payment", Rate: "1.00000000", SortOrder: 2950},
}
for _, method := range v5Methods {
rate := strings.TrimSpace(method.Rate)
if rate == "" {
rate = "1.00000000"
}
if _, err := db.ExecContext(ctx, `
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', 'v5pay', ?, ?, ?, ?, ?, ?, '', 'active', ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
country_name = VALUES(country_name),
currency_code = VALUES(currency_code),
method_name = VALUES(method_name),
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,
); err != nil {
return err
}
}
return nil
}
@ -289,13 +368,38 @@ func (r *Repository) ListH5RechargeOptions(ctx context.Context, query ledger.H5R
if err != nil {
return ledger.H5RechargeOptions{}, err
}
methods, err := r.listThirdPartyPaymentMethods(ctx, appcode.FromContext(ctx), ledger.PaymentProviderMifaPay, strings.ToUpper(strings.TrimSpace(query.TargetCountryCode)), false, true)
methods, err := r.listH5ThirdPartyPaymentMethods(ctx, appcode.FromContext(ctx), strings.ToUpper(strings.TrimSpace(query.TargetCountryCode)))
if err != nil {
return ledger.H5RechargeOptions{}, err
}
return ledger.H5RechargeOptions{Products: products, PaymentMethods: methods}, nil
}
func (r *Repository) listH5ThirdPartyPaymentMethods(ctx context.Context, appCode string, countryCode string) ([]ledger.ThirdPartyPaymentMethod, error) {
where := `WHERE m.app_code = ? AND m.status = 'active' AND m.usd_to_currency_rate > 0`
args := []any{appCode}
if countryCode != "" {
where += ` AND m.country_code IN (?, 'GLOBAL')`
args = append(args, countryCode)
}
rows, err := r.db.QueryContext(ctx, thirdPartyPaymentMethodSelectSQL()+`
`+where+`
ORDER BY c.sort_order ASC, CASE WHEN m.country_code = 'GLOBAL' THEN 1 ELSE 0 END ASC, m.sort_order ASC, m.method_id ASC`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
methods := make([]ledger.ThirdPartyPaymentMethod, 0)
for rows.Next() {
method, err := scanThirdPartyPaymentMethod(rows)
if err != nil {
return nil, err
}
methods = append(methods, method)
}
return methods, rows.Err()
}
func (r *Repository) listThirdPartyPaymentMethods(ctx context.Context, appCode string, providerCode string, countryCode string, includeDisabled bool, requireRate bool) ([]ledger.ThirdPartyPaymentMethod, error) {
where := `WHERE m.app_code = ? AND m.provider_code = ?`
args := []any{appCode, providerCode}
@ -620,6 +724,41 @@ func (r *Repository) GetExternalRechargeOrder(ctx context.Context, appCode strin
return order, err
}
func (r *Repository) ListExternalRechargeOrdersForReconcile(ctx context.Context, appCode string, limit int) ([]ledger.ExternalRechargeOrder, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
ctx = contextWithCommandApp(ctx, appCode)
if limit <= 0 {
return nil, nil
}
if limit > 500 {
limit = 500
}
rows, err := r.db.QueryContext(ctx, externalRechargeOrderSelectSQL()+`
WHERE app_code = ?
AND provider_code IN (?, ?)
AND status = ?
ORDER BY updated_at_ms ASC, created_at_ms ASC
LIMIT ?`,
appcode.FromContext(ctx), ledger.PaymentProviderMifaPay, ledger.PaymentProviderV5Pay,
ledger.ExternalRechargeStatusRedirected, limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
orders := make([]ledger.ExternalRechargeOrder, 0, limit)
for rows.Next() {
order, err := scanExternalRechargeOrder(rows)
if err != nil {
return nil, err
}
orders = append(orders, order)
}
return orders, rows.Err()
}
func (r *Repository) lookupExternalRechargeOrderByCommand(ctx context.Context, appCode string, commandID string) (ledger.ExternalRechargeOrder, bool, error) {
order, err := scanExternalRechargeOrder(r.db.QueryRowContext(ctx, externalRechargeOrderSelectSQL()+`
WHERE app_code = ? AND command_id = ?`,
@ -764,6 +903,8 @@ func providerRechargeBizType(providerCode string) string {
switch ledger.NormalizePaymentProvider(providerCode) {
case ledger.PaymentProviderMifaPay:
return bizTypeMifaPayRecharge
case ledger.PaymentProviderV5Pay:
return bizTypeV5PayRecharge
case ledger.PaymentProviderUSDTTRC20:
return bizTypeUSDTTRC20Recharge
default:

View File

@ -387,19 +387,24 @@ func (r *Repository) CreateH5RechargeProduct(audienceType string, regionID int64
// ThirdPartyPaymentMethodID 返回 initdb/迁移种子里的真实 MiFaPay method_id用于 H5 下单和跨国家拒绝测试。
func (r *Repository) ThirdPartyPaymentMethodID(countryCode string, payWay string, payType string) int64 {
return r.ThirdPartyProviderPaymentMethodID(ledger.PaymentProviderMifaPay, countryCode, payWay, payType)
}
// ThirdPartyProviderPaymentMethodID 返回指定三方 provider 的真实 method_id覆盖 V5Pay 和 MiFaPay 共用的支付方式模型。
func (r *Repository) ThirdPartyProviderPaymentMethodID(providerCode string, countryCode string, payWay string, payType string) int64 {
r.t.Helper()
var methodID int64
err := r.schema.DB.QueryRowContext(context.Background(), `
SELECT method_id
FROM third_party_payment_methods
WHERE app_code = 'lalu' AND provider_code = 'mifapay'
WHERE app_code = 'lalu' AND provider_code = ?
AND country_code = ? AND pay_way = ? AND pay_type = ?
LIMIT 1`,
countryCode, payWay, payType,
LIMIT 1`,
providerCode, countryCode, payWay, payType,
).Scan(&methodID)
if err != nil {
r.t.Fatalf("query third party payment method %s/%s/%s failed: %v", countryCode, payWay, payType, err)
r.t.Fatalf("query third party payment method %s/%s/%s/%s failed: %v", providerCode, countryCode, payWay, payType, err)
}
return methodID
}

View File

@ -546,6 +546,23 @@ func (s *Server) HandleMifapayNotify(ctx context.Context, req *walletv1.HandleMi
return &walletv1.HandleMifapayNotifyResponse{Accepted: accepted, ResponseText: responseText, OrderId: order.OrderID}, nil
}
// HandleV5PayNotify 验签并处理 V5Pay 回调;成功或重复成功由 HTTP 层按 response_text 返回小写 success。
func (s *Server) HandleV5PayNotify(ctx context.Context, req *walletv1.HandleV5PayNotifyRequest) (*walletv1.HandleV5PayNotifyResponse, error) {
ctx = appcode.WithContext(ctx, req.GetAppCode())
order, accepted, err := s.svc.HandleV5PayNotify(ctx, req.GetAppCode(), ledger.V5PayNotification{
Fields: req.GetFields(),
RawJSON: req.GetRawJson(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
responseText := "fail"
if accepted {
responseText = "success"
}
return &walletv1.HandleV5PayNotifyResponse{Accepted: accepted, ResponseText: responseText, OrderId: order.OrderID}, nil
}
// GetDiamondExchangeConfig 返回钻石兑换配置。
func (s *Server) GetDiamondExchangeConfig(ctx context.Context, req *walletv1.GetDiamondExchangeConfigRequest) (*walletv1.GetDiamondExchangeConfigResponse, error) {
ctx = appcode.WithContext(ctx, req.GetAppCode())