v5pay
This commit is contained in:
parent
338fc140d8
commit
7c6c3f38e5
6
Makefile
6
Makefile
@ -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
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
12
deploy/rocketmq/broker.local.conf
Normal file
12
deploy/rocketmq/broker.local.conf
Normal 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
|
||||
@ -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
|
||||
|
||||
46
scripts/apply-local-rocketmq-topics.sh
Executable file
46
scripts/apply-local-rocketmq-topics.sh
Executable 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
|
||||
@ -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
|
||||
|
||||
75
scripts/mysql/048_v5pay_payment_methods.sql
Normal file
75
scripts/mysql/048_v5pay_payment_methods.sql
Normal 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'));
|
||||
2
scripts/mysql/049_external_recharge_reconcile_index.sql
Normal file
2
scripts/mysql/049_external_recharge_reconcile_index.sql
Normal 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);
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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{{
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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/币商转账,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"
|
||||
)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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 {
|
||||
|
||||
410
services/wallet-service/internal/client/v5pay/client.go
Normal file
410
services/wallet-service/internal/client/v5pay/client.go
Normal 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 收银台接口并返回 checkoutUrl;productType 由后台支付方式配置决定。
|
||||
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 ""
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
},
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 != "" {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 直接保存 productType,H5 只展示“本国家可用方式”,下单时服务端用 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:
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user