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
|
./scripts/resolve-compose-container-conflicts.sh
|
||||||
docker compose up -d mysql redis
|
docker compose up -d mysql redis
|
||||||
./scripts/apply-local-mysql-initdb.sh
|
./scripts/apply-local-mysql-initdb.sh
|
||||||
|
./scripts/apply-local-rocketmq-topics.sh
|
||||||
docker compose stop $(GO_RUN_SERVICES) >/dev/null 2>&1 || true
|
docker compose stop $(GO_RUN_SERVICES) >/dev/null 2>&1 || true
|
||||||
./scripts/print-compose-addresses.sh
|
./scripts/print-compose-addresses.sh
|
||||||
TZ=UTC go run ./cmd/dev-run -services "$(DEV_RUN_SERVICE_LIST)"
|
TZ=UTC go run ./cmd/dev-run -services "$(DEV_RUN_SERVICE_LIST)"
|
||||||
@ -69,6 +70,7 @@ admin-deps:
|
|||||||
./scripts/resolve-compose-container-conflicts.sh
|
./scripts/resolve-compose-container-conflicts.sh
|
||||||
docker compose up -d mysql redis
|
docker compose up -d mysql redis
|
||||||
./scripts/apply-local-mysql-initdb.sh
|
./scripts/apply-local-mysql-initdb.sh
|
||||||
|
./scripts/apply-local-rocketmq-topics.sh
|
||||||
docker compose up --build -d user-service
|
docker compose up --build -d user-service
|
||||||
./scripts/print-compose-addresses.sh
|
./scripts/print-compose-addresses.sh
|
||||||
|
|
||||||
@ -92,11 +94,13 @@ up:
|
|||||||
@if [ -z "$(SERVICE_ID)" ]; then \
|
@if [ -z "$(SERVICE_ID)" ]; then \
|
||||||
docker compose up -d mysql redis; \
|
docker compose up -d mysql redis; \
|
||||||
./scripts/apply-local-mysql-initdb.sh; \
|
./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; \
|
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 \
|
elif [ "$(SERVICE_ID)" = "mysql" ] || [ "$(SERVICE_ID)" = "redis" ]; then \
|
||||||
docker compose up -d $(SERVICE_ID); \
|
docker compose up -d $(SERVICE_ID); \
|
||||||
else \
|
else \
|
||||||
./scripts/apply-local-mysql-initdb.sh; \
|
./scripts/apply-local-mysql-initdb.sh; \
|
||||||
|
./scripts/apply-local-rocketmq-topics.sh; \
|
||||||
docker compose up -d $(SERVICE_ID); \
|
docker compose up -d $(SERVICE_ID); \
|
||||||
fi
|
fi
|
||||||
./scripts/print-compose-addresses.sh
|
./scripts/print-compose-addresses.sh
|
||||||
@ -114,6 +118,7 @@ rebuild: check-service
|
|||||||
./scripts/resolve-compose-container-conflicts.sh
|
./scripts/resolve-compose-container-conflicts.sh
|
||||||
@if [ "$(SERVICE_ID)" != "mysql" ] && [ "$(SERVICE_ID)" != "redis" ]; then \
|
@if [ "$(SERVICE_ID)" != "mysql" ] && [ "$(SERVICE_ID)" != "redis" ]; then \
|
||||||
./scripts/apply-local-mysql-initdb.sh; \
|
./scripts/apply-local-mysql-initdb.sh; \
|
||||||
|
./scripts/apply-local-rocketmq-topics.sh; \
|
||||||
fi
|
fi
|
||||||
docker compose up -d --build $(SERVICE_ID)
|
docker compose up -d --build $(SERVICE_ID)
|
||||||
./scripts/print-compose-addresses.sh
|
./scripts/print-compose-addresses.sh
|
||||||
@ -174,6 +179,7 @@ up-service: check-service
|
|||||||
./scripts/resolve-compose-container-conflicts.sh
|
./scripts/resolve-compose-container-conflicts.sh
|
||||||
@if [ "$(SERVICE_ID)" != "mysql" ] && [ "$(SERVICE_ID)" != "redis" ]; then \
|
@if [ "$(SERVICE_ID)" != "mysql" ] && [ "$(SERVICE_ID)" != "redis" ]; then \
|
||||||
./scripts/apply-local-mysql-initdb.sh; \
|
./scripts/apply-local-mysql-initdb.sh; \
|
||||||
|
./scripts/apply-local-rocketmq-topics.sh; \
|
||||||
fi
|
fi
|
||||||
docker compose up -d $(SERVICE_ID)
|
docker compose up -d $(SERVICE_ID)
|
||||||
./scripts/print-compose-addresses.sh
|
./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;
|
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 {
|
message DiamondExchangeRule {
|
||||||
string exchange_type = 1;
|
string exchange_type = 1;
|
||||||
string from_asset_type = 2;
|
string from_asset_type = 2;
|
||||||
@ -2004,6 +2017,7 @@ service WalletService {
|
|||||||
rpc SubmitH5RechargeTx(SubmitH5RechargeTxRequest) returns (H5RechargeOrderResponse);
|
rpc SubmitH5RechargeTx(SubmitH5RechargeTxRequest) returns (H5RechargeOrderResponse);
|
||||||
rpc GetH5RechargeOrder(GetH5RechargeOrderRequest) returns (H5RechargeOrderResponse);
|
rpc GetH5RechargeOrder(GetH5RechargeOrderRequest) returns (H5RechargeOrderResponse);
|
||||||
rpc HandleMifapayNotify(HandleMifapayNotifyRequest) returns (HandleMifapayNotifyResponse);
|
rpc HandleMifapayNotify(HandleMifapayNotifyRequest) returns (HandleMifapayNotifyResponse);
|
||||||
|
rpc HandleV5PayNotify(HandleV5PayNotifyRequest) returns (HandleV5PayNotifyResponse);
|
||||||
rpc GetDiamondExchangeConfig(GetDiamondExchangeConfigRequest) returns (GetDiamondExchangeConfigResponse);
|
rpc GetDiamondExchangeConfig(GetDiamondExchangeConfigRequest) returns (GetDiamondExchangeConfigResponse);
|
||||||
rpc ListWalletTransactions(ListWalletTransactionsRequest) returns (ListWalletTransactionsResponse);
|
rpc ListWalletTransactions(ListWalletTransactionsRequest) returns (ListWalletTransactionsResponse);
|
||||||
rpc ListVipPackages(ListVipPackagesRequest) returns (ListVipPackagesResponse);
|
rpc ListVipPackages(ListVipPackagesRequest) returns (ListVipPackagesResponse);
|
||||||
|
|||||||
@ -260,6 +260,7 @@ const (
|
|||||||
WalletService_SubmitH5RechargeTx_FullMethodName = "/hyapp.wallet.v1.WalletService/SubmitH5RechargeTx"
|
WalletService_SubmitH5RechargeTx_FullMethodName = "/hyapp.wallet.v1.WalletService/SubmitH5RechargeTx"
|
||||||
WalletService_GetH5RechargeOrder_FullMethodName = "/hyapp.wallet.v1.WalletService/GetH5RechargeOrder"
|
WalletService_GetH5RechargeOrder_FullMethodName = "/hyapp.wallet.v1.WalletService/GetH5RechargeOrder"
|
||||||
WalletService_HandleMifapayNotify_FullMethodName = "/hyapp.wallet.v1.WalletService/HandleMifapayNotify"
|
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_GetDiamondExchangeConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/GetDiamondExchangeConfig"
|
||||||
WalletService_ListWalletTransactions_FullMethodName = "/hyapp.wallet.v1.WalletService/ListWalletTransactions"
|
WalletService_ListWalletTransactions_FullMethodName = "/hyapp.wallet.v1.WalletService/ListWalletTransactions"
|
||||||
WalletService_ListVipPackages_FullMethodName = "/hyapp.wallet.v1.WalletService/ListVipPackages"
|
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)
|
SubmitH5RechargeTx(ctx context.Context, in *SubmitH5RechargeTxRequest, opts ...grpc.CallOption) (*H5RechargeOrderResponse, error)
|
||||||
GetH5RechargeOrder(ctx context.Context, in *GetH5RechargeOrderRequest, 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)
|
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)
|
GetDiamondExchangeConfig(ctx context.Context, in *GetDiamondExchangeConfigRequest, opts ...grpc.CallOption) (*GetDiamondExchangeConfigResponse, error)
|
||||||
ListWalletTransactions(ctx context.Context, in *ListWalletTransactionsRequest, opts ...grpc.CallOption) (*ListWalletTransactionsResponse, error)
|
ListWalletTransactions(ctx context.Context, in *ListWalletTransactionsRequest, opts ...grpc.CallOption) (*ListWalletTransactionsResponse, error)
|
||||||
ListVipPackages(ctx context.Context, in *ListVipPackagesRequest, opts ...grpc.CallOption) (*ListVipPackagesResponse, 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
|
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) {
|
func (c *walletServiceClient) GetDiamondExchangeConfig(ctx context.Context, in *GetDiamondExchangeConfigRequest, opts ...grpc.CallOption) (*GetDiamondExchangeConfigResponse, error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(GetDiamondExchangeConfigResponse)
|
out := new(GetDiamondExchangeConfigResponse)
|
||||||
@ -1292,6 +1304,7 @@ type WalletServiceServer interface {
|
|||||||
SubmitH5RechargeTx(context.Context, *SubmitH5RechargeTxRequest) (*H5RechargeOrderResponse, error)
|
SubmitH5RechargeTx(context.Context, *SubmitH5RechargeTxRequest) (*H5RechargeOrderResponse, error)
|
||||||
GetH5RechargeOrder(context.Context, *GetH5RechargeOrderRequest) (*H5RechargeOrderResponse, error)
|
GetH5RechargeOrder(context.Context, *GetH5RechargeOrderRequest) (*H5RechargeOrderResponse, error)
|
||||||
HandleMifapayNotify(context.Context, *HandleMifapayNotifyRequest) (*HandleMifapayNotifyResponse, error)
|
HandleMifapayNotify(context.Context, *HandleMifapayNotifyRequest) (*HandleMifapayNotifyResponse, error)
|
||||||
|
HandleV5PayNotify(context.Context, *HandleV5PayNotifyRequest) (*HandleV5PayNotifyResponse, error)
|
||||||
GetDiamondExchangeConfig(context.Context, *GetDiamondExchangeConfigRequest) (*GetDiamondExchangeConfigResponse, error)
|
GetDiamondExchangeConfig(context.Context, *GetDiamondExchangeConfigRequest) (*GetDiamondExchangeConfigResponse, error)
|
||||||
ListWalletTransactions(context.Context, *ListWalletTransactionsRequest) (*ListWalletTransactionsResponse, error)
|
ListWalletTransactions(context.Context, *ListWalletTransactionsRequest) (*ListWalletTransactionsResponse, error)
|
||||||
ListVipPackages(context.Context, *ListVipPackagesRequest) (*ListVipPackagesResponse, 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) {
|
func (UnimplementedWalletServiceServer) HandleMifapayNotify(context.Context, *HandleMifapayNotifyRequest) (*HandleMifapayNotifyResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method HandleMifapayNotify not implemented")
|
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) {
|
func (UnimplementedWalletServiceServer) GetDiamondExchangeConfig(context.Context, *GetDiamondExchangeConfigRequest) (*GetDiamondExchangeConfigResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method GetDiamondExchangeConfig not implemented")
|
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)
|
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) {
|
func _WalletService_GetDiamondExchangeConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(GetDiamondExchangeConfigRequest)
|
in := new(GetDiamondExchangeConfigRequest)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
@ -3355,6 +3389,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "HandleMifapayNotify",
|
MethodName: "HandleMifapayNotify",
|
||||||
Handler: _WalletService_HandleMifapayNotify_Handler,
|
Handler: _WalletService_HandleMifapayNotify_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "HandleV5PayNotify",
|
||||||
|
Handler: _WalletService_HandleV5PayNotify_Handler,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
MethodName: "GetDiamondExchangeConfig",
|
MethodName: "GetDiamondExchangeConfig",
|
||||||
Handler: _WalletService_GetDiamondExchangeConfig_Handler,
|
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"
|
- "10911:10911"
|
||||||
- "19011:10911"
|
- "19011:10911"
|
||||||
volumes:
|
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
|
- rocketmq-store:/home/rocketmq/store
|
||||||
depends_on:
|
depends_on:
|
||||||
- rocketmq-namesrv
|
- 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
|
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
|
updated_at_ms = UNIX_TIMESTAMP(CURRENT_TIMESTAMP(3)) * 1000
|
||||||
WHERE app_code = 'lalu'
|
WHERE app_code = 'lalu'
|
||||||
AND region_id = 0
|
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",
|
Status: "active",
|
||||||
UsdToCurrencyRate: "1.00000000",
|
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))
|
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()
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
router.ServeHTTP(recorder, request)
|
router.ServeHTTP(recorder, request)
|
||||||
@ -51,7 +71,7 @@ func TestListThirdPartyPaymentChannelsForwardsIncludeDisabledMethods(t *testing.
|
|||||||
}
|
}
|
||||||
if wallet.lastThirdPartyChannels == nil ||
|
if wallet.lastThirdPartyChannels == nil ||
|
||||||
wallet.lastThirdPartyChannels.GetAppCode() != "lalu" ||
|
wallet.lastThirdPartyChannels.GetAppCode() != "lalu" ||
|
||||||
wallet.lastThirdPartyChannels.GetProviderCode() != "mifapay" ||
|
wallet.lastThirdPartyChannels.GetProviderCode() != "" ||
|
||||||
wallet.lastThirdPartyChannels.GetStatus() != "active" ||
|
wallet.lastThirdPartyChannels.GetStatus() != "active" ||
|
||||||
!wallet.lastThirdPartyChannels.GetIncludeDisabledMethods() {
|
!wallet.lastThirdPartyChannels.GetIncludeDisabledMethods() {
|
||||||
t.Fatalf("third party channels request mismatch: %+v", wallet.lastThirdPartyChannels)
|
t.Fatalf("third party channels request mismatch: %+v", wallet.lastThirdPartyChannels)
|
||||||
@ -62,8 +82,10 @@ func TestListThirdPartyPaymentChannelsForwardsIncludeDisabledMethods(t *testing.
|
|||||||
}
|
}
|
||||||
items := response.Data["items"].([]any)
|
items := response.Data["items"].([]any)
|
||||||
channel := items[0].(map[string]any)
|
channel := items[0].(map[string]any)
|
||||||
|
v5Channel := items[1].(map[string]any)
|
||||||
methods := channel["methods"].([]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)
|
t.Fatalf("third party channels response mismatch: %+v", response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -119,6 +141,14 @@ func TestSyncThirdPartyPaymentRatesUpdatesAllMethodsFromFetchedCurrencyRates(t *
|
|||||||
{MethodId: 810, CurrencyCode: "SAR", Status: "active"},
|
{MethodId: 810, CurrencyCode: "SAR", Status: "active"},
|
||||||
{MethodId: 811, CurrencyCode: "BHD", Status: "disabled"},
|
{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)
|
handler := New(wallet, nil, nil)
|
||||||
@ -138,7 +168,7 @@ func TestSyncThirdPartyPaymentRatesUpdatesAllMethodsFromFetchedCurrencyRates(t *
|
|||||||
if wallet.lastThirdPartyChannels == nil || !wallet.lastThirdPartyChannels.GetIncludeDisabledMethods() {
|
if wallet.lastThirdPartyChannels == nil || !wallet.lastThirdPartyChannels.GetIncludeDisabledMethods() {
|
||||||
t.Fatalf("sync must read active and disabled methods: %+v", wallet.lastThirdPartyChannels)
|
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)
|
t.Fatalf("sync should update every method with a fetched currency rate: %+v", wallet.updateRateRequests)
|
||||||
}
|
}
|
||||||
got := map[int64]string{}
|
got := map[int64]string{}
|
||||||
@ -148,14 +178,14 @@ func TestSyncThirdPartyPaymentRatesUpdatesAllMethodsFromFetchedCurrencyRates(t *
|
|||||||
}
|
}
|
||||||
got[req.GetMethodId()] = req.GetUsdToCurrencyRate()
|
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)
|
t.Fatalf("synced rates mismatch: %+v", got)
|
||||||
}
|
}
|
||||||
var response adminPaymentTestResponse
|
var response adminPaymentTestResponse
|
||||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||||
t.Fatalf("decode response failed: %v", err)
|
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)
|
t.Fatalf("sync response mismatch: %+v", response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,7 @@ type WalletClient interface {
|
|||||||
SubmitH5RechargeTx(ctx context.Context, req *walletv1.SubmitH5RechargeTxRequest) (*walletv1.H5RechargeOrderResponse, error)
|
SubmitH5RechargeTx(ctx context.Context, req *walletv1.SubmitH5RechargeTxRequest) (*walletv1.H5RechargeOrderResponse, error)
|
||||||
GetH5RechargeOrder(ctx context.Context, req *walletv1.GetH5RechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error)
|
GetH5RechargeOrder(ctx context.Context, req *walletv1.GetH5RechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error)
|
||||||
HandleMifapayNotify(ctx context.Context, req *walletv1.HandleMifapayNotifyRequest) (*walletv1.HandleMifapayNotifyResponse, 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)
|
ConfirmGooglePayment(ctx context.Context, req *walletv1.ConfirmGooglePaymentRequest) (*walletv1.ConfirmGooglePaymentResponse, error)
|
||||||
GetDiamondExchangeConfig(ctx context.Context, req *walletv1.GetDiamondExchangeConfigRequest) (*walletv1.GetDiamondExchangeConfigResponse, error)
|
GetDiamondExchangeConfig(ctx context.Context, req *walletv1.GetDiamondExchangeConfigRequest) (*walletv1.GetDiamondExchangeConfigResponse, error)
|
||||||
ListWalletTransactions(ctx context.Context, req *walletv1.ListWalletTransactionsRequest) (*walletv1.ListWalletTransactionsResponse, 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)
|
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) {
|
func (c *grpcWalletClient) ConfirmGooglePayment(ctx context.Context, req *walletv1.ConfirmGooglePaymentRequest) (*walletv1.ConfirmGooglePaymentResponse, error) {
|
||||||
return c.client.ConfirmGooglePayment(ctx, req)
|
return c.client.ConfirmGooglePayment(ctx, req)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -234,6 +234,7 @@ type WalletHandlers struct {
|
|||||||
SubmitH5RechargeTx http.HandlerFunc
|
SubmitH5RechargeTx http.HandlerFunc
|
||||||
GetH5RechargeOrder http.HandlerFunc
|
GetH5RechargeOrder http.HandlerFunc
|
||||||
HandleMifapayNotify http.HandlerFunc
|
HandleMifapayNotify http.HandlerFunc
|
||||||
|
HandleV5PayNotify http.HandlerFunc
|
||||||
ConfirmGooglePayment http.HandlerFunc
|
ConfirmGooglePayment http.HandlerFunc
|
||||||
GetDiamondExchangeConfig http.HandlerFunc
|
GetDiamondExchangeConfig http.HandlerFunc
|
||||||
ListCoinTransactions 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}/tx", http.MethodPost, h.SubmitH5RechargeTx)
|
||||||
r.public("/recharge/h5/orders/{order_id}", http.MethodGet, h.GetH5RechargeOrder)
|
r.public("/recharge/h5/orders/{order_id}", http.MethodGet, h.GetH5RechargeOrder)
|
||||||
r.public("/payment/mifapay/notify", http.MethodPost, h.HandleMifapayNotify)
|
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/payments/google/confirm", http.MethodPost, h.ConfirmGooglePayment)
|
||||||
r.profile("/wallet/diamond-exchange/config", http.MethodGet, h.GetDiamondExchangeConfig)
|
r.profile("/wallet/diamond-exchange/config", http.MethodGet, h.GetDiamondExchangeConfig)
|
||||||
r.profile("/wallet/coin-transactions", http.MethodGet, h.ListCoinTransactions)
|
r.profile("/wallet/coin-transactions", http.MethodGet, h.ListCoinTransactions)
|
||||||
|
|||||||
@ -509,6 +509,8 @@ type fakeWalletClient struct {
|
|||||||
h5GetOrderResp *walletv1.H5RechargeOrderResponse
|
h5GetOrderResp *walletv1.H5RechargeOrderResponse
|
||||||
lastMifaPayNotify *walletv1.HandleMifapayNotifyRequest
|
lastMifaPayNotify *walletv1.HandleMifapayNotifyRequest
|
||||||
mifaPayNotifyResp *walletv1.HandleMifapayNotifyResponse
|
mifaPayNotifyResp *walletv1.HandleMifapayNotifyResponse
|
||||||
|
lastV5PayNotify *walletv1.HandleV5PayNotifyRequest
|
||||||
|
v5PayNotifyResp *walletv1.HandleV5PayNotifyResponse
|
||||||
lastGoogleConfirm *walletv1.ConfirmGooglePaymentRequest
|
lastGoogleConfirm *walletv1.ConfirmGooglePaymentRequest
|
||||||
googleConfirmResp *walletv1.ConfirmGooglePaymentResponse
|
googleConfirmResp *walletv1.ConfirmGooglePaymentResponse
|
||||||
lastDiamondExchange *walletv1.GetDiamondExchangeConfigRequest
|
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
|
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) {
|
func (f *fakeWalletClient) ConfirmGooglePayment(_ context.Context, req *walletv1.ConfirmGooglePaymentRequest) (*walletv1.ConfirmGooglePaymentResponse, error) {
|
||||||
f.lastGoogleConfirm = req
|
f.lastGoogleConfirm = req
|
||||||
if f.err != nil {
|
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) {
|
func TestRoomCommandsPropagateClientCommandID(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
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) {
|
func TestH5RechargeOptionsNormalizesSaudiCountryNameToPaymentCountryCode(t *testing.T) {
|
||||||
walletClient := &fakeWalletClient{h5OptionsResp: &walletv1.H5RechargeOptionsResponse{
|
walletClient := &fakeWalletClient{h5OptionsResp: &walletv1.H5RechargeOptionsResponse{
|
||||||
PaymentMethods: []*walletv1.ThirdPartyPaymentMethod{{
|
PaymentMethods: []*walletv1.ThirdPartyPaymentMethod{{
|
||||||
|
|||||||
@ -3,12 +3,16 @@ package walletapi
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
userv1 "hyapp.local/api/proto/user/v1"
|
userv1 "hyapp.local/api/proto/user/v1"
|
||||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||||
"hyapp/pkg/appcode"
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/pkg/logx"
|
||||||
"hyapp/services/gateway-service/internal/auth"
|
"hyapp/services/gateway-service/internal/auth"
|
||||||
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||||||
"hyapp/services/gateway-service/internal/transport/http/useridinput"
|
"hyapp/services/gateway-service/internal/transport/http/useridinput"
|
||||||
@ -18,6 +22,7 @@ const (
|
|||||||
h5RechargeAudienceNormal = "normal"
|
h5RechargeAudienceNormal = "normal"
|
||||||
h5RechargeAudienceCoinSeller = "coin_seller"
|
h5RechargeAudienceCoinSeller = "coin_seller"
|
||||||
h5PaymentProviderMifaPay = "mifapay"
|
h5PaymentProviderMifaPay = "mifapay"
|
||||||
|
h5PaymentProviderV5Pay = "v5pay"
|
||||||
h5PaymentProviderUSDTTRC20 = "usdt_trc20"
|
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) {
|
func (h *Handler) listH5RechargeOptions(writer http.ResponseWriter, request *http.Request) {
|
||||||
target, ok := h.resolveH5RechargeTarget(writer, request, request.URL.Query().Get("display_user_id"))
|
target, ok := h.resolveH5RechargeTarget(writer, request, request.URL.Query().Get("display_user_id"))
|
||||||
if !ok {
|
if !ok {
|
||||||
@ -169,7 +174,7 @@ func (h *Handler) listH5RechargeOptions(writer http.ResponseWriter, request *htt
|
|||||||
httpkit.WriteOK(writer, request, h5RechargeOptionsFromProto(target, resp))
|
httpkit.WriteOK(writer, request, h5RechargeOptionsFromProto(target, resp))
|
||||||
}
|
}
|
||||||
|
|
||||||
// createH5RechargeOrder 创建 MiFaPay 或 USDT 外部充值订单;目标账号始终由 gateway 重新解析。
|
// createH5RechargeOrder 创建本地三方支付或 USDT 外部充值订单;目标账号始终由 gateway 重新解析。
|
||||||
func (h *Handler) createH5RechargeOrder(writer http.ResponseWriter, request *http.Request) {
|
func (h *Handler) createH5RechargeOrder(writer http.ResponseWriter, request *http.Request) {
|
||||||
var body h5RechargeOrderRequestBody
|
var body h5RechargeOrderRequestBody
|
||||||
if !httpkit.Decode(writer, request, &body) {
|
if !httpkit.Decode(writer, request, &body) {
|
||||||
@ -300,6 +305,58 @@ func (h *Handler) handleMifapayNotify(writer http.ResponseWriter, request *http.
|
|||||||
writeMifaPayNotifyText(writer, http.StatusOK, "SUCCESS")
|
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) {
|
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 {
|
if h.walletClient == nil || h.userProfileClient == nil || h.userIdentityClient == nil || h.userHostClient == nil {
|
||||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
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())
|
userID := auth.UserIDFromContext(request.Context())
|
||||||
resolvedByToken := userID > 0
|
resolvedByToken := userID > 0
|
||||||
if !resolvedByToken {
|
if !resolvedByToken {
|
||||||
|
if strings.TrimSpace(displayUserID) == "" {
|
||||||
|
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||||
|
return h5RechargeTargetContext{}, false
|
||||||
|
}
|
||||||
// 无 token 入口的输入是“用户标识”,可能是 active 靓号、当前短号、默认数字 ID 或真实 user_id。
|
// 无 token 入口的输入是“用户标识”,可能是 active 靓号、当前短号、默认数字 ID 或真实 user_id。
|
||||||
// 所有传给 wallet-service 的目标都必须先在 gateway 统一收敛成内部 user_id,避免各业务入口自行解析导致靓号报错。
|
// 所有传给 wallet-service 的目标都必须先在 gateway 统一收敛成内部 user_id,避免各业务入口自行解析导致靓号报错。
|
||||||
resolvedUserID, err := useridinput.Resolve(request.Context(), h.userIdentityClient, httpkit.UserMeta(request, ""), displayUserID)
|
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 {
|
switch value {
|
||||||
case "usdt", "usdt-trc20", "usdt_trc20", "trc20":
|
case "usdt", "usdt-trc20", "usdt_trc20", "trc20":
|
||||||
return h5PaymentProviderUSDTTRC20
|
return h5PaymentProviderUSDTTRC20
|
||||||
|
case "v5", "v5pay", "v5_pay":
|
||||||
|
return h5PaymentProviderV5Pay
|
||||||
case "mifa", "mifapay", "third_party":
|
case "mifa", "mifapay", "third_party":
|
||||||
return h5PaymentProviderMifaPay
|
return h5PaymentProviderMifaPay
|
||||||
case "":
|
case "":
|
||||||
@ -685,6 +748,40 @@ func normalizeH5PaymentProvider(value string, paymentMethodID int64) string {
|
|||||||
return value
|
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 {
|
func h5RechargeAuthMode(resolvedByToken bool) string {
|
||||||
if resolvedByToken {
|
if resolvedByToken {
|
||||||
return "token"
|
return "token"
|
||||||
@ -715,3 +812,9 @@ func writeMifaPayNotifyText(writer http.ResponseWriter, statusCode int, body str
|
|||||||
writer.WriteHeader(statusCode)
|
writer.WriteHeader(statusCode)
|
||||||
_, _ = writer.Write([]byte(body))
|
_, _ = 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,
|
SubmitH5RechargeTx: h.submitH5RechargeTx,
|
||||||
GetH5RechargeOrder: h.getH5RechargeOrder,
|
GetH5RechargeOrder: h.getH5RechargeOrder,
|
||||||
HandleMifapayNotify: h.handleMifapayNotify,
|
HandleMifapayNotify: h.handleMifapayNotify,
|
||||||
|
HandleV5PayNotify: h.handleV5PayNotify,
|
||||||
ConfirmGooglePayment: h.confirmGooglePayment,
|
ConfirmGooglePayment: h.confirmGooglePayment,
|
||||||
GetDiamondExchangeConfig: h.getDiamondExchangeConfig,
|
GetDiamondExchangeConfig: h.getDiamondExchangeConfig,
|
||||||
ListCoinTransactions: h.listCoinTransactions,
|
ListCoinTransactions: h.listCoinTransactions,
|
||||||
|
|||||||
@ -545,7 +545,7 @@ func (r *Repository) ConsumeRecharge(ctx context.Context, event RechargeEvent) e
|
|||||||
switch strings.ToLower(strings.TrimSpace(event.RechargeType)) {
|
switch strings.ToLower(strings.TrimSpace(event.RechargeType)) {
|
||||||
case "google", "google_play":
|
case "google", "google_play":
|
||||||
googleUSD = event.USDMinor
|
googleUSD = event.USDMinor
|
||||||
case "mifapay":
|
case "mifapay", "v5pay":
|
||||||
mifapayUSD = event.USDMinor
|
mifapayUSD = event.USDMinor
|
||||||
case "usdt_trc20":
|
case "usdt_trc20":
|
||||||
// USDT-TRC20 是站外 H5 充值渠道;当前大盘只单列 MiFaPay/Google/币商转账,
|
// 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
|
eligible_recharge_types, effective_from_ms, effective_to_ms, created_by_user_id, created_at_ms, updated_at_ms
|
||||||
) VALUES (
|
) VALUES (
|
||||||
'lalu', 0, 'invite-valid-default-v1', 'active', 80000,
|
'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
|
) ON DUPLICATE KEY UPDATE
|
||||||
status = VALUES(status),
|
status = VALUES(status),
|
||||||
required_recharge_coin_amount = VALUES(required_recharge_coin_amount),
|
required_recharge_coin_amount = VALUES(required_recharge_coin_amount),
|
||||||
|
|||||||
@ -802,6 +802,8 @@ func normalizeInviteRechargeType(value string) string {
|
|||||||
return invitedomain.RechargeTypeGooglePlay
|
return invitedomain.RechargeTypeGooglePlay
|
||||||
case "mifapay", "mifa_pay":
|
case "mifapay", "mifa_pay":
|
||||||
return invitedomain.RechargeTypeMifaPay
|
return invitedomain.RechargeTypeMifaPay
|
||||||
|
case "v5pay", "v5_pay":
|
||||||
|
return invitedomain.RechargeTypeV5Pay
|
||||||
case "usdt", "usdt_trc20", "trc20":
|
case "usdt", "usdt_trc20", "trc20":
|
||||||
return invitedomain.RechargeTypeUSDTTRC20
|
return invitedomain.RechargeTypeUSDTTRC20
|
||||||
default:
|
default:
|
||||||
|
|||||||
@ -29,6 +29,8 @@ const (
|
|||||||
RechargeTypeGoogle = "google"
|
RechargeTypeGoogle = "google"
|
||||||
// RechargeTypeMifaPay is the third-party checkout provider value for MiFaPay.
|
// RechargeTypeMifaPay is the third-party checkout provider value for MiFaPay.
|
||||||
RechargeTypeMifaPay = "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 is the third-party checkout provider value for USDT TRC20.
|
||||||
RechargeTypeUSDTTRC20 = "usdt_trc20"
|
RechargeTypeUSDTTRC20 = "usdt_trc20"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -550,6 +550,7 @@ func defaultEligibleRechargeTypes() []string {
|
|||||||
invitedomain.RechargeTypeGooglePlay,
|
invitedomain.RechargeTypeGooglePlay,
|
||||||
invitedomain.RechargeTypeGoogle,
|
invitedomain.RechargeTypeGoogle,
|
||||||
invitedomain.RechargeTypeMifaPay,
|
invitedomain.RechargeTypeMifaPay,
|
||||||
|
invitedomain.RechargeTypeV5Pay,
|
||||||
invitedomain.RechargeTypeUSDTTRC20,
|
invitedomain.RechargeTypeUSDTTRC20,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -562,6 +563,8 @@ func normalizeRechargeType(value string) string {
|
|||||||
return invitedomain.RechargeTypeGooglePlay
|
return invitedomain.RechargeTypeGooglePlay
|
||||||
case "mifapay", "mifa_pay":
|
case "mifapay", "mifa_pay":
|
||||||
return invitedomain.RechargeTypeMifaPay
|
return invitedomain.RechargeTypeMifaPay
|
||||||
|
case "v5pay", "v5_pay":
|
||||||
|
return invitedomain.RechargeTypeV5Pay
|
||||||
case "usdt", "usdt_trc20", "trc20":
|
case "usdt", "usdt_trc20", "trc20":
|
||||||
return invitedomain.RechargeTypeUSDTTRC20
|
return invitedomain.RechargeTypeUSDTTRC20
|
||||||
default:
|
default:
|
||||||
|
|||||||
@ -72,6 +72,31 @@ mifapay:
|
|||||||
api_base_url: "https://platform.xqdmipay.com"
|
api_base_url: "https://platform.xqdmipay.com"
|
||||||
http_timeout: "10s"
|
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:
|
tron_grid:
|
||||||
enabled: true
|
enabled: true
|
||||||
@ -86,6 +111,13 @@ external_recharge:
|
|||||||
usdt_trc20_address: "cccccccccccc"
|
usdt_trc20_address: "cccccccccccc"
|
||||||
mifapay_notify_url: "http://127.0.0.1:13000/api/v1/payment/mifapay/notify"
|
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"
|
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:
|
outbox_worker:
|
||||||
enabled: true
|
enabled: true
|
||||||
poll_interval: "1s"
|
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),
|
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_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_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)
|
KEY idx_external_recharge_orders_tx (app_code, provider_code, tx_hash)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='H5 外部充值订单表';
|
) 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', '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);
|
('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 临时预览图,避免误改运营手工替换的正式图。
|
-- MiFaPay logo 使用已上传 COS 的固定对象地址;仅覆盖空值、早期占位图和 Apifox 临时预览图,避免误改运营手工替换的正式图。
|
||||||
UPDATE third_party_payment_methods m
|
UPDATE third_party_payment_methods m
|
||||||
JOIN (
|
JOIN (
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import (
|
|||||||
"hyapp/services/wallet-service/internal/client/googleplay"
|
"hyapp/services/wallet-service/internal/client/googleplay"
|
||||||
"hyapp/services/wallet-service/internal/client/mifapay"
|
"hyapp/services/wallet-service/internal/client/mifapay"
|
||||||
"hyapp/services/wallet-service/internal/client/trongrid"
|
"hyapp/services/wallet-service/internal/client/trongrid"
|
||||||
|
"hyapp/services/wallet-service/internal/client/v5pay"
|
||||||
"hyapp/services/wallet-service/internal/config"
|
"hyapp/services/wallet-service/internal/config"
|
||||||
walletservice "hyapp/services/wallet-service/internal/service/wallet"
|
walletservice "hyapp/services/wallet-service/internal/service/wallet"
|
||||||
mysqlstorage "hyapp/services/wallet-service/internal/storage/mysql"
|
mysqlstorage "hyapp/services/wallet-service/internal/storage/mysql"
|
||||||
@ -33,26 +34,27 @@ import (
|
|||||||
|
|
||||||
// App 装配 wallet-service gRPC 入口。
|
// App 装配 wallet-service gRPC 入口。
|
||||||
type App struct {
|
type App struct {
|
||||||
server *grpc.Server
|
server *grpc.Server
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
health *grpchealth.ServingChecker
|
health *grpchealth.ServingChecker
|
||||||
healthHTTP *healthhttp.Server
|
healthHTTP *healthhttp.Server
|
||||||
mysqlRepo *mysqlstorage.Repository
|
mysqlRepo *mysqlstorage.Repository
|
||||||
walletSvc *walletservice.Service
|
walletSvc *walletservice.Service
|
||||||
activityConn *grpc.ClientConn
|
activityConn *grpc.ClientConn
|
||||||
outboxProducer *rocketmqx.Producer
|
outboxProducer *rocketmqx.Producer
|
||||||
realtimeOutboxProducer *rocketmqx.Producer
|
realtimeOutboxProducer *rocketmqx.Producer
|
||||||
projectionConsumer *rocketmqx.Consumer
|
projectionConsumer *rocketmqx.Consumer
|
||||||
outboxWorkerCfg config.OutboxWorkerConfig
|
outboxWorkerCfg config.OutboxWorkerConfig
|
||||||
realtimeOutboxWorkerCfg config.OutboxWorkerConfig
|
realtimeOutboxWorkerCfg config.OutboxWorkerConfig
|
||||||
projectionWorkerCfg config.ProjectionWorkerConfig
|
projectionWorkerCfg config.ProjectionWorkerConfig
|
||||||
walletOutboxTopic string
|
externalRechargeReconcileWorkerCfg config.ExternalRechargeReconcileWorkerConfig
|
||||||
realtimeWalletOutboxTopic string
|
walletOutboxTopic string
|
||||||
nodeID string
|
realtimeWalletOutboxTopic string
|
||||||
redPacketExpiryWorkerCfg config.RedPacketExpiryWorkerConfig
|
nodeID string
|
||||||
stopWorker context.CancelFunc
|
redPacketExpiryWorkerCfg config.RedPacketExpiryWorkerConfig
|
||||||
workers sync.WaitGroup
|
stopWorker context.CancelFunc
|
||||||
closeOnce sync.Once
|
workers sync.WaitGroup
|
||||||
|
closeOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
// New 初始化 wallet-service。
|
// New 初始化 wallet-service。
|
||||||
@ -106,6 +108,8 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
USDTTRC20Address: cfg.ExternalRecharge.USDTTRC20Address,
|
USDTTRC20Address: cfg.ExternalRecharge.USDTTRC20Address,
|
||||||
MifaPayNotifyURL: cfg.ExternalRecharge.MifaPayNotifyURL,
|
MifaPayNotifyURL: cfg.ExternalRecharge.MifaPayNotifyURL,
|
||||||
MifaPayReturnURL: cfg.ExternalRecharge.MifaPayReturnURL,
|
MifaPayReturnURL: cfg.ExternalRecharge.MifaPayReturnURL,
|
||||||
|
V5PayNotifyURL: cfg.ExternalRecharge.V5PayNotifyURL,
|
||||||
|
V5PayReturnURL: cfg.ExternalRecharge.V5PayReturnURL,
|
||||||
})
|
})
|
||||||
if cfg.GooglePlay.Enabled {
|
if cfg.GooglePlay.Enabled {
|
||||||
googleClient, err := googleplay.New(cfg.GooglePlay)
|
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")
|
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 {
|
if cfg.TronGrid.Enabled {
|
||||||
svc.SetTronUSDTClient(trongrid.New(cfg.TronGrid))
|
svc.SetTronUSDTClient(trongrid.New(cfg.TronGrid))
|
||||||
}
|
}
|
||||||
@ -162,23 +174,24 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &App{
|
return &App{
|
||||||
server: server,
|
server: server,
|
||||||
listener: listener,
|
listener: listener,
|
||||||
health: health,
|
health: health,
|
||||||
healthHTTP: healthHTTP,
|
healthHTTP: healthHTTP,
|
||||||
mysqlRepo: repository,
|
mysqlRepo: repository,
|
||||||
walletSvc: svc,
|
walletSvc: svc,
|
||||||
activityConn: activityConn,
|
activityConn: activityConn,
|
||||||
outboxProducer: outboxProducer,
|
outboxProducer: outboxProducer,
|
||||||
realtimeOutboxProducer: realtimeOutboxProducer,
|
realtimeOutboxProducer: realtimeOutboxProducer,
|
||||||
projectionConsumer: projectionConsumer,
|
projectionConsumer: projectionConsumer,
|
||||||
outboxWorkerCfg: cfg.OutboxWorker,
|
outboxWorkerCfg: cfg.OutboxWorker,
|
||||||
realtimeOutboxWorkerCfg: cfg.RealtimeOutboxWorker,
|
realtimeOutboxWorkerCfg: cfg.RealtimeOutboxWorker,
|
||||||
projectionWorkerCfg: cfg.ProjectionWorker,
|
projectionWorkerCfg: cfg.ProjectionWorker,
|
||||||
walletOutboxTopic: cfg.RocketMQ.WalletOutbox.Topic,
|
externalRechargeReconcileWorkerCfg: cfg.ExternalRechargeReconcileWorker,
|
||||||
realtimeWalletOutboxTopic: cfg.RocketMQ.RealtimeOutbox.Topic,
|
walletOutboxTopic: cfg.RocketMQ.WalletOutbox.Topic,
|
||||||
nodeID: cfg.NodeID,
|
realtimeWalletOutboxTopic: cfg.RocketMQ.RealtimeOutbox.Topic,
|
||||||
redPacketExpiryWorkerCfg: cfg.RedPacketExpiryWorker,
|
nodeID: cfg.NodeID,
|
||||||
|
redPacketExpiryWorkerCfg: cfg.RedPacketExpiryWorker,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -238,6 +251,28 @@ func (a *App) runBackgroundWorkers() {
|
|||||||
if a.realtimeOutboxWorkerCfg.Enabled && a.realtimeOutboxProducer != nil {
|
if a.realtimeOutboxWorkerCfg.Enabled && a.realtimeOutboxProducer != nil {
|
||||||
a.startWalletOutboxWorkers(ctx, "wallet-realtime-outbox", a.realtimeOutboxWorkerCfg, a.realtimeOutboxProducer, a.realtimeWalletOutboxTopic, a.realtimeOutboxWorkerCfg.EventTypes, 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 {
|
if a.redPacketExpiryWorkerCfg.Enabled {
|
||||||
redPacketWorkerID := "red-packet-expiry-" + a.nodeID
|
redPacketWorkerID := "red-packet-expiry-" + a.nodeID
|
||||||
a.workers.Add(1)
|
a.workers.Add(1)
|
||||||
@ -506,6 +541,12 @@ func mifapayConfigReady(cfg config.MifaPayConfig) bool {
|
|||||||
strings.TrimSpace(cfg.PlatformPublicKey) != ""
|
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) {
|
func shutdownProducers(producers ...*rocketmqx.Producer) {
|
||||||
for _, producer := range producers {
|
for _, producer := range producers {
|
||||||
if producer != nil {
|
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"`
|
GooglePlay GooglePlayConfig `yaml:"google_play"`
|
||||||
// MifaPay 控制 H5 MiFaPay 站外支付下单和回调验签。
|
// MifaPay 控制 H5 MiFaPay 站外支付下单和回调验签。
|
||||||
MifaPay MifaPayConfig `yaml:"mifapay"`
|
MifaPay MifaPayConfig `yaml:"mifapay"`
|
||||||
|
// V5Pay 控制 H5 V5Pay 站外支付下单、查询和回调验签。
|
||||||
|
V5Pay V5PayConfig `yaml:"v5pay"`
|
||||||
// TronGrid 控制 USDT-TRC20 tx_hash 链上校验。
|
// TronGrid 控制 USDT-TRC20 tx_hash 链上校验。
|
||||||
TronGrid TronGridConfig `yaml:"tron_grid"`
|
TronGrid TronGridConfig `yaml:"tron_grid"`
|
||||||
// ExternalRecharge 控制 H5 外部充值公开地址和回跳地址。
|
// ExternalRecharge 控制 H5 外部充值公开地址和回跳地址。
|
||||||
ExternalRecharge ExternalRechargeConfig `yaml:"external_recharge"`
|
ExternalRecharge ExternalRechargeConfig `yaml:"external_recharge"`
|
||||||
|
// ExternalRechargeReconcileWorker 控制三方支付回调丢失后的服务端查单补偿。
|
||||||
|
ExternalRechargeReconcileWorker ExternalRechargeReconcileWorkerConfig `yaml:"external_recharge_reconcile_worker"`
|
||||||
// OutboxWorker 控制 wallet_outbox 到 MQ 的补偿投递。
|
// OutboxWorker 控制 wallet_outbox 到 MQ 的补偿投递。
|
||||||
OutboxWorker OutboxWorkerConfig `yaml:"outbox_worker"`
|
OutboxWorker OutboxWorkerConfig `yaml:"outbox_worker"`
|
||||||
// RealtimeOutboxWorker 控制红包等实时 UI 事件到独立 MQ topic 的补偿投递。
|
// RealtimeOutboxWorker 控制红包等实时 UI 事件到独立 MQ topic 的补偿投递。
|
||||||
@ -136,6 +140,30 @@ type MifaPayEndpointConfig struct {
|
|||||||
HTTPTimeout time.Duration `yaml:"http_timeout"`
|
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 可替换成自有代理。
|
// TronGridConfig 保存 TRON HTTP 兼容接口配置;base_url 可替换成自有代理。
|
||||||
type TronGridConfig struct {
|
type TronGridConfig struct {
|
||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled"`
|
||||||
@ -151,6 +179,16 @@ type ExternalRechargeConfig struct {
|
|||||||
USDTTRC20Address string `yaml:"usdt_trc20_address"`
|
USDTTRC20Address string `yaml:"usdt_trc20_address"`
|
||||||
MifaPayNotifyURL string `yaml:"mifapay_notify_url"`
|
MifaPayNotifyURL string `yaml:"mifapay_notify_url"`
|
||||||
MifaPayReturnURL string `yaml:"mifapay_return_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 返回本地开发默认配置。
|
// Default 返回本地开发默认配置。
|
||||||
@ -182,6 +220,11 @@ func Default() Config {
|
|||||||
APIBaseURL: "https://platformtest.xqdmipay.com",
|
APIBaseURL: "https://platformtest.xqdmipay.com",
|
||||||
HTTPTimeout: 10 * time.Second,
|
HTTPTimeout: 10 * time.Second,
|
||||||
},
|
},
|
||||||
|
V5Pay: V5PayConfig{
|
||||||
|
Enabled: true,
|
||||||
|
APIBaseURL: "https://api-uat.v5pay.com",
|
||||||
|
HTTPTimeout: 10 * time.Second,
|
||||||
|
},
|
||||||
TronGrid: TronGridConfig{
|
TronGrid: TronGridConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
APIBaseURL: "https://api.trongrid.io",
|
APIBaseURL: "https://api.trongrid.io",
|
||||||
@ -191,6 +234,12 @@ func Default() Config {
|
|||||||
ExternalRecharge: ExternalRechargeConfig{
|
ExternalRecharge: ExternalRechargeConfig{
|
||||||
USDTTRC20Enabled: true,
|
USDTTRC20Enabled: true,
|
||||||
},
|
},
|
||||||
|
ExternalRechargeReconcileWorker: ExternalRechargeReconcileWorkerConfig{
|
||||||
|
Enabled: true,
|
||||||
|
AppCode: "lalu",
|
||||||
|
PollInterval: 30 * time.Second,
|
||||||
|
BatchSize: 50,
|
||||||
|
},
|
||||||
OutboxWorker: OutboxWorkerConfig{
|
OutboxWorker: OutboxWorkerConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
PollInterval: time.Second,
|
PollInterval: time.Second,
|
||||||
@ -317,6 +366,8 @@ func Load(path string) (Config, error) {
|
|||||||
}
|
}
|
||||||
cfg.MifaPay = normalizeMifaPayConfig(cfg.MifaPay)
|
cfg.MifaPay = normalizeMifaPayConfig(cfg.MifaPay)
|
||||||
// MiFaPay 功能开关默认打开;商户密钥属于环境敏感配置,允许本地缺省启动,启动装配时再按凭证完整性决定是否接入真实网关。
|
// MiFaPay 功能开关默认打开;商户密钥属于环境敏感配置,允许本地缺省启动,启动装配时再按凭证完整性决定是否接入真实网关。
|
||||||
|
cfg.V5Pay = normalizeV5PayConfig(cfg.V5Pay)
|
||||||
|
// V5Pay 功能开关默认打开;merchantNo/appKey/secretKey 是环境敏感配置,缺失时不注入真实网关。
|
||||||
cfg.TronGrid.APIBaseURL = strings.TrimRight(strings.TrimSpace(cfg.TronGrid.APIBaseURL), "/")
|
cfg.TronGrid.APIBaseURL = strings.TrimRight(strings.TrimSpace(cfg.TronGrid.APIBaseURL), "/")
|
||||||
if cfg.TronGrid.APIBaseURL == "" {
|
if cfg.TronGrid.APIBaseURL == "" {
|
||||||
cfg.TronGrid.APIBaseURL = Default().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.USDTTRC20Address = strings.TrimSpace(cfg.ExternalRecharge.USDTTRC20Address)
|
||||||
cfg.ExternalRecharge.MifaPayNotifyURL = strings.TrimSpace(cfg.ExternalRecharge.MifaPayNotifyURL)
|
cfg.ExternalRecharge.MifaPayNotifyURL = strings.TrimSpace(cfg.ExternalRecharge.MifaPayNotifyURL)
|
||||||
cfg.ExternalRecharge.MifaPayReturnURL = strings.TrimSpace(cfg.ExternalRecharge.MifaPayReturnURL)
|
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 下单入口,但配置本身不被静默改成关闭。
|
// USDT 功能开关默认打开;未配置收款地址时 H5 不展示 USDT 下单入口,但配置本身不被静默改成关闭。
|
||||||
|
cfg.ExternalRechargeReconcileWorker = normalizeExternalRechargeReconcileWorkerConfig(cfg.ExternalRechargeReconcileWorker, Default().ExternalRechargeReconcileWorker)
|
||||||
cfg.OutboxWorker = normalizeOutboxWorkerConfig(cfg.OutboxWorker, Default().OutboxWorker)
|
cfg.OutboxWorker = normalizeOutboxWorkerConfig(cfg.OutboxWorker, Default().OutboxWorker)
|
||||||
if cfg.OutboxWorker.Enabled && !cfg.RocketMQ.WalletOutbox.Enabled {
|
if cfg.OutboxWorker.Enabled && !cfg.RocketMQ.WalletOutbox.Enabled {
|
||||||
return Config{}, errors.New("outbox_worker requires rocketmq.wallet_outbox.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
|
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 {
|
func normalizeMifaPayConfig(cfg MifaPayConfig) MifaPayConfig {
|
||||||
cfg.Environment = strings.ToLower(strings.TrimSpace(cfg.Environment))
|
cfg.Environment = strings.ToLower(strings.TrimSpace(cfg.Environment))
|
||||||
// 本地联调可以同时保留 test/prod 两套商户配置;显式 environment 决定真实注入哪套,避免测试网和正式网字段互相覆盖。
|
// 本地联调可以同时保留 test/prod 两套商户配置;显式 environment 决定真实注入哪套,避免测试网和正式网字段互相覆盖。
|
||||||
@ -396,6 +466,54 @@ func normalizeMifaPayConfig(cfg MifaPayConfig) MifaPayConfig {
|
|||||||
return cfg
|
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 {
|
func applyMifaPayEndpointConfig(cfg MifaPayConfig, endpoint MifaPayEndpointConfig) MifaPayConfig {
|
||||||
// 环境块只在字段真实配置时覆盖扁平字段;这样旧的 flat 配置仍能启动,新配置也能按 test/prod 切换。
|
// 环境块只在字段真实配置时覆盖扁平字段;这样旧的 flat 配置仍能启动,新配置也能按 test/prod 切换。
|
||||||
if value := strings.TrimSpace(endpoint.MerAccount); value != "" {
|
if value := strings.TrimSpace(endpoint.MerAccount); value != "" {
|
||||||
@ -422,6 +540,15 @@ func applyMifaPayEndpointConfig(cfg MifaPayConfig, endpoint MifaPayEndpointConfi
|
|||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) {
|
func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) {
|
||||||
defaults := defaultRocketMQConfig()
|
defaults := defaultRocketMQConfig()
|
||||||
cfg.NameServers = normalizeStringSlice(cfg.NameServers)
|
cfg.NameServers = normalizeStringSlice(cfg.NameServers)
|
||||||
|
|||||||
@ -20,6 +20,9 @@ func TestLoadLocalEnablesOutboxMQ(t *testing.T) {
|
|||||||
if !cfg.OutboxWorker.Enabled || !cfg.RealtimeOutboxWorker.Enabled || !cfg.ProjectionWorker.Enabled {
|
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)
|
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 {
|
if cfg.OutboxWorker.Concurrency != 1 || cfg.RealtimeOutboxWorker.Concurrency != 2 {
|
||||||
t.Fatalf("local outbox worker defaults mismatch: outbox=%+v realtime=%+v", cfg.OutboxWorker, cfg.RealtimeOutboxWorker)
|
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",
|
path: "../../configs/config.docker.yaml",
|
||||||
merNo: "10001910",
|
env: "test",
|
||||||
merAccount: "6000055d34f445418124ab7c0beb81a6",
|
merNo: "10001925",
|
||||||
|
merAccount: "d00f5fc97a114a0db291eceaf9841739",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "../../configs/config.tencent.example.yaml",
|
path: "../../configs/config.tencent.example.yaml",
|
||||||
|
env: "prod",
|
||||||
merNo: "10001910",
|
merNo: "10001910",
|
||||||
merAccount: "6000055d34f445418124ab7c0beb81a6",
|
merAccount: "6000055d34f445418124ab7c0beb81a6",
|
||||||
},
|
},
|
||||||
|
|||||||
@ -56,6 +56,8 @@ const (
|
|||||||
PaymentProviderGooglePlay = "google_play"
|
PaymentProviderGooglePlay = "google_play"
|
||||||
// PaymentProviderMifaPay 是 MiFaPay 三方收银台支付渠道。
|
// PaymentProviderMifaPay 是 MiFaPay 三方收银台支付渠道。
|
||||||
PaymentProviderMifaPay = "mifapay"
|
PaymentProviderMifaPay = "mifapay"
|
||||||
|
// PaymentProviderV5Pay 是 V5Pay 三方收银台支付渠道。
|
||||||
|
PaymentProviderV5Pay = "v5pay"
|
||||||
// PaymentProviderUSDTTRC20 是用户提交 TRC20 链上交易哈希的 USDT 充值渠道。
|
// PaymentProviderUSDTTRC20 是用户提交 TRC20 链上交易哈希的 USDT 充值渠道。
|
||||||
PaymentProviderUSDTTRC20 = "usdt_trc20"
|
PaymentProviderUSDTTRC20 = "usdt_trc20"
|
||||||
// ThirdPartyPaymentStatusActive 表示渠道或支付方式可用于 H5 下单。
|
// ThirdPartyPaymentStatusActive 表示渠道或支付方式可用于 H5 下单。
|
||||||
@ -752,6 +754,12 @@ type MifaPayNotification struct {
|
|||||||
Sign string
|
Sign string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// V5PayNotification 保存 V5Pay 回调原始字段;验签必须使用所有返回字段,避免未来新增字段绕过签名校验。
|
||||||
|
type V5PayNotification struct {
|
||||||
|
Fields map[string]string
|
||||||
|
RawJSON string
|
||||||
|
}
|
||||||
|
|
||||||
// DiamondExchangeRule 是钻石兑换金币或余额的固定规则投影。
|
// DiamondExchangeRule 是钻石兑换金币或余额的固定规则投影。
|
||||||
type DiamondExchangeRule struct {
|
type DiamondExchangeRule struct {
|
||||||
ExchangeType string
|
ExchangeType string
|
||||||
@ -1314,6 +1322,8 @@ func NormalizePaymentProvider(provider string) string {
|
|||||||
switch strings.ToLower(strings.TrimSpace(provider)) {
|
switch strings.ToLower(strings.TrimSpace(provider)) {
|
||||||
case PaymentProviderMifaPay:
|
case PaymentProviderMifaPay:
|
||||||
return PaymentProviderMifaPay
|
return PaymentProviderMifaPay
|
||||||
|
case PaymentProviderV5Pay:
|
||||||
|
return PaymentProviderV5Pay
|
||||||
case PaymentProviderUSDTTRC20:
|
case PaymentProviderUSDTTRC20:
|
||||||
return PaymentProviderUSDTTRC20
|
return PaymentProviderUSDTTRC20
|
||||||
case PaymentProviderGooglePlay:
|
case PaymentProviderGooglePlay:
|
||||||
|
|||||||
@ -20,6 +20,8 @@ type ExternalRechargeConfig struct {
|
|||||||
USDTTRC20Address string
|
USDTTRC20Address string
|
||||||
MifaPayNotifyURL string
|
MifaPayNotifyURL string
|
||||||
MifaPayReturnURL string
|
MifaPayReturnURL string
|
||||||
|
V5PayNotifyURL string
|
||||||
|
V5PayReturnURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// MifaPayCreateOrderRequest 是 wallet-service 传给 MiFaPay client 的已校验下单快照。
|
// MifaPayCreateOrderRequest 是 wallet-service 传给 MiFaPay client 的已校验下单快照。
|
||||||
@ -76,6 +78,76 @@ type MifaPayNotifyData struct {
|
|||||||
RawJSON string
|
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 只处理订单状态。
|
// MifaPayClient 隔离 MiFaPay RSA 签名、验签和 HTTP 协议细节,service 只处理订单状态。
|
||||||
type MifaPayClient interface {
|
type MifaPayClient interface {
|
||||||
CreateOrder(ctx context.Context, req MifaPayCreateOrderRequest) (MifaPayCreateOrderResponse, error)
|
CreateOrder(ctx context.Context, req MifaPayCreateOrderRequest) (MifaPayCreateOrderResponse, error)
|
||||||
@ -83,6 +155,14 @@ type MifaPayClient interface {
|
|||||||
ParseNotification(notification ledger.MifaPayNotification) (MifaPayNotifyData, error)
|
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 是否真实打到平台共享地址。
|
// TronUSDTClient 校验用户提交的 TRC20 tx_hash 是否真实打到平台共享地址。
|
||||||
type TronUSDTClient interface {
|
type TronUSDTClient interface {
|
||||||
VerifyUSDTTransfer(ctx context.Context, txHash string, toAddress string, amountMinor int64) (string, error)
|
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.USDTTRC20Address = strings.TrimSpace(config.USDTTRC20Address)
|
||||||
config.MifaPayNotifyURL = strings.TrimSpace(config.MifaPayNotifyURL)
|
config.MifaPayNotifyURL = strings.TrimSpace(config.MifaPayNotifyURL)
|
||||||
config.MifaPayReturnURL = strings.TrimSpace(config.MifaPayReturnURL)
|
config.MifaPayReturnURL = strings.TrimSpace(config.MifaPayReturnURL)
|
||||||
|
config.V5PayNotifyURL = strings.TrimSpace(config.V5PayNotifyURL)
|
||||||
|
config.V5PayReturnURL = strings.TrimSpace(config.V5PayReturnURL)
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -152,14 +234,69 @@ func (s *Service) ListH5RechargeOptions(ctx context.Context, query ledger.H5Rech
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return ledger.H5RechargeOptions{}, err
|
return ledger.H5RechargeOptions{}, err
|
||||||
}
|
}
|
||||||
if s.mifaPay == nil {
|
if s.mifaPay == nil && s.v5Pay == nil {
|
||||||
options.PaymentMethods = 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.USDTTRC20Enabled = s.externalRecharge.USDTTRC20Enabled && s.externalRecharge.USDTTRC20Address != ""
|
||||||
options.USDTTRC20Address = s.externalRecharge.USDTTRC20Address
|
options.USDTTRC20Address = s.externalRecharge.USDTTRC20Address
|
||||||
return options, nil
|
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 外部充值订单;只有回调或链上校验成功后才会入账。
|
// CreateH5RechargeOrder 创建 H5 外部充值订单;只有回调或链上校验成功后才会入账。
|
||||||
func (s *Service) CreateH5RechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand) (ledger.ExternalRechargeOrder, error) {
|
func (s *Service) CreateH5RechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand) (ledger.ExternalRechargeOrder, error) {
|
||||||
command = normalizeCreateExternalRechargeOrderCommand(command)
|
command = normalizeCreateExternalRechargeOrderCommand(command)
|
||||||
@ -182,6 +319,8 @@ func (s *Service) CreateH5RechargeOrder(ctx context.Context, command ledger.Crea
|
|||||||
return s.createUSDTRechargeOrder(ctx, command, product)
|
return s.createUSDTRechargeOrder(ctx, command, product)
|
||||||
case ledger.PaymentProviderMifaPay:
|
case ledger.PaymentProviderMifaPay:
|
||||||
return s.createMifaPayRechargeOrder(ctx, command, product)
|
return s.createMifaPayRechargeOrder(ctx, command, product)
|
||||||
|
case ledger.PaymentProviderV5Pay:
|
||||||
|
return s.createV5PayRechargeOrder(ctx, command, product)
|
||||||
default:
|
default:
|
||||||
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.InvalidArgument, "payment provider is invalid")
|
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)
|
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 {
|
func normalizeMifaPayOrderError(err error) error {
|
||||||
if err == nil {
|
return normalizeExternalProviderOrderError(err, "mifapay")
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if _, ok := xerr.As(err); ok {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// MiFaPay HTTP、JSON、RSA 验签和响应结构错误都是外部支付依赖不可用;不能把普通 Go error 透到 gRPC 边界,
|
|
||||||
// 否则 gateway 只能降级成 INTERNAL_ERROR,前端拿不到“上游支付失败”的可重试语义。
|
|
||||||
return xerr.New(xerr.Unavailable, "mifapay order upstream response is invalid")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type mifaPayProviderPayloadError interface {
|
type mifaPayProviderPayloadError interface {
|
||||||
@ -262,10 +442,26 @@ type mifaPayProviderPayloadError interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func mifaPayProviderPayloadJSON(err error) string {
|
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
|
var payloadErr mifaPayProviderPayloadError
|
||||||
if errors.As(err, &payloadErr) {
|
if errors.As(err, &payloadErr) {
|
||||||
// MiFaPay 网关拒单的原始响应是和支付订单排障强相关的三方事实,落库后可直接给支付技术支持按 code/msg 查链路。
|
// 支付网关拒单的原始响应是和支付订单排障强相关的三方事实,落库后可直接给支付技术支持按 code/msg 查链路。
|
||||||
// 普通网络错误、JSON 解析错误或本地 RSA 错误没有三方响应体,这里保持空值,避免写入误导性的本地错误文本。
|
// 普通网络错误、JSON 解析错误或本地签名错误没有三方响应体,这里保持空值,避免写入误导性的本地错误文本。
|
||||||
return strings.TrimSpace(payloadErr.ProviderPayloadJSON())
|
return strings.TrimSpace(payloadErr.ProviderPayloadJSON())
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
@ -322,9 +518,41 @@ func (s *Service) GetH5RechargeOrder(ctx context.Context, appCode string, orderI
|
|||||||
if refreshed, err := s.refreshMifaPayRechargeOrder(ctx, order); err == nil {
|
if refreshed, err := s.refreshMifaPayRechargeOrder(ctx, order); err == nil {
|
||||||
order = refreshed
|
order = refreshed
|
||||||
}
|
}
|
||||||
|
if refreshed, err := s.refreshV5PayRechargeOrder(ctx, order); err == nil {
|
||||||
|
order = refreshed
|
||||||
|
}
|
||||||
return order, nil
|
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) {
|
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 {
|
if s.mifaPay == nil || order.ProviderCode != ledger.PaymentProviderMifaPay || order.Status == ledger.ExternalRechargeStatusCredited || order.Status == ledger.ExternalRechargeStatusFailed {
|
||||||
return order, nil
|
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。
|
// HandleMifapayNotify 只在验签、订单号、币种和金额全部匹配后入账;重复回调返回 SUCCESS。
|
||||||
func (s *Service) HandleMifapayNotify(ctx context.Context, appCode string, notification ledger.MifaPayNotification) (ledger.ExternalRechargeOrder, bool, error) {
|
func (s *Service) HandleMifapayNotify(ctx context.Context, appCode string, notification ledger.MifaPayNotification) (ledger.ExternalRechargeOrder, bool, error) {
|
||||||
if s.mifaPay == nil {
|
if s.mifaPay == nil {
|
||||||
@ -392,6 +660,39 @@ func (s *Service) HandleMifapayNotify(ctx context.Context, appCode string, notif
|
|||||||
return credited, err == nil, err
|
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 {
|
func normalizeCreateExternalRechargeOrderCommand(command ledger.CreateExternalRechargeOrderCommand) ledger.CreateExternalRechargeOrderCommand {
|
||||||
command.AppCode = appcode.Normalize(command.AppCode)
|
command.AppCode = appcode.Normalize(command.AppCode)
|
||||||
command.CommandID = strings.TrimSpace(command.CommandID)
|
command.CommandID = strings.TrimSpace(command.CommandID)
|
||||||
@ -504,6 +805,22 @@ func validateMifaPayMethodForOrder(method ledger.ThirdPartyPaymentMethod, comman
|
|||||||
return nil
|
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) {
|
func calculateProviderAmountMinor(usdMinor int64, rate string) (int64, error) {
|
||||||
if usdMinor <= 0 {
|
if usdMinor <= 0 {
|
||||||
return 0, xerr.New(xerr.InvalidArgument, "usd amount is invalid")
|
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
|
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 {
|
func mifaPayProviderReturnURL(returnURL string) string {
|
||||||
returnURL = strings.TrimSpace(returnURL)
|
returnURL = strings.TrimSpace(returnURL)
|
||||||
if returnURL == "" {
|
if returnURL == "" {
|
||||||
@ -568,6 +930,10 @@ func mifaPayProviderReturnURL(returnURL string) string {
|
|||||||
return parsed.String()
|
return parsed.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func v5PayProviderReturnURL(returnURL string) string {
|
||||||
|
return mifaPayProviderReturnURL(returnURL)
|
||||||
|
}
|
||||||
|
|
||||||
func firstNonEmpty(values ...string) string {
|
func firstNonEmpty(values ...string) string {
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
if value = strings.TrimSpace(value); value != "" {
|
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)
|
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)
|
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)
|
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)
|
GetDiamondExchangeConfig(ctx context.Context, userID int64) ([]ledger.DiamondExchangeRule, error)
|
||||||
ListWalletTransactions(ctx context.Context, query ledger.ListWalletTransactionsQuery) ([]ledger.WalletTransaction, int64, error)
|
ListWalletTransactions(ctx context.Context, query ledger.ListWalletTransactionsQuery) ([]ledger.WalletTransaction, int64, error)
|
||||||
ListVipPackages(ctx context.Context, userID int64) (ledger.UserVip, []ledger.VipLevel, error)
|
ListVipPackages(ctx context.Context, userID int64) (ledger.UserVip, []ledger.VipLevel, error)
|
||||||
@ -133,6 +134,7 @@ type Service struct {
|
|||||||
activity ActivityBadgeClient
|
activity ActivityBadgeClient
|
||||||
googlePlay GooglePlayClient
|
googlePlay GooglePlayClient
|
||||||
mifaPay MifaPayClient
|
mifaPay MifaPayClient
|
||||||
|
v5Pay V5PayClient
|
||||||
tronUSDT TronUSDTClient
|
tronUSDT TronUSDTClient
|
||||||
externalRecharge ExternalRechargeConfig
|
externalRecharge ExternalRechargeConfig
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
@ -157,6 +159,11 @@ func (s *Service) SetMifaPayClient(client MifaPayClient) {
|
|||||||
s.mifaPay = client
|
s.mifaPay = client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetV5PayClient 注入 V5Pay 下单、查询和验签客户端;未配置时 H5 V5Pay 下单返回依赖不可用。
|
||||||
|
func (s *Service) SetV5PayClient(client V5PayClient) {
|
||||||
|
s.v5Pay = client
|
||||||
|
}
|
||||||
|
|
||||||
// SetTronUSDTClient 注入 TRON TRC20 交易校验客户端;未配置时 USDT tx 提交只会保留待确认订单。
|
// SetTronUSDTClient 注入 TRON TRC20 交易校验客户端;未配置时 USDT tx 提交只会保留待确认订单。
|
||||||
func (s *Service) SetTronUSDTClient(client TronUSDTClient) {
|
func (s *Service) SetTronUSDTClient(client TronUSDTClient) {
|
||||||
s.tronUSDT = client
|
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 过滤。
|
// TestH5MifaPayOrderRejectsPaymentMethodFromAnotherCountry 锁定下单阶段再次校验国家,防止前端伪造 method_id 绕过 options 过滤。
|
||||||
func TestH5MifaPayOrderRejectsPaymentMethodFromAnotherCountry(t *testing.T) {
|
func TestH5MifaPayOrderRejectsPaymentMethodFromAnotherCountry(t *testing.T) {
|
||||||
repository := mysqltest.NewRepository(t)
|
repository := mysqltest.NewRepository(t)
|
||||||
@ -2533,6 +2589,80 @@ func createRedirectedMifaPayOrder(t *testing.T, repository *mysqltest.Repository
|
|||||||
return order
|
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 钱包,并写普通充值记录。
|
// TestH5USDTRechargeCreditsNormalUserCoinWallet 验证 H5 普通用户 USDT 充值只进入普通 COIN 钱包,并写普通充值记录。
|
||||||
func TestH5USDTRechargeCreditsNormalUserCoinWallet(t *testing.T) {
|
func TestH5USDTRechargeCreditsNormalUserCoinWallet(t *testing.T) {
|
||||||
repository := mysqltest.NewRepository(t)
|
repository := mysqltest.NewRepository(t)
|
||||||
@ -5369,6 +5499,53 @@ func (f *fakeMifaPayClient) ParseNotification(ledger.MifaPayNotification) (walle
|
|||||||
return f.notifyData, nil
|
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 {
|
type fakeTronUSDTClient struct {
|
||||||
lastTxHash string
|
lastTxHash string
|
||||||
lastToAddress string
|
lastToAddress string
|
||||||
|
|||||||
@ -39,6 +39,7 @@ const (
|
|||||||
bizTypeSalaryTransferToCoinSeller = "salary_transfer_to_coin_seller"
|
bizTypeSalaryTransferToCoinSeller = "salary_transfer_to_coin_seller"
|
||||||
bizTypeGooglePlayRecharge = "google_play_recharge"
|
bizTypeGooglePlayRecharge = "google_play_recharge"
|
||||||
bizTypeMifaPayRecharge = "mifapay"
|
bizTypeMifaPayRecharge = "mifapay"
|
||||||
|
bizTypeV5PayRecharge = "v5pay"
|
||||||
bizTypeUSDTTRC20Recharge = "usdt_trc20"
|
bizTypeUSDTTRC20Recharge = "usdt_trc20"
|
||||||
bizTypeGameDebit = "game_debit"
|
bizTypeGameDebit = "game_debit"
|
||||||
bizTypeGameCredit = "game_credit"
|
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),
|
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_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_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)
|
KEY idx_external_recharge_tx (app_code, provider_code, tx_hash)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='H5 外部充值订单表'`); err != nil {
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='H5 外部充值订单表'`); err != nil {
|
||||||
return err
|
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 {
|
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
|
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 自己支持的国家和支付产品,不再关心任何下游通道名称。
|
// MiFaPay 是 H5 的统一三方收银台入口:后台只管理 MiFaPay 自己支持的国家和支付产品,不再关心任何下游通道名称。
|
||||||
// 这里只 seed 可运营的 payWay/payType 清单;商户号、商户标识、商户私钥、平台公钥都属于运行环境密钥,不进入数据库 seed。
|
// 这里只 seed 可运营的 payWay/payType 清单;商户号、商户标识、商户私钥、平台公钥都属于运行环境密钥,不进入数据库 seed。
|
||||||
methods := []seedPaymentMethod{
|
methods := []seedPaymentMethod{
|
||||||
@ -168,6 +175,78 @@ func seedThirdPartyPaymentDefaults(ctx context.Context, db *sql.DB) error {
|
|||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -289,13 +368,38 @@ func (r *Repository) ListH5RechargeOptions(ctx context.Context, query ledger.H5R
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return ledger.H5RechargeOptions{}, err
|
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 {
|
if err != nil {
|
||||||
return ledger.H5RechargeOptions{}, err
|
return ledger.H5RechargeOptions{}, err
|
||||||
}
|
}
|
||||||
return ledger.H5RechargeOptions{Products: products, PaymentMethods: methods}, nil
|
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) {
|
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 = ?`
|
where := `WHERE m.app_code = ? AND m.provider_code = ?`
|
||||||
args := []any{appCode, providerCode}
|
args := []any{appCode, providerCode}
|
||||||
@ -620,6 +724,41 @@ func (r *Repository) GetExternalRechargeOrder(ctx context.Context, appCode strin
|
|||||||
return order, err
|
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) {
|
func (r *Repository) lookupExternalRechargeOrderByCommand(ctx context.Context, appCode string, commandID string) (ledger.ExternalRechargeOrder, bool, error) {
|
||||||
order, err := scanExternalRechargeOrder(r.db.QueryRowContext(ctx, externalRechargeOrderSelectSQL()+`
|
order, err := scanExternalRechargeOrder(r.db.QueryRowContext(ctx, externalRechargeOrderSelectSQL()+`
|
||||||
WHERE app_code = ? AND command_id = ?`,
|
WHERE app_code = ? AND command_id = ?`,
|
||||||
@ -764,6 +903,8 @@ func providerRechargeBizType(providerCode string) string {
|
|||||||
switch ledger.NormalizePaymentProvider(providerCode) {
|
switch ledger.NormalizePaymentProvider(providerCode) {
|
||||||
case ledger.PaymentProviderMifaPay:
|
case ledger.PaymentProviderMifaPay:
|
||||||
return bizTypeMifaPayRecharge
|
return bizTypeMifaPayRecharge
|
||||||
|
case ledger.PaymentProviderV5Pay:
|
||||||
|
return bizTypeV5PayRecharge
|
||||||
case ledger.PaymentProviderUSDTTRC20:
|
case ledger.PaymentProviderUSDTTRC20:
|
||||||
return bizTypeUSDTTRC20Recharge
|
return bizTypeUSDTTRC20Recharge
|
||||||
default:
|
default:
|
||||||
|
|||||||
@ -387,19 +387,24 @@ func (r *Repository) CreateH5RechargeProduct(audienceType string, regionID int64
|
|||||||
|
|
||||||
// ThirdPartyPaymentMethodID 返回 initdb/迁移种子里的真实 MiFaPay method_id,用于 H5 下单和跨国家拒绝测试。
|
// ThirdPartyPaymentMethodID 返回 initdb/迁移种子里的真实 MiFaPay method_id,用于 H5 下单和跨国家拒绝测试。
|
||||||
func (r *Repository) ThirdPartyPaymentMethodID(countryCode string, payWay string, payType string) int64 {
|
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()
|
r.t.Helper()
|
||||||
|
|
||||||
var methodID int64
|
var methodID int64
|
||||||
err := r.schema.DB.QueryRowContext(context.Background(), `
|
err := r.schema.DB.QueryRowContext(context.Background(), `
|
||||||
SELECT method_id
|
SELECT method_id
|
||||||
FROM third_party_payment_methods
|
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 = ?
|
AND country_code = ? AND pay_way = ? AND pay_type = ?
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
countryCode, payWay, payType,
|
providerCode, countryCode, payWay, payType,
|
||||||
).Scan(&methodID)
|
).Scan(&methodID)
|
||||||
if err != nil {
|
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
|
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
|
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 返回钻石兑换配置。
|
// GetDiamondExchangeConfig 返回钻石兑换配置。
|
||||||
func (s *Server) GetDiamondExchangeConfig(ctx context.Context, req *walletv1.GetDiamondExchangeConfigRequest) (*walletv1.GetDiamondExchangeConfigResponse, error) {
|
func (s *Server) GetDiamondExchangeConfig(ctx context.Context, req *walletv1.GetDiamondExchangeConfigRequest) (*walletv1.GetDiamondExchangeConfigResponse, error) {
|
||||||
ctx = appcode.WithContext(ctx, req.GetAppCode())
|
ctx = appcode.WithContext(ctx, req.GetAppCode())
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user