自动充值
This commit is contained in:
parent
8e5f213d9b
commit
b8cbb93183
File diff suppressed because it is too large
Load Diff
@ -1625,6 +1625,34 @@ message H5RechargeOrderResponse {
|
|||||||
ExternalRechargeOrder order = 1;
|
ExternalRechargeOrder order = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message VerifyCoinSellerRechargeReceiptRequest {
|
||||||
|
string request_id = 1;
|
||||||
|
string app_code = 2;
|
||||||
|
string provider_code = 3;
|
||||||
|
string external_order_no = 4;
|
||||||
|
string chain = 5;
|
||||||
|
int64 usd_minor_amount = 6;
|
||||||
|
int64 order_date_ms = 7;
|
||||||
|
string provider_country_code = 8;
|
||||||
|
string provider_currency_code = 9;
|
||||||
|
string provider_pay_type = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message VerifyCoinSellerRechargeReceiptResponse {
|
||||||
|
bool verified = 1;
|
||||||
|
string provider_code = 2;
|
||||||
|
string external_order_no = 3;
|
||||||
|
string provider_order_id = 4;
|
||||||
|
string status = 5;
|
||||||
|
string currency_code = 6;
|
||||||
|
int64 provider_amount_minor = 7;
|
||||||
|
string raw_json = 8;
|
||||||
|
string chain = 9;
|
||||||
|
string receive_address = 10;
|
||||||
|
int64 verified_at_ms = 11;
|
||||||
|
string failure_reason = 12;
|
||||||
|
}
|
||||||
|
|
||||||
message HandleMifapayNotifyRequest {
|
message HandleMifapayNotifyRequest {
|
||||||
string request_id = 1;
|
string request_id = 1;
|
||||||
string app_code = 2;
|
string app_code = 2;
|
||||||
@ -2344,6 +2372,7 @@ service WalletService {
|
|||||||
rpc ListTemporaryRechargeOrders(ListTemporaryRechargeOrdersRequest) returns (ListTemporaryRechargeOrdersResponse);
|
rpc ListTemporaryRechargeOrders(ListTemporaryRechargeOrdersRequest) returns (ListTemporaryRechargeOrdersResponse);
|
||||||
rpc SubmitH5RechargeTx(SubmitH5RechargeTxRequest) returns (H5RechargeOrderResponse);
|
rpc SubmitH5RechargeTx(SubmitH5RechargeTxRequest) returns (H5RechargeOrderResponse);
|
||||||
rpc GetH5RechargeOrder(GetH5RechargeOrderRequest) returns (H5RechargeOrderResponse);
|
rpc GetH5RechargeOrder(GetH5RechargeOrderRequest) returns (H5RechargeOrderResponse);
|
||||||
|
rpc VerifyCoinSellerRechargeReceipt(VerifyCoinSellerRechargeReceiptRequest) returns (VerifyCoinSellerRechargeReceiptResponse);
|
||||||
rpc HandleMifapayNotify(HandleMifapayNotifyRequest) returns (HandleMifapayNotifyResponse);
|
rpc HandleMifapayNotify(HandleMifapayNotifyRequest) returns (HandleMifapayNotifyResponse);
|
||||||
rpc HandleV5PayNotify(HandleV5PayNotifyRequest) returns (HandleV5PayNotifyResponse);
|
rpc HandleV5PayNotify(HandleV5PayNotifyRequest) returns (HandleV5PayNotifyResponse);
|
||||||
rpc GetDiamondExchangeConfig(GetDiamondExchangeConfigRequest) returns (GetDiamondExchangeConfigResponse);
|
rpc GetDiamondExchangeConfig(GetDiamondExchangeConfigRequest) returns (GetDiamondExchangeConfigResponse);
|
||||||
|
|||||||
@ -275,6 +275,7 @@ const (
|
|||||||
WalletService_ListTemporaryRechargeOrders_FullMethodName = "/hyapp.wallet.v1.WalletService/ListTemporaryRechargeOrders"
|
WalletService_ListTemporaryRechargeOrders_FullMethodName = "/hyapp.wallet.v1.WalletService/ListTemporaryRechargeOrders"
|
||||||
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_VerifyCoinSellerRechargeReceipt_FullMethodName = "/hyapp.wallet.v1.WalletService/VerifyCoinSellerRechargeReceipt"
|
||||||
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_HandleV5PayNotify_FullMethodName = "/hyapp.wallet.v1.WalletService/HandleV5PayNotify"
|
||||||
WalletService_GetDiamondExchangeConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/GetDiamondExchangeConfig"
|
WalletService_GetDiamondExchangeConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/GetDiamondExchangeConfig"
|
||||||
@ -384,6 +385,7 @@ type WalletServiceClient interface {
|
|||||||
ListTemporaryRechargeOrders(ctx context.Context, in *ListTemporaryRechargeOrdersRequest, opts ...grpc.CallOption) (*ListTemporaryRechargeOrdersResponse, error)
|
ListTemporaryRechargeOrders(ctx context.Context, in *ListTemporaryRechargeOrdersRequest, opts ...grpc.CallOption) (*ListTemporaryRechargeOrdersResponse, error)
|
||||||
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)
|
||||||
|
VerifyCoinSellerRechargeReceipt(ctx context.Context, in *VerifyCoinSellerRechargeReceiptRequest, opts ...grpc.CallOption) (*VerifyCoinSellerRechargeReceiptResponse, 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)
|
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)
|
||||||
@ -1161,6 +1163,16 @@ func (c *walletServiceClient) GetH5RechargeOrder(ctx context.Context, in *GetH5R
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *walletServiceClient) VerifyCoinSellerRechargeReceipt(ctx context.Context, in *VerifyCoinSellerRechargeReceiptRequest, opts ...grpc.CallOption) (*VerifyCoinSellerRechargeReceiptResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(VerifyCoinSellerRechargeReceiptResponse)
|
||||||
|
err := c.cc.Invoke(ctx, WalletService_VerifyCoinSellerRechargeReceipt_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *walletServiceClient) HandleMifapayNotify(ctx context.Context, in *HandleMifapayNotifyRequest, opts ...grpc.CallOption) (*HandleMifapayNotifyResponse, error) {
|
func (c *walletServiceClient) HandleMifapayNotify(ctx context.Context, in *HandleMifapayNotifyRequest, opts ...grpc.CallOption) (*HandleMifapayNotifyResponse, error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(HandleMifapayNotifyResponse)
|
out := new(HandleMifapayNotifyResponse)
|
||||||
@ -1511,6 +1523,7 @@ type WalletServiceServer interface {
|
|||||||
ListTemporaryRechargeOrders(context.Context, *ListTemporaryRechargeOrdersRequest) (*ListTemporaryRechargeOrdersResponse, error)
|
ListTemporaryRechargeOrders(context.Context, *ListTemporaryRechargeOrdersRequest) (*ListTemporaryRechargeOrdersResponse, error)
|
||||||
SubmitH5RechargeTx(context.Context, *SubmitH5RechargeTxRequest) (*H5RechargeOrderResponse, error)
|
SubmitH5RechargeTx(context.Context, *SubmitH5RechargeTxRequest) (*H5RechargeOrderResponse, error)
|
||||||
GetH5RechargeOrder(context.Context, *GetH5RechargeOrderRequest) (*H5RechargeOrderResponse, error)
|
GetH5RechargeOrder(context.Context, *GetH5RechargeOrderRequest) (*H5RechargeOrderResponse, error)
|
||||||
|
VerifyCoinSellerRechargeReceipt(context.Context, *VerifyCoinSellerRechargeReceiptRequest) (*VerifyCoinSellerRechargeReceiptResponse, error)
|
||||||
HandleMifapayNotify(context.Context, *HandleMifapayNotifyRequest) (*HandleMifapayNotifyResponse, error)
|
HandleMifapayNotify(context.Context, *HandleMifapayNotifyRequest) (*HandleMifapayNotifyResponse, error)
|
||||||
HandleV5PayNotify(context.Context, *HandleV5PayNotifyRequest) (*HandleV5PayNotifyResponse, error)
|
HandleV5PayNotify(context.Context, *HandleV5PayNotifyRequest) (*HandleV5PayNotifyResponse, error)
|
||||||
GetDiamondExchangeConfig(context.Context, *GetDiamondExchangeConfigRequest) (*GetDiamondExchangeConfigResponse, error)
|
GetDiamondExchangeConfig(context.Context, *GetDiamondExchangeConfigRequest) (*GetDiamondExchangeConfigResponse, error)
|
||||||
@ -1770,6 +1783,9 @@ func (UnimplementedWalletServiceServer) SubmitH5RechargeTx(context.Context, *Sub
|
|||||||
func (UnimplementedWalletServiceServer) GetH5RechargeOrder(context.Context, *GetH5RechargeOrderRequest) (*H5RechargeOrderResponse, error) {
|
func (UnimplementedWalletServiceServer) GetH5RechargeOrder(context.Context, *GetH5RechargeOrderRequest) (*H5RechargeOrderResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method GetH5RechargeOrder not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method GetH5RechargeOrder not implemented")
|
||||||
}
|
}
|
||||||
|
func (UnimplementedWalletServiceServer) VerifyCoinSellerRechargeReceipt(context.Context, *VerifyCoinSellerRechargeReceiptRequest) (*VerifyCoinSellerRechargeReceiptResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method VerifyCoinSellerRechargeReceipt not implemented")
|
||||||
|
}
|
||||||
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")
|
||||||
}
|
}
|
||||||
@ -3204,6 +3220,24 @@ func _WalletService_GetH5RechargeOrder_Handler(srv interface{}, ctx context.Cont
|
|||||||
return interceptor(ctx, in, info, handler)
|
return interceptor(ctx, in, info, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _WalletService_VerifyCoinSellerRechargeReceipt_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(VerifyCoinSellerRechargeReceiptRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(WalletServiceServer).VerifyCoinSellerRechargeReceipt(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: WalletService_VerifyCoinSellerRechargeReceipt_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(WalletServiceServer).VerifyCoinSellerRechargeReceipt(ctx, req.(*VerifyCoinSellerRechargeReceiptRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
func _WalletService_HandleMifapayNotify_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
func _WalletService_HandleMifapayNotify_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(HandleMifapayNotifyRequest)
|
in := new(HandleMifapayNotifyRequest)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
@ -3993,6 +4027,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "GetH5RechargeOrder",
|
MethodName: "GetH5RechargeOrder",
|
||||||
Handler: _WalletService_GetH5RechargeOrder_Handler,
|
Handler: _WalletService_GetH5RechargeOrder_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "VerifyCoinSellerRechargeReceipt",
|
||||||
|
Handler: _WalletService_VerifyCoinSellerRechargeReceipt_Handler,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
MethodName: "HandleMifapayNotify",
|
MethodName: "HandleMifapayNotify",
|
||||||
Handler: _WalletService_HandleMifapayNotify_Handler,
|
Handler: _WalletService_HandleMifapayNotify_Handler,
|
||||||
|
|||||||
@ -95,6 +95,8 @@ services:
|
|||||||
container_name: wallet-service
|
container_name: wallet-service
|
||||||
environment:
|
environment:
|
||||||
TZ: UTC
|
TZ: UTC
|
||||||
|
volumes:
|
||||||
|
- ./.env:/app/.env:ro
|
||||||
ports:
|
ports:
|
||||||
- "13004:13004"
|
- "13004:13004"
|
||||||
- "13104:13104"
|
- "13104:13104"
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
package configx
|
package configx
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
@ -16,3 +19,82 @@ func LoadYAML(path string, out any) error {
|
|||||||
|
|
||||||
return yaml.Unmarshal(body, out)
|
return yaml.Unmarshal(body, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoadYAMLWithEnv 只展开 ${NAME} 形式的环境变量;普通 $ 字符保留,避免误伤密钥正文。
|
||||||
|
func LoadYAMLWithEnv(path string, out any) error {
|
||||||
|
body, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return yaml.Unmarshal([]byte(expandBraceEnv(string(body))), out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadDotEnvFile 读取本地运行时 .env 文件;真实环境变量优先,文件里的值只补本地缺省。
|
||||||
|
func LoadDotEnvFile(path string) error {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for scanner.Scan() {
|
||||||
|
key, value, ok := parseDotEnvLine(scanner.Text())
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := os.LookupEnv(key); exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.Setenv(key, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scanner.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDotEnvLine(line string) (string, string, bool) {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
line = strings.TrimPrefix(line, "export ")
|
||||||
|
key, value, ok := strings.Cut(line, "=")
|
||||||
|
if !ok {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if key == "" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if len(value) >= 2 {
|
||||||
|
quote := value[0]
|
||||||
|
if (quote == '"' || quote == '\'') && value[len(value)-1] == quote {
|
||||||
|
value = value[1 : len(value)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return key, value, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandBraceEnv(input string) string {
|
||||||
|
var builder strings.Builder
|
||||||
|
for i := 0; i < len(input); {
|
||||||
|
if input[i] == '$' && i+1 < len(input) && input[i+1] == '{' {
|
||||||
|
end := strings.IndexByte(input[i+2:], '}')
|
||||||
|
if end >= 0 {
|
||||||
|
key := input[i+2 : i+2+end]
|
||||||
|
builder.WriteString(os.Getenv(key))
|
||||||
|
i += end + 3
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
builder.WriteByte(input[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
|
|||||||
48
pkg/configx/configx_test.go
Normal file
48
pkg/configx/configx_test.go
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
package configx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadYAMLExpandsBraceEnvOnly(t *testing.T) {
|
||||||
|
t.Setenv("CONFIGX_TEST_SECRET", "secret-value")
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||||
|
if err := os.WriteFile(path, []byte("secret: \"${CONFIGX_TEST_SECRET}\"\nliteral: \"price$100\"\nmissing: \"${CONFIGX_TEST_MISSING}\"\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write config failed: %v", err)
|
||||||
|
}
|
||||||
|
var cfg struct {
|
||||||
|
Secret string `yaml:"secret"`
|
||||||
|
Literal string `yaml:"literal"`
|
||||||
|
Missing string `yaml:"missing"`
|
||||||
|
}
|
||||||
|
if err := LoadYAMLWithEnv(path, &cfg); err != nil {
|
||||||
|
t.Fatalf("LoadYAMLWithEnv failed: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Secret != "secret-value" || cfg.Literal != "price$100" || cfg.Missing != "" {
|
||||||
|
t.Fatalf("env expansion mismatch: %+v", cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadDotEnvFileDoesNotOverrideRuntimeEnv(t *testing.T) {
|
||||||
|
t.Setenv("CONFIGX_DOTENV_EXISTING", "runtime")
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = os.Unsetenv("CONFIGX_DOTENV_NEW")
|
||||||
|
_ = os.Unsetenv("CONFIGX_DOTENV_EXPORTED")
|
||||||
|
})
|
||||||
|
path := filepath.Join(t.TempDir(), ".env")
|
||||||
|
content := []byte("CONFIGX_DOTENV_EXISTING=file\nCONFIGX_DOTENV_NEW=\"file-value\"\n# comment\nexport CONFIGX_DOTENV_EXPORTED='exported-value'\n")
|
||||||
|
if err := os.WriteFile(path, content, 0o600); err != nil {
|
||||||
|
t.Fatalf("write .env failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := LoadDotEnvFile(path); err != nil {
|
||||||
|
t.Fatalf("LoadDotEnvFile failed: %v", err)
|
||||||
|
}
|
||||||
|
if os.Getenv("CONFIGX_DOTENV_EXISTING") != "runtime" {
|
||||||
|
t.Fatalf(".env must not override runtime env")
|
||||||
|
}
|
||||||
|
if os.Getenv("CONFIGX_DOTENV_NEW") != "file-value" || os.Getenv("CONFIGX_DOTENV_EXPORTED") != "exported-value" {
|
||||||
|
t.Fatalf(".env variables were not loaded")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -44,6 +44,7 @@ import (
|
|||||||
dashboardmodule "hyapp-admin-server/internal/modules/dashboard"
|
dashboardmodule "hyapp-admin-server/internal/modules/dashboard"
|
||||||
databimodule "hyapp-admin-server/internal/modules/databi"
|
databimodule "hyapp-admin-server/internal/modules/databi"
|
||||||
financeapplicationmodule "hyapp-admin-server/internal/modules/financeapplication"
|
financeapplicationmodule "hyapp-admin-server/internal/modules/financeapplication"
|
||||||
|
financeordermodule "hyapp-admin-server/internal/modules/financeorder"
|
||||||
financewithdrawalmodule "hyapp-admin-server/internal/modules/financewithdrawal"
|
financewithdrawalmodule "hyapp-admin-server/internal/modules/financewithdrawal"
|
||||||
firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward"
|
firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward"
|
||||||
fullservernoticemodule "hyapp-admin-server/internal/modules/fullservernotice"
|
fullservernoticemodule "hyapp-admin-server/internal/modules/fullservernotice"
|
||||||
@ -198,6 +199,11 @@ func main() {
|
|||||||
fatalRuntime("connect_finance_bill_sources_failed", err)
|
fatalRuntime("connect_finance_bill_sources_failed", err)
|
||||||
}
|
}
|
||||||
defer closeFinanceBillSources(context.Background())
|
defer closeFinanceBillSources(context.Background())
|
||||||
|
legacyCoinSellerRechargeWriters, closeLegacyCoinSellerRechargeWriters, err := connectLegacyCoinSellerRechargeWriters(context.Background(), cfg.LegacyCoinSellerRechargeSources)
|
||||||
|
if err != nil {
|
||||||
|
fatalRuntime("connect_legacy_coin_seller_recharge_sources_failed", err)
|
||||||
|
}
|
||||||
|
defer closeLegacyCoinSellerRechargeWriters(context.Background())
|
||||||
|
|
||||||
store := repository.New(db)
|
store := repository.New(db)
|
||||||
if cfg.MySQLAutoMigrate {
|
if cfg.MySQLAutoMigrate {
|
||||||
@ -329,6 +335,7 @@ func main() {
|
|||||||
Databi: databimodule.New(databiService, store, auditHandler),
|
Databi: databimodule.New(databiService, store, auditHandler),
|
||||||
FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||||
FinanceApplication: financeapplicationmodule.New(store, auditHandler, financeApplicationOptions...),
|
FinanceApplication: financeapplicationmodule.New(store, auditHandler, financeApplicationOptions...),
|
||||||
|
FinanceOrder: financeordermodule.New(store, walletclient.NewGRPC(walletConn), userclient.NewGRPC(userConn), auditHandler, financeordermodule.WithLegacyCoinSellerRechargeWriters(legacyCoinSellerRechargeWriters...)),
|
||||||
FinanceWithdrawal: financewithdrawalmodule.New(store, walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn), auditHandler),
|
FinanceWithdrawal: financewithdrawalmodule.New(store, walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn), auditHandler),
|
||||||
FullServerNotice: fullservernoticemodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
FullServerNotice: fullservernoticemodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||||
Game: gamemanagementmodule.New(
|
Game: gamemanagementmodule.New(
|
||||||
@ -498,6 +505,57 @@ func connectFinanceBillSources(ctx context.Context, configs []config.FinanceBill
|
|||||||
return sources, cleanup, nil
|
return sources, cleanup, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// connectLegacyCoinSellerRechargeWriters 只连接显式启用的 legacy 可写账套;
|
||||||
|
// dashboard/账单源是只读报表连接,不能复用来写 Aslan/Yumi 钱包流水。
|
||||||
|
func connectLegacyCoinSellerRechargeWriters(ctx context.Context, configs []config.LegacyCoinSellerRechargeSourceConfig) ([]financeordermodule.LegacyCoinSellerRechargeWriter, func(context.Context), error) {
|
||||||
|
writers := []financeordermodule.LegacyCoinSellerRechargeWriter{}
|
||||||
|
cleanup := func(context.Context) {
|
||||||
|
for _, writer := range writers {
|
||||||
|
if writer != nil {
|
||||||
|
_ = writer.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, sourceConfig := range configs {
|
||||||
|
if !sourceConfig.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
appDB, err := sql.Open("mysql", sourceConfig.AppMySQLDSN)
|
||||||
|
if err != nil {
|
||||||
|
cleanup(ctx)
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
walletDB, err := sql.Open("mysql", sourceConfig.WalletMySQLDSN)
|
||||||
|
if err != nil {
|
||||||
|
_ = appDB.Close()
|
||||||
|
cleanup(ctx)
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
timeout := sourceConfig.RequestTimeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 5 * time.Second
|
||||||
|
}
|
||||||
|
connectCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
if err := appDB.PingContext(connectCtx); err != nil {
|
||||||
|
cancel()
|
||||||
|
_ = appDB.Close()
|
||||||
|
_ = walletDB.Close()
|
||||||
|
cleanup(ctx)
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := walletDB.PingContext(connectCtx); err != nil {
|
||||||
|
cancel()
|
||||||
|
_ = appDB.Close()
|
||||||
|
_ = walletDB.Close()
|
||||||
|
cleanup(ctx)
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
writers = append(writers, financeordermodule.NewMySQLLegacyCoinSellerRechargeWriter(sourceConfig, appDB, walletDB))
|
||||||
|
}
|
||||||
|
return writers, cleanup, nil
|
||||||
|
}
|
||||||
|
|
||||||
func dashboardExternalSourceIndex(sources []dashboardmodule.ExternalDashboardSource) map[string]dashboardmodule.ExternalDashboardSource {
|
func dashboardExternalSourceIndex(sources []dashboardmodule.ExternalDashboardSource) map[string]dashboardmodule.ExternalDashboardSource {
|
||||||
index := map[string]dashboardmodule.ExternalDashboardSource{}
|
index := map[string]dashboardmodule.ExternalDashboardSource{}
|
||||||
for _, source := range sources {
|
for _, source := range sources {
|
||||||
|
|||||||
@ -123,6 +123,21 @@ dashboard_external_sources:
|
|||||||
# aslan_mongo 源按整段区间做聚合(注册/日活/充值/礼物 + 留存 cohort),30 天区间比逐日查询重,
|
# aslan_mongo 源按整段区间做聚合(注册/日活/充值/礼物 + 留存 cohort),30 天区间比逐日查询重,
|
||||||
# 5s 会在长区间超时;线上建议 30s 起。
|
# 5s 会在长区间超时;线上建议 30s 起。
|
||||||
request_timeout: "30s"
|
request_timeout: "30s"
|
||||||
|
legacy_coin_seller_recharge_sources:
|
||||||
|
- enabled: false
|
||||||
|
app_code: "yumi"
|
||||||
|
app_name: "Yumi"
|
||||||
|
sys_origin: "LIKEI"
|
||||||
|
app_mysql_dsn: "likei_writer:REPLACE_ME@tcp(10.2.21.3:3306)/likei?parseTime=true&charset=utf8mb4&loc=Asia%2FRiyadh"
|
||||||
|
wallet_mysql_dsn: "likei_wallet_writer:REPLACE_ME@tcp(10.2.21.3:3306)/likei_wallet?parseTime=true&charset=utf8mb4&loc=Asia%2FRiyadh"
|
||||||
|
request_timeout: "5s"
|
||||||
|
- enabled: false
|
||||||
|
app_code: "aslan"
|
||||||
|
app_name: "Aslan"
|
||||||
|
sys_origin: "ATYOU"
|
||||||
|
app_mysql_dsn: "aslan_writer:REPLACE_ME@tcp(sg-cdb-pa8hgyh1.sql.tencentcdb.com:29850)/atyou?parseTime=true&charset=utf8mb4&loc=Asia%2FShanghai"
|
||||||
|
wallet_mysql_dsn: "aslan_wallet_writer:REPLACE_ME@tcp(sg-cdb-pa8hgyh1.sql.tencentcdb.com:29850)/atyou_wallet?parseTime=true&charset=utf8mb4&loc=Asia%2FShanghai"
|
||||||
|
request_timeout: "5s"
|
||||||
finance_notifications:
|
finance_notifications:
|
||||||
dingtalk:
|
dingtalk:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
@ -120,6 +120,21 @@ dashboard_external_sources:
|
|||||||
legacy_wallet_database: "atyou_wallet"
|
legacy_wallet_database: "atyou_wallet"
|
||||||
stat_timezone: "Asia/Shanghai"
|
stat_timezone: "Asia/Shanghai"
|
||||||
request_timeout: "5s"
|
request_timeout: "5s"
|
||||||
|
legacy_coin_seller_recharge_sources:
|
||||||
|
- enabled: false
|
||||||
|
app_code: "yumi"
|
||||||
|
app_name: "Yumi"
|
||||||
|
sys_origin: "LIKEI"
|
||||||
|
app_mysql_dsn: ""
|
||||||
|
wallet_mysql_dsn: ""
|
||||||
|
request_timeout: "5s"
|
||||||
|
- enabled: false
|
||||||
|
app_code: "aslan"
|
||||||
|
app_name: "Aslan"
|
||||||
|
sys_origin: "ATYOU"
|
||||||
|
app_mysql_dsn: ""
|
||||||
|
wallet_mysql_dsn: ""
|
||||||
|
request_timeout: "5s"
|
||||||
finance_notifications:
|
finance_notifications:
|
||||||
dingtalk:
|
dingtalk:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
@ -13,39 +13,40 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ServiceName string `yaml:"service_name"`
|
ServiceName string `yaml:"service_name"`
|
||||||
NodeID string `yaml:"node_id"`
|
NodeID string `yaml:"node_id"`
|
||||||
Environment string `yaml:"environment"`
|
Environment string `yaml:"environment"`
|
||||||
Log logging.Config `yaml:"log"`
|
Log logging.Config `yaml:"log"`
|
||||||
HTTPAddr string `yaml:"http_addr"`
|
HTTPAddr string `yaml:"http_addr"`
|
||||||
MySQLDSN string `yaml:"mysql_dsn"`
|
MySQLDSN string `yaml:"mysql_dsn"`
|
||||||
UserMySQLDSN string `yaml:"user_mysql_dsn"`
|
UserMySQLDSN string `yaml:"user_mysql_dsn"`
|
||||||
WalletMySQLDSN string `yaml:"wallet_mysql_dsn"`
|
WalletMySQLDSN string `yaml:"wallet_mysql_dsn"`
|
||||||
RobotProfileSource RobotProfileSourceConfig `yaml:"robot_profile_source"`
|
RobotProfileSource RobotProfileSourceConfig `yaml:"robot_profile_source"`
|
||||||
MoneyRegionSources []MoneyRegionSourceConfig `yaml:"money_region_sources"`
|
MoneyRegionSources []MoneyRegionSourceConfig `yaml:"money_region_sources"`
|
||||||
FinanceBillSources []FinanceBillSourceConfig `yaml:"finance_bill_sources"`
|
FinanceBillSources []FinanceBillSourceConfig `yaml:"finance_bill_sources"`
|
||||||
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
|
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
|
||||||
Migrations MigrationConfig `yaml:"migrations"`
|
Migrations MigrationConfig `yaml:"migrations"`
|
||||||
JWTSecret string `yaml:"jwt_secret"`
|
JWTSecret string `yaml:"jwt_secret"`
|
||||||
AccessTokenTTL time.Duration `yaml:"access_token_ttl"`
|
AccessTokenTTL time.Duration `yaml:"access_token_ttl"`
|
||||||
RefreshTokenTTL time.Duration `yaml:"refresh_token_ttl"`
|
RefreshTokenTTL time.Duration `yaml:"refresh_token_ttl"`
|
||||||
CORSAllowedOrigins []string `yaml:"cors_allowed_origins"`
|
CORSAllowedOrigins []string `yaml:"cors_allowed_origins"`
|
||||||
RefreshCookieSecure bool `yaml:"refresh_cookie_secure"`
|
RefreshCookieSecure bool `yaml:"refresh_cookie_secure"`
|
||||||
RefreshCookieSameSite string `yaml:"refresh_cookie_same_site"`
|
RefreshCookieSameSite string `yaml:"refresh_cookie_same_site"`
|
||||||
Bootstrap BootstrapConfig `yaml:"bootstrap"`
|
Bootstrap BootstrapConfig `yaml:"bootstrap"`
|
||||||
BootstrapPassword string `yaml:"bootstrap_password"`
|
BootstrapPassword string `yaml:"bootstrap_password"`
|
||||||
UserService UserServiceConfig `yaml:"user_service"`
|
UserService UserServiceConfig `yaml:"user_service"`
|
||||||
TencentCOS TencentCOSConfig `yaml:"tencent-cos"`
|
TencentCOS TencentCOSConfig `yaml:"tencent-cos"`
|
||||||
Redis RedisConfig `yaml:"redis"`
|
Redis RedisConfig `yaml:"redis"`
|
||||||
Jobs JobsConfig `yaml:"jobs"`
|
Jobs JobsConfig `yaml:"jobs"`
|
||||||
WalletService WalletServiceConfig `yaml:"wallet_service"`
|
WalletService WalletServiceConfig `yaml:"wallet_service"`
|
||||||
RoomService RoomServiceConfig `yaml:"room_service"`
|
RoomService RoomServiceConfig `yaml:"room_service"`
|
||||||
RobotService RobotServiceConfig `yaml:"robot_service"`
|
RobotService RobotServiceConfig `yaml:"robot_service"`
|
||||||
ActivityService ActivityServiceConfig `yaml:"activity_service"`
|
ActivityService ActivityServiceConfig `yaml:"activity_service"`
|
||||||
GameService GameServiceConfig `yaml:"game_service"`
|
GameService GameServiceConfig `yaml:"game_service"`
|
||||||
StatisticsService StatisticsServiceConfig `yaml:"statistics_service"`
|
StatisticsService StatisticsServiceConfig `yaml:"statistics_service"`
|
||||||
DashboardExternalSources []DashboardExternalSourceConfig `yaml:"dashboard_external_sources"`
|
DashboardExternalSources []DashboardExternalSourceConfig `yaml:"dashboard_external_sources"`
|
||||||
FinanceNotifications FinanceNotificationsConfig `yaml:"finance_notifications"`
|
LegacyCoinSellerRechargeSources []LegacyCoinSellerRechargeSourceConfig `yaml:"legacy_coin_seller_recharge_sources"`
|
||||||
|
FinanceNotifications FinanceNotificationsConfig `yaml:"finance_notifications"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MigrationConfig struct {
|
type MigrationConfig struct {
|
||||||
@ -106,6 +107,18 @@ type DashboardExternalSourceConfig struct {
|
|||||||
RequestTimeout time.Duration `yaml:"request_timeout"`
|
RequestTimeout time.Duration `yaml:"request_timeout"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LegacyCoinSellerRechargeSourceConfig 是 Aslan/Yumi 等独立账套的币商进货写入源。
|
||||||
|
// 它与 dashboard_external_sources 分离,避免把只读报表连接误当成可写钱包连接。
|
||||||
|
type LegacyCoinSellerRechargeSourceConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
AppCode string `yaml:"app_code"`
|
||||||
|
AppName string `yaml:"app_name"`
|
||||||
|
SysOrigin string `yaml:"sys_origin"`
|
||||||
|
AppMySQLDSN string `yaml:"app_mysql_dsn"`
|
||||||
|
WalletMySQLDSN string `yaml:"wallet_mysql_dsn"`
|
||||||
|
RequestTimeout time.Duration `yaml:"request_timeout"`
|
||||||
|
}
|
||||||
|
|
||||||
type FinanceNotificationsConfig struct {
|
type FinanceNotificationsConfig struct {
|
||||||
DingTalk DingTalkRobotConfig `yaml:"dingtalk"`
|
DingTalk DingTalkRobotConfig `yaml:"dingtalk"`
|
||||||
}
|
}
|
||||||
@ -316,6 +329,22 @@ func Default() Config {
|
|||||||
RequestTimeout: 5 * time.Second,
|
RequestTimeout: 5 * time.Second,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
LegacyCoinSellerRechargeSources: []LegacyCoinSellerRechargeSourceConfig{
|
||||||
|
{
|
||||||
|
Enabled: false,
|
||||||
|
AppCode: "yumi",
|
||||||
|
AppName: "Yumi",
|
||||||
|
SysOrigin: "LIKEI",
|
||||||
|
RequestTimeout: 5 * time.Second,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Enabled: false,
|
||||||
|
AppCode: "aslan",
|
||||||
|
AppName: "Aslan",
|
||||||
|
SysOrigin: "ATYOU",
|
||||||
|
RequestTimeout: 5 * time.Second,
|
||||||
|
},
|
||||||
|
},
|
||||||
FinanceNotifications: FinanceNotificationsConfig{
|
FinanceNotifications: FinanceNotificationsConfig{
|
||||||
DingTalk: DingTalkRobotConfig{
|
DingTalk: DingTalkRobotConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
@ -494,6 +523,18 @@ func (cfg *Config) Normalize() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
cfg.applyDashboardExternalSourceEnvOverrides()
|
cfg.applyDashboardExternalSourceEnvOverrides()
|
||||||
|
for index := range cfg.LegacyCoinSellerRechargeSources {
|
||||||
|
source := &cfg.LegacyCoinSellerRechargeSources[index]
|
||||||
|
source.AppCode = strings.ToLower(strings.TrimSpace(source.AppCode))
|
||||||
|
source.AppName = strings.TrimSpace(source.AppName)
|
||||||
|
source.SysOrigin = strings.ToUpper(strings.TrimSpace(source.SysOrigin))
|
||||||
|
source.AppMySQLDSN = strings.TrimSpace(source.AppMySQLDSN)
|
||||||
|
source.WalletMySQLDSN = strings.TrimSpace(source.WalletMySQLDSN)
|
||||||
|
if source.RequestTimeout <= 0 {
|
||||||
|
source.RequestTimeout = 5 * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cfg.applyLegacyCoinSellerRechargeSourceEnvOverrides()
|
||||||
cfg.applyFinanceNotificationEnvOverrides()
|
cfg.applyFinanceNotificationEnvOverrides()
|
||||||
cfg.FinanceNotifications.DingTalk.WebhookURL = strings.TrimSpace(cfg.FinanceNotifications.DingTalk.WebhookURL)
|
cfg.FinanceNotifications.DingTalk.WebhookURL = strings.TrimSpace(cfg.FinanceNotifications.DingTalk.WebhookURL)
|
||||||
cfg.FinanceNotifications.DingTalk.Secret = strings.TrimSpace(cfg.FinanceNotifications.DingTalk.Secret)
|
cfg.FinanceNotifications.DingTalk.Secret = strings.TrimSpace(cfg.FinanceNotifications.DingTalk.Secret)
|
||||||
@ -616,6 +657,20 @@ func (cfg Config) Validate() error {
|
|||||||
return errors.New("bootstrap.password must not use the default value outside local/dev")
|
return errors.New("bootstrap.password must not use the default value outside local/dev")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, source := range cfg.LegacyCoinSellerRechargeSources {
|
||||||
|
if !source.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(source.AppCode) == "" {
|
||||||
|
return errors.New("legacy_coin_seller_recharge_sources app_code is required when enabled")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(source.SysOrigin) == "" {
|
||||||
|
return errors.New("legacy_coin_seller_recharge_sources sys_origin is required when enabled")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(source.AppMySQLDSN) == "" || strings.TrimSpace(source.WalletMySQLDSN) == "" {
|
||||||
|
return errors.New("legacy_coin_seller_recharge_sources app_mysql_dsn and wallet_mysql_dsn are required when enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
if len(cfg.CORSAllowedOrigins) == 0 {
|
if len(cfg.CORSAllowedOrigins) == 0 {
|
||||||
return errors.New("cors_allowed_origins must not be empty")
|
return errors.New("cors_allowed_origins must not be empty")
|
||||||
}
|
}
|
||||||
@ -926,6 +981,33 @@ func (cfg *Config) applyFinanceBillSourceEnvOverrides() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) applyLegacyCoinSellerRechargeSourceEnvOverrides() {
|
||||||
|
for index := range cfg.LegacyCoinSellerRechargeSources {
|
||||||
|
source := &cfg.LegacyCoinSellerRechargeSources[index]
|
||||||
|
appKey := envAppKey(source.AppCode)
|
||||||
|
if appKey == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(os.Getenv("HYAPP_ADMIN_" + appKey + "_LEGACY_COIN_SELLER_RECHARGE_ENABLED")); value != "" {
|
||||||
|
source.Enabled = parseBoolEnv(value)
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(firstEnv(
|
||||||
|
"HYAPP_ADMIN_"+appKey+"_COIN_SELLER_RECHARGE_APP_MYSQL_DSN",
|
||||||
|
"HYAPP_ADMIN_"+appKey+"_LEGACY_APP_MYSQL_DSN",
|
||||||
|
)); value != "" {
|
||||||
|
// legacy 用户库 DSN 带可写权限,只能由运行环境注入;仓库配置保持占位。
|
||||||
|
source.AppMySQLDSN = value
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(firstEnv(
|
||||||
|
"HYAPP_ADMIN_"+appKey+"_COIN_SELLER_RECHARGE_WALLET_MYSQL_DSN",
|
||||||
|
"HYAPP_ADMIN_"+appKey+"_LEGACY_WALLET_MYSQL_DSN",
|
||||||
|
)); value != "" {
|
||||||
|
// legacy 钱包库 DSN 用于写 user_freight_balance_running_water,不能复用只读报表源。
|
||||||
|
source.WalletMySQLDSN = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func envAppKey(appCode string) string {
|
func envAppKey(appCode string) string {
|
||||||
appCode = strings.ToUpper(strings.TrimSpace(appCode))
|
appCode = strings.ToUpper(strings.TrimSpace(appCode))
|
||||||
if appCode == "" {
|
if appCode == "" {
|
||||||
|
|||||||
@ -51,6 +51,7 @@ type Client interface {
|
|||||||
CreateTemporaryRechargeOrder(ctx context.Context, req *walletv1.CreateTemporaryRechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error)
|
CreateTemporaryRechargeOrder(ctx context.Context, req *walletv1.CreateTemporaryRechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error)
|
||||||
GetTemporaryRechargeOrder(ctx context.Context, req *walletv1.GetTemporaryRechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error)
|
GetTemporaryRechargeOrder(ctx context.Context, req *walletv1.GetTemporaryRechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error)
|
||||||
ListTemporaryRechargeOrders(ctx context.Context, req *walletv1.ListTemporaryRechargeOrdersRequest) (*walletv1.ListTemporaryRechargeOrdersResponse, error)
|
ListTemporaryRechargeOrders(ctx context.Context, req *walletv1.ListTemporaryRechargeOrdersRequest) (*walletv1.ListTemporaryRechargeOrdersResponse, error)
|
||||||
|
VerifyCoinSellerRechargeReceipt(ctx context.Context, req *walletv1.VerifyCoinSellerRechargeReceiptRequest) (*walletv1.VerifyCoinSellerRechargeReceiptResponse, error)
|
||||||
SetThirdPartyPaymentMethodStatus(ctx context.Context, req *walletv1.SetThirdPartyPaymentMethodStatusRequest) (*walletv1.ThirdPartyPaymentMethodResponse, error)
|
SetThirdPartyPaymentMethodStatus(ctx context.Context, req *walletv1.SetThirdPartyPaymentMethodStatusRequest) (*walletv1.ThirdPartyPaymentMethodResponse, error)
|
||||||
UpdateThirdPartyPaymentRate(ctx context.Context, req *walletv1.UpdateThirdPartyPaymentRateRequest) (*walletv1.ThirdPartyPaymentMethodResponse, error)
|
UpdateThirdPartyPaymentRate(ctx context.Context, req *walletv1.UpdateThirdPartyPaymentRateRequest) (*walletv1.ThirdPartyPaymentMethodResponse, error)
|
||||||
SyncThirdPartyPaymentMethods(ctx context.Context, req *walletv1.SyncThirdPartyPaymentMethodsRequest) (*walletv1.SyncThirdPartyPaymentMethodsResponse, error)
|
SyncThirdPartyPaymentMethods(ctx context.Context, req *walletv1.SyncThirdPartyPaymentMethodsRequest) (*walletv1.SyncThirdPartyPaymentMethodsResponse, error)
|
||||||
@ -240,6 +241,10 @@ func (c *GRPCClient) ListTemporaryRechargeOrders(ctx context.Context, req *walle
|
|||||||
return c.client.ListTemporaryRechargeOrders(ctx, req)
|
return c.client.ListTemporaryRechargeOrders(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *GRPCClient) VerifyCoinSellerRechargeReceipt(ctx context.Context, req *walletv1.VerifyCoinSellerRechargeReceiptRequest) (*walletv1.VerifyCoinSellerRechargeReceiptResponse, error) {
|
||||||
|
return c.client.VerifyCoinSellerRechargeReceipt(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *GRPCClient) SetThirdPartyPaymentMethodStatus(ctx context.Context, req *walletv1.SetThirdPartyPaymentMethodStatusRequest) (*walletv1.ThirdPartyPaymentMethodResponse, error) {
|
func (c *GRPCClient) SetThirdPartyPaymentMethodStatus(ctx context.Context, req *walletv1.SetThirdPartyPaymentMethodStatusRequest) (*walletv1.ThirdPartyPaymentMethodResponse, error) {
|
||||||
return c.client.SetThirdPartyPaymentMethodStatus(ctx, req)
|
return c.client.SetThirdPartyPaymentMethodStatus(ctx, req)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,22 @@ const (
|
|||||||
FinanceApplicationStatusApproved = "approved"
|
FinanceApplicationStatusApproved = "approved"
|
||||||
FinanceApplicationStatusRejected = "rejected"
|
FinanceApplicationStatusRejected = "rejected"
|
||||||
|
|
||||||
|
CoinSellerRechargeOrderStatusCreated = "created"
|
||||||
|
CoinSellerRechargeOrderStatusVerifyFailed = "verify_failed"
|
||||||
|
CoinSellerRechargeOrderStatusVerified = "verified"
|
||||||
|
CoinSellerRechargeOrderStatusGranting = "granting"
|
||||||
|
CoinSellerRechargeOrderStatusGrantFailed = "grant_failed"
|
||||||
|
CoinSellerRechargeOrderStatusGranted = "granted"
|
||||||
|
|
||||||
|
CoinSellerRechargeVerifyStatusPending = "pending"
|
||||||
|
CoinSellerRechargeVerifyStatusVerified = "verified"
|
||||||
|
CoinSellerRechargeVerifyStatusFailed = "failed"
|
||||||
|
|
||||||
|
CoinSellerRechargeGrantStatusPending = "pending"
|
||||||
|
CoinSellerRechargeGrantStatusRunning = "processing"
|
||||||
|
CoinSellerRechargeGrantStatusGranted = "granted"
|
||||||
|
CoinSellerRechargeGrantStatusFailed = "failed"
|
||||||
|
|
||||||
WithdrawalApplicationStatusPending = "pending"
|
WithdrawalApplicationStatusPending = "pending"
|
||||||
WithdrawalApplicationStatusApproved = "approved"
|
WithdrawalApplicationStatusApproved = "approved"
|
||||||
WithdrawalApplicationStatusRejected = "rejected"
|
WithdrawalApplicationStatusRejected = "rejected"
|
||||||
@ -469,6 +485,55 @@ func (FinanceApplication) TableName() string {
|
|||||||
return "admin_finance_applications"
|
return "admin_finance_applications"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CoinSellerRechargeOrder 是后台币商进货的统一审计入口;真实入账事实仍由 Lalu wallet-service 或 legacy 钱包账套持有。
|
||||||
|
type CoinSellerRechargeOrder struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
AppCode string `gorm:"size:32;not null;index:idx_admin_coin_seller_recharge_app_time;uniqueIndex:uk_admin_coin_seller_recharge_external,priority:1" json:"appCode"`
|
||||||
|
TargetUserID int64 `gorm:"column:target_user_id;not null;index:idx_admin_coin_seller_recharge_target" json:"targetUserId"`
|
||||||
|
TargetDisplayUserID string `gorm:"column:target_display_user_id;size:64;not null;default:'';index:idx_admin_coin_seller_recharge_display" json:"targetDisplayUserId"`
|
||||||
|
TargetCountryID int64 `gorm:"column:target_country_id;not null;default:0" json:"targetCountryId"`
|
||||||
|
TargetRegionID int64 `gorm:"column:target_region_id;not null;default:0" json:"targetRegionId"`
|
||||||
|
USDAmount string `gorm:"column:usd_amount;type:decimal(18,2);not null;default:0.00" json:"usdAmount"`
|
||||||
|
USDMinorAmount int64 `gorm:"column:usd_minor_amount;not null;default:0" json:"usdMinorAmount"`
|
||||||
|
CoinAmount int64 `gorm:"column:coin_amount;not null;default:0" json:"coinAmount"`
|
||||||
|
ProviderCode string `gorm:"column:provider_code;size:32;not null;uniqueIndex:uk_admin_coin_seller_recharge_external,priority:2;index:idx_admin_coin_seller_recharge_provider" json:"providerCode"`
|
||||||
|
Chain string `gorm:"size:16;not null;default:'';uniqueIndex:uk_admin_coin_seller_recharge_external,priority:3" json:"chain"`
|
||||||
|
ExternalOrderNo string `gorm:"column:external_order_no;size:128;not null;uniqueIndex:uk_admin_coin_seller_recharge_external,priority:4;index:idx_admin_coin_seller_recharge_external" json:"externalOrderNo"`
|
||||||
|
ProviderOrderID string `gorm:"column:provider_order_id;size:128;not null;default:''" json:"providerOrderId"`
|
||||||
|
ProviderStatus string `gorm:"column:provider_status;size:64;not null;default:''" json:"providerStatus"`
|
||||||
|
ProviderCountryCode string `gorm:"column:provider_country_code;size:16;not null;default:''" json:"providerCountryCode"`
|
||||||
|
ProviderCurrencyCode string `gorm:"column:provider_currency_code;size:16;not null;default:''" json:"providerCurrencyCode"`
|
||||||
|
ProviderAmountMinor int64 `gorm:"column:provider_amount_minor;not null;default:0" json:"providerAmountMinor"`
|
||||||
|
ProviderPayType string `gorm:"column:provider_pay_type;size:64;not null;default:''" json:"providerPayType"`
|
||||||
|
ReceiveAddress string `gorm:"column:receive_address;size:128;not null;default:''" json:"receiveAddress"`
|
||||||
|
OrderDateMS int64 `gorm:"column:order_date_ms;not null;default:0" json:"orderDateMs"`
|
||||||
|
Status string `gorm:"size:32;not null;default:created;index:idx_admin_coin_seller_recharge_status_time" json:"status"`
|
||||||
|
VerifyStatus string `gorm:"column:verify_status;size:32;not null;default:pending;index:idx_admin_coin_seller_recharge_verify" json:"verifyStatus"`
|
||||||
|
GrantStatus string `gorm:"column:grant_status;size:32;not null;default:pending;index:idx_admin_coin_seller_recharge_grant" json:"grantStatus"`
|
||||||
|
Remark string `gorm:"type:text" json:"remark"`
|
||||||
|
FailureReason string `gorm:"column:failure_reason;type:text" json:"failureReason"`
|
||||||
|
ProviderPayloadJSON string `gorm:"column:provider_payload_json;type:text" json:"providerPayloadJson"`
|
||||||
|
WalletCommandID string `gorm:"column:wallet_command_id;size:128;not null;default:'';index:idx_admin_coin_seller_recharge_command" json:"walletCommandId"`
|
||||||
|
WalletTransactionID string `gorm:"column:wallet_transaction_id;size:128;not null;default:'';index:idx_admin_coin_seller_recharge_wallet_tx" json:"walletTransactionId"`
|
||||||
|
WalletAssetType string `gorm:"column:wallet_asset_type;size:64;not null;default:''" json:"walletAssetType"`
|
||||||
|
WalletAmountDelta int64 `gorm:"column:wallet_amount_delta;not null;default:0" json:"walletAmountDelta"`
|
||||||
|
WalletBalanceAfter int64 `gorm:"column:wallet_balance_after;not null;default:0" json:"walletBalanceAfter"`
|
||||||
|
OperatorUserID uint `gorm:"column:operator_user_id;not null;index:idx_admin_coin_seller_recharge_operator" json:"operatorUserId"`
|
||||||
|
OperatorName string `gorm:"column:operator_name;size:64;not null;default:''" json:"operatorName"`
|
||||||
|
VerifiedByUserID *uint `gorm:"column:verified_by_user_id;index:idx_admin_coin_seller_recharge_verifier" json:"verifiedByUserId"`
|
||||||
|
VerifiedByName string `gorm:"column:verified_by_name;size:64;not null;default:''" json:"verifiedByName"`
|
||||||
|
GrantedByUserID *uint `gorm:"column:granted_by_user_id;index:idx_admin_coin_seller_recharge_granter" json:"grantedByUserId"`
|
||||||
|
GrantedByName string `gorm:"column:granted_by_name;size:64;not null;default:''" json:"grantedByName"`
|
||||||
|
VerifiedAtMS *int64 `gorm:"column:verified_at_ms" json:"verifiedAtMs"`
|
||||||
|
GrantedAtMS *int64 `gorm:"column:granted_at_ms" json:"grantedAtMs"`
|
||||||
|
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli;index:idx_admin_coin_seller_recharge_app_time;index:idx_admin_coin_seller_recharge_status_time" json:"createdAtMs"`
|
||||||
|
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (CoinSellerRechargeOrder) TableName() string {
|
||||||
|
return "admin_coin_seller_recharge_orders"
|
||||||
|
}
|
||||||
|
|
||||||
type UserWithdrawalApplication struct {
|
type UserWithdrawalApplication struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
AppCode string `gorm:"size:32;index:idx_admin_withdrawal_app_status_time;not null" json:"appCode"`
|
AppCode string `gorm:"size:32;index:idx_admin_withdrawal_app_status_time;not null" json:"appCode"`
|
||||||
|
|||||||
@ -625,24 +625,23 @@ func (s *AslanMongoDashboardSource) loadRechargeStats(ctx context.Context, start
|
|||||||
"status": "SUCCESS",
|
"status": "SUCCESS",
|
||||||
"trialPeriod": bson.M{"$ne": true},
|
"trialPeriod": bson.M{"$ne": true},
|
||||||
"createTime": bson.M{"$gte": startDate, "$lt": endDate},
|
"createTime": bson.M{"$gte": startDate, "$lt": endDate},
|
||||||
"countryCode": bson.M{"$exists": true, "$ne": ""},
|
|
||||||
}
|
}
|
||||||
aslanMongoExcludeFreightGoldRecharge(match)
|
aslanMongoExcludeFreightGoldRecharge(match)
|
||||||
aslanMongoApplyCountryFilter(match, "countryCode", countryFilter)
|
|
||||||
// Aslan 原 Top 充值统计按 acceptUserId 聚合;createUser 只在 acceptUserId 缺失的旧单据上兜底。
|
// Aslan 原 Top 充值统计按 acceptUserId 聚合;createUser 只在 acceptUserId 缺失的旧单据上兜底。
|
||||||
userExpression := bson.D{{Key: "$ifNull", Value: bson.A{"$acceptUserId", "$createUser"}}}
|
userExpression := bson.D{{Key: "$ifNull", Value: bson.A{"$acceptUserId", "$createUser"}}}
|
||||||
pipeline := mongo.Pipeline{
|
pipeline := mongo.Pipeline{
|
||||||
{{Key: "$match", Value: match}},
|
{{Key: "$match", Value: match}},
|
||||||
{{Key: "$group", Value: bson.D{
|
|
||||||
{Key: "_id", Value: bson.D{
|
|
||||||
{Key: "day", Value: s.dayExpression("$createTime")},
|
|
||||||
{Key: "country", Value: "$countryCode"},
|
|
||||||
{Key: "factory", Value: bson.D{{Key: "$toUpper", Value: bson.D{{Key: "$ifNull", Value: bson.A{"$factory.factoryCode", ""}}}}}},
|
|
||||||
}},
|
|
||||||
{Key: "amount", Value: bson.D{{Key: "$sum", Value: "$amountUsd"}}},
|
|
||||||
{Key: "users", Value: bson.D{{Key: "$addToSet", Value: userExpression}}},
|
|
||||||
}}},
|
|
||||||
}
|
}
|
||||||
|
pipeline = append(pipeline, aslanMongoRechargeCountryStages(userExpression, countryFilter)...)
|
||||||
|
pipeline = append(pipeline, bson.D{{Key: "$group", Value: bson.D{
|
||||||
|
{Key: "_id", Value: bson.D{
|
||||||
|
{Key: "day", Value: s.dayExpression("$createTime")},
|
||||||
|
{Key: "country", Value: "$aslanBillCountry"},
|
||||||
|
{Key: "factory", Value: bson.D{{Key: "$toUpper", Value: bson.D{{Key: "$ifNull", Value: bson.A{"$factory.factoryCode", ""}}}}}},
|
||||||
|
}},
|
||||||
|
{Key: "amount", Value: bson.D{{Key: "$sum", Value: "$amountUsd"}}},
|
||||||
|
{Key: "users", Value: bson.D{{Key: "$addToSet", Value: "$aslanBillUserID"}}},
|
||||||
|
}}})
|
||||||
distinctUsers := map[aslanMongoDayCountryUsers]map[string]struct{}{}
|
distinctUsers := map[aslanMongoDayCountryUsers]map[string]struct{}{}
|
||||||
err := s.aggregateEach(ctx, aslanMongoInAppPurchaseCollection, pipeline, func(row bson.M) error {
|
err := s.aggregateEach(ctx, aslanMongoInAppPurchaseCollection, pipeline, func(row bson.M) error {
|
||||||
key := aslanMongoDocument(row["_id"])
|
key := aslanMongoDocument(row["_id"])
|
||||||
@ -684,14 +683,15 @@ func (s *AslanMongoDashboardSource) loadRechargeStats(ctx context.Context, start
|
|||||||
// 新用户充值:按 天×用户 聚合当日充值,再与当日注册 cohort 求交集;cohort 自带国家归属。
|
// 新用户充值:按 天×用户 聚合当日充值,再与当日注册 cohort 求交集;cohort 自带国家归属。
|
||||||
perUserPipeline := mongo.Pipeline{
|
perUserPipeline := mongo.Pipeline{
|
||||||
{{Key: "$match", Value: match}},
|
{{Key: "$match", Value: match}},
|
||||||
{{Key: "$group", Value: bson.D{
|
|
||||||
{Key: "_id", Value: bson.D{
|
|
||||||
{Key: "day", Value: s.dayExpression("$createTime")},
|
|
||||||
{Key: "user", Value: userExpression},
|
|
||||||
}},
|
|
||||||
{Key: "amount", Value: bson.D{{Key: "$sum", Value: "$amountUsd"}}},
|
|
||||||
}}},
|
|
||||||
}
|
}
|
||||||
|
perUserPipeline = append(perUserPipeline, aslanMongoRechargeCountryStages(userExpression, countryFilter)...)
|
||||||
|
perUserPipeline = append(perUserPipeline, bson.D{{Key: "$group", Value: bson.D{
|
||||||
|
{Key: "_id", Value: bson.D{
|
||||||
|
{Key: "day", Value: s.dayExpression("$createTime")},
|
||||||
|
{Key: "user", Value: "$aslanBillUserID"},
|
||||||
|
}},
|
||||||
|
{Key: "amount", Value: bson.D{{Key: "$sum", Value: "$amountUsd"}}},
|
||||||
|
}}})
|
||||||
perUser := map[string]map[int64]int64{}
|
perUser := map[string]map[int64]int64{}
|
||||||
err = s.aggregateEach(ctx, aslanMongoInAppPurchaseCollection, perUserPipeline, func(row bson.M) error {
|
err = s.aggregateEach(ctx, aslanMongoInAppPurchaseCollection, perUserPipeline, func(row bson.M) error {
|
||||||
key := aslanMongoDocument(row["_id"])
|
key := aslanMongoDocument(row["_id"])
|
||||||
@ -728,6 +728,41 @@ func (s *AslanMongoDashboardSource) loadRechargeStats(ctx context.Context, start
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func aslanMongoRechargeCountryStages(userExpression any, countryFilter externalDashboardCountryFilter) mongo.Pipeline {
|
||||||
|
// Google 账单普遍没有 countryCode;finance 口径已按付款用户资料兜底,dashboard/social 也必须一致,
|
||||||
|
// 否则全 App、按 App 对比和国家拆分会漏掉这些真实充值。
|
||||||
|
stages := mongo.Pipeline{
|
||||||
|
bson.D{{Key: "$addFields", Value: bson.M{"aslanBillUserID": userExpression}}},
|
||||||
|
bson.D{{Key: "$lookup", Value: bson.M{
|
||||||
|
"from": aslanMongoUserProfileCollection,
|
||||||
|
"localField": "aslanBillUserID",
|
||||||
|
"foreignField": "_id",
|
||||||
|
"as": "aslanBillUser",
|
||||||
|
}}},
|
||||||
|
bson.D{{Key: "$addFields", Value: bson.M{
|
||||||
|
"aslanBillCountry": bson.M{"$toUpper": bson.M{"$cond": bson.A{
|
||||||
|
bson.M{"$ne": bson.A{bson.M{"$ifNull": bson.A{"$countryCode", ""}}, ""}},
|
||||||
|
"$countryCode",
|
||||||
|
bson.M{"$ifNull": bson.A{bson.M{"$first": "$aslanBillUser.countryCode"}, ""}},
|
||||||
|
}}},
|
||||||
|
}}},
|
||||||
|
bson.D{{Key: "$match", Value: bson.M{"aslanBillCountry": bson.M{"$ne": ""}}}},
|
||||||
|
}
|
||||||
|
if countryFilter.Applied {
|
||||||
|
codes := make([]string, 0, len(countryFilter.Codes))
|
||||||
|
for _, code := range countryFilter.Codes {
|
||||||
|
if normalized := aslanMongoCountryCode(code); normalized != "" {
|
||||||
|
codes = append(codes, normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(codes) == 0 {
|
||||||
|
codes = append(codes, "__NO_COUNTRY__")
|
||||||
|
}
|
||||||
|
stages = append(stages, bson.D{{Key: "$match", Value: bson.M{"aslanBillCountry": bson.M{"$in": codes}}}})
|
||||||
|
}
|
||||||
|
return stages
|
||||||
|
}
|
||||||
|
|
||||||
func applyAslanRechargeUserMetrics(cohorts map[string]map[string][]int64, grid *legacyMongoGrid, distinctUsers map[aslanMongoDayCountryUsers]map[string]struct{}, perUser map[string]map[int64]int64) {
|
func applyAslanRechargeUserMetrics(cohorts map[string]map[string][]int64, grid *legacyMongoGrid, distinctUsers map[aslanMongoDayCountryUsers]map[string]struct{}, perUser map[string]map[int64]int64) {
|
||||||
for key, userSet := range distinctUsers {
|
for key, userSet := range distinctUsers {
|
||||||
grid.at(key.day, key.country).PaidUsers = int64(len(userSet))
|
grid.at(key.day, key.country).PaidUsers = int64(len(userSet))
|
||||||
@ -1757,8 +1792,8 @@ func aslanMongoDashboardMetricSources(goldWater bool, legacyRecharge bool) []map
|
|||||||
legacySources := []map[string]any{
|
legacySources := []map[string]any{
|
||||||
{"field": "coin_seller_recharge_usd_minor", "available": true, "source": "likei legacy MySQL user_gold_coin_recharge gold_coin_source=2 recharge_amount + user_freight_balance_running_water PURCHASE amount/usd_quantity - DEDUCTION amount/usd_quantity"},
|
{"field": "coin_seller_recharge_usd_minor", "available": true, "source": "likei legacy MySQL user_gold_coin_recharge gold_coin_source=2 recharge_amount + user_freight_balance_running_water PURCHASE amount/usd_quantity - DEDUCTION amount/usd_quantity"},
|
||||||
}
|
}
|
||||||
rechargeUsersSource := "likei Mongo in_app_purchase_details SUCCESS distinct acceptUserId(排除 FREIGHT_GOLD)"
|
rechargeUsersSource := "likei Mongo in_app_purchase_details SUCCESS distinct acceptUserId(排除 FREIGHT_GOLD,国家码按账单 countryCode 优先、user_run_profile.countryCode 兜底)"
|
||||||
rechargeAmountSource := "likei Mongo in_app_purchase_details amountUsd(排除 FREIGHT_GOLD)"
|
rechargeAmountSource := "likei Mongo in_app_purchase_details amountUsd(排除 FREIGHT_GOLD,国家码按账单 countryCode 优先、user_run_profile.countryCode 兜底)"
|
||||||
mifaPaySource := "likei Mongo in_app_purchase_details factory.factoryCode!=GOOGLE(排除 FREIGHT_GOLD,全部三方渠道合计)"
|
mifaPaySource := "likei Mongo in_app_purchase_details factory.factoryCode!=GOOGLE(排除 FREIGHT_GOLD,全部三方渠道合计)"
|
||||||
newUserRechargeSource := "likei Mongo 当日注册 cohort × in_app_purchase_details(排除 FREIGHT_GOLD)"
|
newUserRechargeSource := "likei Mongo 当日注册 cohort × in_app_purchase_details(排除 FREIGHT_GOLD)"
|
||||||
if !legacyRecharge {
|
if !legacyRecharge {
|
||||||
|
|||||||
146
server/admin/internal/modules/financeorder/dto.go
Normal file
146
server/admin/internal/modules/financeorder/dto.go
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
package financeorder
|
||||||
|
|
||||||
|
import "hyapp-admin-server/internal/model"
|
||||||
|
|
||||||
|
type createCoinSellerRechargeOrderRequest struct {
|
||||||
|
AppCode string `json:"appCode"`
|
||||||
|
TargetUserID string `json:"targetUserId"`
|
||||||
|
USDAmount float64 `json:"usdAmount"`
|
||||||
|
CoinAmount int64 `json:"coinAmount"`
|
||||||
|
ProviderCode string `json:"providerCode"`
|
||||||
|
Chain string `json:"chain"`
|
||||||
|
ExternalOrderNo string `json:"externalOrderNo"`
|
||||||
|
ProviderCountryCode string `json:"providerCountryCode"`
|
||||||
|
ProviderCurrencyCode string `json:"providerCurrencyCode"`
|
||||||
|
ProviderAmountMinor int64 `json:"providerAmountMinor"`
|
||||||
|
ProviderPayType string `json:"providerPayType"`
|
||||||
|
OrderDate string `json:"orderDate"`
|
||||||
|
OrderDateMS int64 `json:"orderDateMs"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type verifyCoinSellerRechargeReceiptRequest struct {
|
||||||
|
AppCode string `json:"appCode"`
|
||||||
|
USDAmount float64 `json:"usdAmount"`
|
||||||
|
USDMinorAmount int64 `json:"usdMinorAmount"`
|
||||||
|
ProviderCode string `json:"providerCode"`
|
||||||
|
Chain string `json:"chain"`
|
||||||
|
ExternalOrderNo string `json:"externalOrderNo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type coinSellerRechargeReceiptVerificationDTO struct {
|
||||||
|
Verified bool `json:"verified"`
|
||||||
|
ProviderCode string `json:"providerCode"`
|
||||||
|
ExternalOrderNo string `json:"externalOrderNo"`
|
||||||
|
ProviderOrderID string `json:"providerOrderId"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CurrencyCode string `json:"currencyCode"`
|
||||||
|
ProviderAmountMinor int64 `json:"providerAmountMinor"`
|
||||||
|
Chain string `json:"chain"`
|
||||||
|
ReceiveAddress string `json:"receiveAddress"`
|
||||||
|
VerifiedAtMS int64 `json:"verifiedAtMs"`
|
||||||
|
FailureReason string `json:"failureReason"`
|
||||||
|
RawJSON string `json:"rawJson"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type coinSellerRechargeOrderDTO struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
AppCode string `json:"appCode"`
|
||||||
|
TargetUserID string `json:"targetUserId"`
|
||||||
|
TargetDisplayUserID string `json:"targetDisplayUserId"`
|
||||||
|
TargetCountryID int64 `json:"targetCountryId"`
|
||||||
|
TargetRegionID int64 `json:"targetRegionId"`
|
||||||
|
USDAmount string `json:"usdAmount"`
|
||||||
|
USDMinorAmount int64 `json:"usdMinorAmount"`
|
||||||
|
CoinAmount int64 `json:"coinAmount"`
|
||||||
|
|
||||||
|
ProviderCode string `json:"providerCode"`
|
||||||
|
Chain string `json:"chain"`
|
||||||
|
ExternalOrderNo string `json:"externalOrderNo"`
|
||||||
|
ProviderOrderID string `json:"providerOrderId"`
|
||||||
|
ProviderStatus string `json:"providerStatus"`
|
||||||
|
ProviderCountryCode string `json:"providerCountryCode"`
|
||||||
|
ProviderCurrencyCode string `json:"providerCurrencyCode"`
|
||||||
|
ProviderAmountMinor int64 `json:"providerAmountMinor"`
|
||||||
|
ProviderPayType string `json:"providerPayType"`
|
||||||
|
ReceiveAddress string `json:"receiveAddress"`
|
||||||
|
OrderDateMS int64 `json:"orderDateMs"`
|
||||||
|
|
||||||
|
Status string `json:"status"`
|
||||||
|
VerifyStatus string `json:"verifyStatus"`
|
||||||
|
GrantStatus string `json:"grantStatus"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
FailureReason string `json:"failureReason"`
|
||||||
|
ProviderPayloadJSON string `json:"providerPayloadJson"`
|
||||||
|
|
||||||
|
WalletCommandID string `json:"walletCommandId"`
|
||||||
|
WalletTransactionID string `json:"walletTransactionId"`
|
||||||
|
WalletAssetType string `json:"walletAssetType"`
|
||||||
|
WalletAmountDelta int64 `json:"walletAmountDelta"`
|
||||||
|
WalletBalanceAfter int64 `json:"walletBalanceAfter"`
|
||||||
|
|
||||||
|
OperatorUserID uint `json:"operatorUserId"`
|
||||||
|
OperatorName string `json:"operatorName"`
|
||||||
|
VerifiedByUserID *uint `json:"verifiedByUserId"`
|
||||||
|
VerifiedByName string `json:"verifiedByName"`
|
||||||
|
GrantedByUserID *uint `json:"grantedByUserId"`
|
||||||
|
GrantedByName string `json:"grantedByName"`
|
||||||
|
VerifiedAtMS *int64 `json:"verifiedAtMs"`
|
||||||
|
GrantedAtMS *int64 `json:"grantedAtMs"`
|
||||||
|
CreatedAtMS int64 `json:"createdAtMs"`
|
||||||
|
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func coinSellerRechargeOrderDTOFromModel(order model.CoinSellerRechargeOrder) coinSellerRechargeOrderDTO {
|
||||||
|
return coinSellerRechargeOrderDTO{
|
||||||
|
ID: order.ID,
|
||||||
|
AppCode: order.AppCode,
|
||||||
|
TargetUserID: formatInt64(order.TargetUserID),
|
||||||
|
TargetDisplayUserID: order.TargetDisplayUserID,
|
||||||
|
TargetCountryID: order.TargetCountryID,
|
||||||
|
TargetRegionID: order.TargetRegionID,
|
||||||
|
USDAmount: order.USDAmount,
|
||||||
|
USDMinorAmount: order.USDMinorAmount,
|
||||||
|
CoinAmount: order.CoinAmount,
|
||||||
|
ProviderCode: order.ProviderCode,
|
||||||
|
Chain: order.Chain,
|
||||||
|
ExternalOrderNo: order.ExternalOrderNo,
|
||||||
|
ProviderOrderID: order.ProviderOrderID,
|
||||||
|
ProviderStatus: order.ProviderStatus,
|
||||||
|
ProviderCountryCode: order.ProviderCountryCode,
|
||||||
|
ProviderCurrencyCode: order.ProviderCurrencyCode,
|
||||||
|
ProviderAmountMinor: order.ProviderAmountMinor,
|
||||||
|
ProviderPayType: order.ProviderPayType,
|
||||||
|
ReceiveAddress: order.ReceiveAddress,
|
||||||
|
OrderDateMS: order.OrderDateMS,
|
||||||
|
Status: order.Status,
|
||||||
|
VerifyStatus: order.VerifyStatus,
|
||||||
|
GrantStatus: order.GrantStatus,
|
||||||
|
Remark: order.Remark,
|
||||||
|
FailureReason: order.FailureReason,
|
||||||
|
ProviderPayloadJSON: order.ProviderPayloadJSON,
|
||||||
|
WalletCommandID: order.WalletCommandID,
|
||||||
|
WalletTransactionID: order.WalletTransactionID,
|
||||||
|
WalletAssetType: order.WalletAssetType,
|
||||||
|
WalletAmountDelta: order.WalletAmountDelta,
|
||||||
|
WalletBalanceAfter: order.WalletBalanceAfter,
|
||||||
|
OperatorUserID: order.OperatorUserID,
|
||||||
|
OperatorName: order.OperatorName,
|
||||||
|
VerifiedByUserID: order.VerifiedByUserID,
|
||||||
|
VerifiedByName: order.VerifiedByName,
|
||||||
|
GrantedByUserID: order.GrantedByUserID,
|
||||||
|
GrantedByName: order.GrantedByName,
|
||||||
|
VerifiedAtMS: order.VerifiedAtMS,
|
||||||
|
GrantedAtMS: order.GrantedAtMS,
|
||||||
|
CreatedAtMS: order.CreatedAtMS,
|
||||||
|
UpdatedAtMS: order.UpdatedAtMS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func coinSellerRechargeOrderDTOsFromModel(orders []model.CoinSellerRechargeOrder) []coinSellerRechargeOrderDTO {
|
||||||
|
out := make([]coinSellerRechargeOrderDTO, 0, len(orders))
|
||||||
|
for _, order := range orders {
|
||||||
|
out = append(out, coinSellerRechargeOrderDTOFromModel(order))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
205
server/admin/internal/modules/financeorder/handler.go
Normal file
205
server/admin/internal/modules/financeorder/handler.go
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
package financeorder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hyapp-admin-server/internal/integration/userclient"
|
||||||
|
"hyapp-admin-server/internal/integration/walletclient"
|
||||||
|
"hyapp-admin-server/internal/middleware"
|
||||||
|
"hyapp-admin-server/internal/modules/shared"
|
||||||
|
"hyapp-admin-server/internal/repository"
|
||||||
|
"hyapp-admin-server/internal/response"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
service *Service
|
||||||
|
audit shared.OperationLogger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(store *repository.Store, wallet walletclient.Client, user userclient.Client, audit shared.OperationLogger, options ...Option) *Handler {
|
||||||
|
return &Handler{service: NewService(store, wallet, user, options...), audit: audit}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListCoinSellerRechargeOrders(c *gin.Context) {
|
||||||
|
options := shared.ListOptions(c)
|
||||||
|
items, total, err := h.service.ListOrders(repository.CoinSellerRechargeOrderListOptions{
|
||||||
|
Page: options.Page,
|
||||||
|
PageSize: options.PageSize,
|
||||||
|
AppCode: firstQuery(c, "app_code", "appCode"),
|
||||||
|
Status: options.Status,
|
||||||
|
VerifyStatus: firstQuery(c, "verify_status", "verifyStatus"),
|
||||||
|
GrantStatus: firstQuery(c, "grant_status", "grantStatus"),
|
||||||
|
ProviderCode: firstQuery(c, "provider_code", "providerCode"),
|
||||||
|
Keyword: options.Keyword,
|
||||||
|
TargetUserID: parseInt64Query(c, "target_user_id", "targetUserId"),
|
||||||
|
OperatorID: uint(parseInt64Query(c, "operator_id", "operatorId")),
|
||||||
|
CreatedFromMS: parseInt64Query(c, "created_from_ms", "createdFromMs", "start_ms", "startMs"),
|
||||||
|
CreatedToMS: parseInt64Query(c, "created_to_ms", "createdToMs", "end_ms", "endMs"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(c, err, "获取币商充值订单失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetCoinSellerRechargeOrder(c *gin.Context) {
|
||||||
|
id, ok := shared.ParseID(c, "order_id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := h.service.GetOrder(id)
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(c, err, "获取币商充值订单失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreateCoinSellerRechargeOrder(c *gin.Context) {
|
||||||
|
var req createCoinSellerRechargeOrderRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "币商充值订单参数不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := h.service.CreateOrder(c.Request.Context(), shared.ActorFromContext(c), req, middleware.CurrentRequestID(c))
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(c, err, "创建币商充值订单失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shared.OperationLogWithResourceID(c, h.audit, "create-coin-seller-recharge-order", "admin_coin_seller_recharge_orders", idString(item.ID), "success", item.ProviderCode+":"+item.ExternalOrderNo)
|
||||||
|
response.Created(c, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) VerifyCoinSellerRechargeReceipt(c *gin.Context) {
|
||||||
|
var req verifyCoinSellerRechargeReceiptRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, "币商充值凭证参数不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := h.service.VerifyReceipt(c.Request.Context(), shared.ActorFromContext(c), req, middleware.CurrentRequestID(c))
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(c, err, "校验币商充值凭证失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shared.OperationLogWithResourceID(c, h.audit, "verify-coin-seller-recharge-receipt", "admin_coin_seller_recharge_orders", item.ExternalOrderNo, "success", item.ProviderCode+":"+item.Status)
|
||||||
|
response.OK(c, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) VerifyCoinSellerRechargeOrder(c *gin.Context) {
|
||||||
|
id, ok := shared.ParseID(c, "order_id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := h.service.VerifyOrder(c.Request.Context(), shared.ActorFromContext(c), id, middleware.CurrentRequestID(c))
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(c, err, "校验币商充值订单失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shared.OperationLogWithResourceID(c, h.audit, "verify-coin-seller-recharge-order", "admin_coin_seller_recharge_orders", idString(item.ID), "success", item.VerifyStatus)
|
||||||
|
response.OK(c, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GrantCoinSellerRechargeOrder(c *gin.Context) {
|
||||||
|
id, ok := shared.ParseID(c, "order_id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := h.service.GrantOrder(c.Request.Context(), shared.ActorFromContext(c), id, middleware.CurrentRequestID(c))
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(c, err, "发放币商充值金币失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shared.OperationLogWithResourceID(c, h.audit, "grant-coin-seller-recharge-order", "admin_coin_seller_recharge_orders", idString(item.ID), "success", item.WalletTransactionID)
|
||||||
|
response.OK(c, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeServiceError(c *gin.Context, err error, fallback string) {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
response.NotFound(c, "币商充值订单不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, errCoinSellerRechargeForbidden) {
|
||||||
|
response.Forbidden(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, repository.ErrCoinSellerRechargeOrderNotVerified) {
|
||||||
|
response.BadRequest(c, "订单尚未校验通过")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, repository.ErrCoinSellerRechargeOrderGranting) {
|
||||||
|
response.BadRequest(c, "订单正在发放中")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, repository.ErrCoinSellerRechargeOrderGranted) {
|
||||||
|
response.BadRequest(c, "订单已发放")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, errInvalidCoinSellerRechargeOrderInput) {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, errLegacyCoinSellerNotFound) {
|
||||||
|
response.BadRequest(c, "目标用户不是启用中的币商/货运代理")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, errLegacyCoinSellerOrderDuplicate) {
|
||||||
|
response.BadRequest(c, "legacy 账套中订单号已入账")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isDuplicateKeyError(err) {
|
||||||
|
response.BadRequest(c, "订单号已绑定,不能重复使用")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if status.Code(err) == codes.NotFound {
|
||||||
|
response.NotFound(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if st, ok := status.FromError(err); ok && strings.TrimSpace(st.Message()) != "" {
|
||||||
|
switch st.Code() {
|
||||||
|
case codes.PermissionDenied:
|
||||||
|
response.Forbidden(c, st.Message())
|
||||||
|
return
|
||||||
|
case codes.InvalidArgument, codes.FailedPrecondition, codes.AlreadyExists, codes.Aborted, codes.ResourceExhausted:
|
||||||
|
response.BadRequest(c, st.Message())
|
||||||
|
return
|
||||||
|
case codes.Unavailable:
|
||||||
|
response.ServerError(c, fallback)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "not configured") {
|
||||||
|
response.ServerError(c, fallback)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstQuery(c *gin.Context, keys ...string) string {
|
||||||
|
for _, key := range keys {
|
||||||
|
if value := strings.TrimSpace(c.Query(key)); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInt64Query(c *gin.Context, keys ...string) int64 {
|
||||||
|
value := firstQuery(c, keys...)
|
||||||
|
if value == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
parsed, _ := strconv.ParseInt(value, 10, 64)
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func idString(id uint) string {
|
||||||
|
return strconv.FormatUint(uint64(id), 10)
|
||||||
|
}
|
||||||
275
server/admin/internal/modules/financeorder/legacy_writer.go
Normal file
275
server/admin/internal/modules/financeorder/legacy_writer.go
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
package financeorder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hyapp-admin-server/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errLegacyCoinSellerNotFound = errors.New("legacy coin seller not found")
|
||||||
|
errLegacyCoinSellerOrderDuplicate = errors.New("legacy coin seller recharge order already exists")
|
||||||
|
)
|
||||||
|
|
||||||
|
type coinSellerTarget struct {
|
||||||
|
UserID int64
|
||||||
|
DisplayUserID string
|
||||||
|
CountryID int64
|
||||||
|
RegionID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type legacyRechargeGrantInput struct {
|
||||||
|
OrderID uint
|
||||||
|
UserID int64
|
||||||
|
ExternalOrderNo string
|
||||||
|
ProviderCode string
|
||||||
|
Chain string
|
||||||
|
USDAmount string
|
||||||
|
CoinAmount int64
|
||||||
|
OperatorUserID uint
|
||||||
|
GrantedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type legacyRechargeGrantResult struct {
|
||||||
|
TransactionID string
|
||||||
|
BalanceAfter int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// LegacyCoinSellerRechargeWriter 把 admin 订单发放到 Aslan/Yumi 独立 legacy 钱包账套;
|
||||||
|
// admin 表只保存审计和绑定事实,具体余额/流水仍按旧系统表结构写入。
|
||||||
|
type LegacyCoinSellerRechargeWriter interface {
|
||||||
|
AppCode() string
|
||||||
|
Close() error
|
||||||
|
ResolveCoinSeller(ctx context.Context, keyword string) (coinSellerTarget, error)
|
||||||
|
GrantCoinSellerRecharge(ctx context.Context, input legacyRechargeGrantInput) (legacyRechargeGrantResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type mysqlLegacyCoinSellerRechargeWriter struct {
|
||||||
|
appCode string
|
||||||
|
sysOrigin string
|
||||||
|
appDB *sql.DB
|
||||||
|
walletDB *sql.DB
|
||||||
|
requestTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMySQLLegacyCoinSellerRechargeWriter(source config.LegacyCoinSellerRechargeSourceConfig, appDB *sql.DB, walletDB *sql.DB) LegacyCoinSellerRechargeWriter {
|
||||||
|
timeout := source.RequestTimeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 5 * time.Second
|
||||||
|
}
|
||||||
|
return &mysqlLegacyCoinSellerRechargeWriter{
|
||||||
|
appCode: strings.ToLower(strings.TrimSpace(source.AppCode)),
|
||||||
|
sysOrigin: strings.ToUpper(strings.TrimSpace(source.SysOrigin)),
|
||||||
|
appDB: appDB,
|
||||||
|
walletDB: walletDB,
|
||||||
|
requestTimeout: timeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *mysqlLegacyCoinSellerRechargeWriter) AppCode() string {
|
||||||
|
if w == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return w.appCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *mysqlLegacyCoinSellerRechargeWriter) Close() error {
|
||||||
|
if w == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var first error
|
||||||
|
if w.appDB != nil {
|
||||||
|
first = w.appDB.Close()
|
||||||
|
}
|
||||||
|
if w.walletDB != nil {
|
||||||
|
if err := w.walletDB.Close(); first == nil {
|
||||||
|
first = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return first
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *mysqlLegacyCoinSellerRechargeWriter) ResolveCoinSeller(ctx context.Context, keyword string) (coinSellerTarget, error) {
|
||||||
|
if w == nil || w.appDB == nil || w.walletDB == nil {
|
||||||
|
return coinSellerTarget{}, errors.New("legacy coin seller recharge writer is not configured")
|
||||||
|
}
|
||||||
|
user, err := w.resolveLegacyUser(ctx, keyword)
|
||||||
|
if err != nil {
|
||||||
|
return coinSellerTarget{}, err
|
||||||
|
}
|
||||||
|
if err := w.requireLegacyCoinSeller(ctx, user.UserID); err != nil {
|
||||||
|
return coinSellerTarget{}, err
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *mysqlLegacyCoinSellerRechargeWriter) resolveLegacyUser(ctx context.Context, keyword string) (coinSellerTarget, error) {
|
||||||
|
keyword = strings.TrimSpace(keyword)
|
||||||
|
if keyword == "" {
|
||||||
|
return coinSellerTarget{}, errLegacyCoinSellerNotFound
|
||||||
|
}
|
||||||
|
queryCtx, cancel := context.WithTimeout(ctx, w.requestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
args := []any{w.sysOrigin, keyword, keyword}
|
||||||
|
matchSQL := "(account = ? OR CAST(id AS CHAR) = ?"
|
||||||
|
if numeric, err := strconv.ParseInt(keyword, 10, 64); err == nil && numeric > 0 {
|
||||||
|
matchSQL += " OR id = ?"
|
||||||
|
args = append(args, numeric)
|
||||||
|
}
|
||||||
|
matchSQL += ")"
|
||||||
|
row := w.appDB.QueryRowContext(queryCtx, `
|
||||||
|
SELECT id, account, COALESCE(country_id, 0)
|
||||||
|
FROM user_base_info
|
||||||
|
WHERE origin_sys = ? AND `+matchSQL+` AND COALESCE(is_del, 0) = 0
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1`, args...)
|
||||||
|
var target coinSellerTarget
|
||||||
|
if err := row.Scan(&target.UserID, &target.DisplayUserID, &target.CountryID); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return coinSellerTarget{}, errLegacyCoinSellerNotFound
|
||||||
|
}
|
||||||
|
return coinSellerTarget{}, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(target.DisplayUserID) == "" {
|
||||||
|
target.DisplayUserID = formatInt64(target.UserID)
|
||||||
|
}
|
||||||
|
return target, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *mysqlLegacyCoinSellerRechargeWriter) requireLegacyCoinSeller(ctx context.Context, userID int64) error {
|
||||||
|
queryCtx, cancel := context.WithTimeout(ctx, w.requestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
row := w.walletDB.QueryRowContext(queryCtx, `
|
||||||
|
SELECT id
|
||||||
|
FROM user_freight_balance
|
||||||
|
WHERE sys_origin = ? AND user_id = ? AND COALESCE(is_close, 0) = 0 AND (COALESCE(dealer, 0) = 1 OR COALESCE(super_dealer, 0) = 1)
|
||||||
|
LIMIT 1`, w.sysOrigin, userID)
|
||||||
|
var id int64
|
||||||
|
if err := row.Scan(&id); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return errLegacyCoinSellerNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *mysqlLegacyCoinSellerRechargeWriter) GrantCoinSellerRecharge(ctx context.Context, input legacyRechargeGrantInput) (legacyRechargeGrantResult, error) {
|
||||||
|
if w == nil || w.walletDB == nil {
|
||||||
|
return legacyRechargeGrantResult{}, errors.New("legacy coin seller recharge writer is not configured")
|
||||||
|
}
|
||||||
|
queryCtx, cancel := context.WithTimeout(ctx, w.requestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
tx, err := w.walletDB.BeginTx(queryCtx, &sql.TxOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return legacyRechargeGrantResult{}, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
}()
|
||||||
|
var (
|
||||||
|
balanceID int64
|
||||||
|
earnPointsText string
|
||||||
|
consumptionText string
|
||||||
|
)
|
||||||
|
if err := tx.QueryRowContext(queryCtx, `
|
||||||
|
SELECT id, CAST(COALESCE(earn_points, 0) AS CHAR), CAST(COALESCE(consumption_points, 0) AS CHAR)
|
||||||
|
FROM user_freight_balance
|
||||||
|
WHERE sys_origin = ? AND user_id = ? AND COALESCE(is_close, 0) = 0 AND (COALESCE(dealer, 0) = 1 OR COALESCE(super_dealer, 0) = 1)
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1
|
||||||
|
FOR UPDATE`, w.sysOrigin, input.UserID).Scan(&balanceID, &earnPointsText, &consumptionText); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return legacyRechargeGrantResult{}, errLegacyCoinSellerNotFound
|
||||||
|
}
|
||||||
|
return legacyRechargeGrantResult{}, err
|
||||||
|
}
|
||||||
|
var duplicateID int64
|
||||||
|
if err := tx.QueryRowContext(queryCtx, `
|
||||||
|
SELECT id
|
||||||
|
FROM user_freight_balance_running_water
|
||||||
|
WHERE sys_origin = ? AND remark = ?
|
||||||
|
LIMIT 1`, w.sysOrigin, input.ExternalOrderNo).Scan(&duplicateID); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return legacyRechargeGrantResult{}, err
|
||||||
|
} else if duplicateID > 0 {
|
||||||
|
return legacyRechargeGrantResult{}, errLegacyCoinSellerOrderDuplicate
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(queryCtx, `
|
||||||
|
UPDATE user_freight_balance
|
||||||
|
SET earn_points = COALESCE(earn_points, 0) + ?, update_user = ?, update_time = ?
|
||||||
|
WHERE id = ?`, input.CoinAmount, input.UserID, input.GrantedAt, balanceID); err != nil {
|
||||||
|
return legacyRechargeGrantResult{}, err
|
||||||
|
}
|
||||||
|
balanceAfter, err := decimalBalanceAfter(earnPointsText, consumptionText, input.CoinAmount)
|
||||||
|
if err != nil {
|
||||||
|
return legacyRechargeGrantResult{}, err
|
||||||
|
}
|
||||||
|
waterID := legacySnowflakeLikeID(input.GrantedAt)
|
||||||
|
rechargeType := legacyRechargeType(input.ProviderCode, input.Chain)
|
||||||
|
if _, err := tx.ExecContext(queryCtx, `
|
||||||
|
INSERT INTO user_freight_balance_running_water
|
||||||
|
(id, sys_origin, user_id, accept_user_id, seller_id, type, quantity, usd_quantity, balance, amount, recharge_type, origin, remark, create_user, update_user, create_time, update_time)
|
||||||
|
VALUES
|
||||||
|
(?, ?, ?, ?, ?, 0, ?, 0, ?, ?, ?, 'PURCHASE', ?, ?, ?, ?, ?)`,
|
||||||
|
waterID,
|
||||||
|
w.sysOrigin,
|
||||||
|
input.UserID,
|
||||||
|
input.UserID,
|
||||||
|
nil,
|
||||||
|
input.CoinAmount,
|
||||||
|
balanceAfter,
|
||||||
|
input.USDAmount,
|
||||||
|
rechargeType,
|
||||||
|
input.ExternalOrderNo,
|
||||||
|
input.UserID,
|
||||||
|
input.UserID,
|
||||||
|
input.GrantedAt,
|
||||||
|
input.GrantedAt,
|
||||||
|
); err != nil {
|
||||||
|
return legacyRechargeGrantResult{}, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return legacyRechargeGrantResult{}, err
|
||||||
|
}
|
||||||
|
return legacyRechargeGrantResult{
|
||||||
|
TransactionID: fmt.Sprintf("legacy-freight-purchase-%d", waterID),
|
||||||
|
BalanceAfter: balanceAfter,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decimalBalanceAfter(earnPointsText string, consumptionText string, delta int64) (int64, error) {
|
||||||
|
earn, err := strconv.ParseFloat(strings.TrimSpace(earnPointsText), 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
consumption, err := strconv.ParseFloat(strings.TrimSpace(consumptionText), 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return int64(earn) + delta - int64(consumption), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func legacyRechargeType(providerCode string, chain string) string {
|
||||||
|
providerCode = strings.ToUpper(strings.TrimSpace(providerCode))
|
||||||
|
if providerCode == "USDT" {
|
||||||
|
if chain = strings.ToUpper(strings.TrimSpace(chain)); chain != "" {
|
||||||
|
return "USDT-" + chain + "-进货"
|
||||||
|
}
|
||||||
|
return "USDT-进货"
|
||||||
|
}
|
||||||
|
return providerCode + "-进货"
|
||||||
|
}
|
||||||
|
|
||||||
|
func legacySnowflakeLikeID(now time.Time) int64 {
|
||||||
|
// 旧服务用 MyBatis ASSIGN_ID 写 bigint 主键;后台没有接入旧雪花 worker,只需保证单进程内足够唯一且单调。
|
||||||
|
return now.UTC().UnixMilli()*1000 + int64(legacyWaterIDCounter.Add(1)%1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
var legacyWaterIDCounter atomic.Uint32
|
||||||
19
server/admin/internal/modules/financeorder/routes.go
Normal file
19
server/admin/internal/modules/financeorder/routes.go
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
package financeorder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"hyapp-admin-server/internal/middleware"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||||
|
if h == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
protected.GET("/admin/finance/orders/coin-seller-recharges", middleware.RequirePermission(permissionCoinSellerRechargeView), h.ListCoinSellerRechargeOrders)
|
||||||
|
protected.GET("/admin/finance/orders/coin-seller-recharges/:order_id", middleware.RequirePermission(permissionCoinSellerRechargeView), h.GetCoinSellerRechargeOrder)
|
||||||
|
protected.POST("/admin/finance/orders/coin-seller-recharges/receipt-verifications", middleware.RequirePermission(permissionCoinSellerRechargeVerify), h.VerifyCoinSellerRechargeReceipt)
|
||||||
|
protected.POST("/admin/finance/orders/coin-seller-recharges", middleware.RequirePermission(permissionCoinSellerRechargeCreate), h.CreateCoinSellerRechargeOrder)
|
||||||
|
protected.POST("/admin/finance/orders/coin-seller-recharges/:order_id/verify", middleware.RequirePermission(permissionCoinSellerRechargeVerify), h.VerifyCoinSellerRechargeOrder)
|
||||||
|
protected.POST("/admin/finance/orders/coin-seller-recharges/:order_id/grant", middleware.RequirePermission(permissionCoinSellerRechargeGrant), h.GrantCoinSellerRechargeOrder)
|
||||||
|
}
|
||||||
910
server/admin/internal/modules/financeorder/service.go
Normal file
910
server/admin/internal/modules/financeorder/service.go
Normal file
@ -0,0 +1,910 @@
|
|||||||
|
package financeorder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"hyapp-admin-server/internal/appctx"
|
||||||
|
"hyapp-admin-server/internal/integration/userclient"
|
||||||
|
"hyapp-admin-server/internal/integration/walletclient"
|
||||||
|
"hyapp-admin-server/internal/model"
|
||||||
|
"hyapp-admin-server/internal/modules/shared"
|
||||||
|
"hyapp-admin-server/internal/repository"
|
||||||
|
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||||
|
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
permissionCoinSellerRechargeView = "finance-order:coin-seller-recharge:view"
|
||||||
|
permissionCoinSellerRechargeCreate = "finance-order:coin-seller-recharge:create"
|
||||||
|
permissionCoinSellerRechargeVerify = "finance-order:coin-seller-recharge:verify"
|
||||||
|
permissionCoinSellerRechargeGrant = "finance-order:coin-seller-recharge:grant"
|
||||||
|
|
||||||
|
coinSellerRechargeAssetType = "COIN_SELLER_COIN"
|
||||||
|
coinSellerRechargeStockType = "usdt_purchase"
|
||||||
|
|
||||||
|
financeOrderInternalUserIDMinDigits = 15
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errInvalidCoinSellerRechargeOrderInput = errors.New("币商充值订单参数不正确")
|
||||||
|
errCoinSellerRechargeForbidden = errors.New("没有操作权限")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
store *repository.Store
|
||||||
|
wallet walletclient.Client
|
||||||
|
user userclient.Client
|
||||||
|
now func() time.Time
|
||||||
|
legacyWriters map[string]LegacyCoinSellerRechargeWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
type Option func(*Service)
|
||||||
|
|
||||||
|
func WithLegacyCoinSellerRechargeWriters(writers ...LegacyCoinSellerRechargeWriter) Option {
|
||||||
|
return func(service *Service) {
|
||||||
|
if service.legacyWriters == nil {
|
||||||
|
service.legacyWriters = map[string]LegacyCoinSellerRechargeWriter{}
|
||||||
|
}
|
||||||
|
for _, writer := range writers {
|
||||||
|
if writer == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
appCode := appctx.Normalize(writer.AppCode())
|
||||||
|
if appCode != "" {
|
||||||
|
service.legacyWriters[appCode] = writer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(store *repository.Store, wallet walletclient.Client, user userclient.Client, options ...Option) *Service {
|
||||||
|
service := &Service{
|
||||||
|
store: store,
|
||||||
|
wallet: wallet,
|
||||||
|
user: user,
|
||||||
|
now: func() time.Time { return time.Now().UTC() },
|
||||||
|
legacyWriters: map[string]LegacyCoinSellerRechargeWriter{},
|
||||||
|
}
|
||||||
|
for _, option := range options {
|
||||||
|
if option != nil {
|
||||||
|
option(service)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) CreateOrder(ctx context.Context, actor shared.Actor, req createCoinSellerRechargeOrderRequest, requestID string) (*coinSellerRechargeOrderDTO, error) {
|
||||||
|
if s == nil || s.store == nil {
|
||||||
|
return nil, errors.New("admin store is not configured")
|
||||||
|
}
|
||||||
|
if actor.UserID == 0 || !hasPermission(actor.Permissions, permissionCoinSellerRechargeCreate) {
|
||||||
|
return nil, errCoinSellerRechargeForbidden
|
||||||
|
}
|
||||||
|
input, err := normalizeCreateOrderRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// 前端“校验通过”只能改善操作体验,不能作为资金事实;创建时必须复用同一套服务端校验,避免篡改请求直接落库。
|
||||||
|
receipt, err := s.verifyReceiptInput(ctx, input.receiptVerificationInput(), strings.TrimSpace(requestID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !receipt.Verified {
|
||||||
|
return nil, errors.New(firstNonEmpty(receipt.FailureReason, "充值凭证未支付成功"))
|
||||||
|
}
|
||||||
|
if err := verifyReceiptMatchesInput(input, receipt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// 钱包入账对象必须在订单占用前确认身份;Lalu 查 user-service,Aslan/Yumi 走 legacy writer,避免跨账套写错目标。
|
||||||
|
target, err := s.resolveCoinSellerTarget(ctx, requestID, input.AppCode, input.TargetUserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
nowMS := s.now().UnixMilli()
|
||||||
|
verifiedAtMS := receipt.VerifiedAtMS
|
||||||
|
if verifiedAtMS <= 0 {
|
||||||
|
verifiedAtMS = nowMS
|
||||||
|
}
|
||||||
|
order := &model.CoinSellerRechargeOrder{
|
||||||
|
AppCode: input.AppCode,
|
||||||
|
TargetUserID: target.UserID,
|
||||||
|
TargetDisplayUserID: firstNonEmpty(target.DisplayUserID, formatInt64(target.UserID)),
|
||||||
|
TargetCountryID: target.CountryID,
|
||||||
|
TargetRegionID: target.RegionID,
|
||||||
|
USDAmount: input.USDAmount,
|
||||||
|
USDMinorAmount: input.USDMinorAmount,
|
||||||
|
CoinAmount: input.CoinAmount,
|
||||||
|
ProviderCode: input.ProviderCode,
|
||||||
|
Chain: input.Chain,
|
||||||
|
ExternalOrderNo: input.ExternalOrderNo,
|
||||||
|
ProviderOrderID: receipt.ProviderOrderID,
|
||||||
|
ProviderStatus: receipt.Status,
|
||||||
|
ProviderCountryCode: input.ProviderCountryCode,
|
||||||
|
ProviderCurrencyCode: firstNonEmpty(receipt.CurrencyCode, input.ProviderCurrencyCode),
|
||||||
|
ProviderAmountMinor: receipt.ProviderAmountMinor,
|
||||||
|
ProviderPayType: input.ProviderPayType,
|
||||||
|
ReceiveAddress: receipt.ReceiveAddress,
|
||||||
|
OrderDateMS: input.OrderDateMS,
|
||||||
|
// 新建订单已经通过外部凭证校验,因此进入待发放状态;历史 pending 订单仍可走列表行校验按钮重试。
|
||||||
|
Status: model.CoinSellerRechargeOrderStatusVerified,
|
||||||
|
VerifyStatus: model.CoinSellerRechargeVerifyStatusVerified,
|
||||||
|
GrantStatus: model.CoinSellerRechargeGrantStatusPending,
|
||||||
|
Remark: input.Remark,
|
||||||
|
ProviderPayloadJSON: receipt.RawJSON,
|
||||||
|
OperatorUserID: actor.UserID,
|
||||||
|
OperatorName: actor.Username,
|
||||||
|
VerifiedByUserID: &actor.UserID,
|
||||||
|
VerifiedByName: actor.Username,
|
||||||
|
VerifiedAtMS: &verifiedAtMS,
|
||||||
|
CreatedAtMS: nowMS,
|
||||||
|
UpdatedAtMS: nowMS,
|
||||||
|
}
|
||||||
|
// admin_coin_seller_recharge_orders 是所有 APP 的订单/币商绑定台账;即使 Aslan/Yumi 最终写 legacy 钱包,也必须先在这里占用外部订单号。
|
||||||
|
if err := s.store.CreateCoinSellerRechargeOrder(order); err != nil {
|
||||||
|
if isDuplicateKeyError(err) {
|
||||||
|
return nil, errors.New("订单号已绑定,不能重复创建")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
item, err := s.store.GetCoinSellerRechargeOrder(order.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dto := coinSellerRechargeOrderDTOFromModel(*item)
|
||||||
|
return &dto, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) VerifyReceipt(ctx context.Context, actor shared.Actor, req verifyCoinSellerRechargeReceiptRequest, requestID string) (*coinSellerRechargeReceiptVerificationDTO, error) {
|
||||||
|
if actor.UserID == 0 || !hasPermission(actor.Permissions, permissionCoinSellerRechargeVerify) {
|
||||||
|
return nil, errCoinSellerRechargeForbidden
|
||||||
|
}
|
||||||
|
// 该接口只返回三方/链上凭证快照,不创建订单、不占用外部订单号;真正绑定发生在 CreateOrder 的事务路径。
|
||||||
|
input, err := normalizeReceiptVerificationRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
receipt, err := s.verifyReceiptInput(ctx, input, strings.TrimSpace(requestID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &receipt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListOrders(options repository.CoinSellerRechargeOrderListOptions) ([]coinSellerRechargeOrderDTO, int64, error) {
|
||||||
|
if s == nil || s.store == nil {
|
||||||
|
return nil, 0, errors.New("admin store is not configured")
|
||||||
|
}
|
||||||
|
if appCode := strings.TrimSpace(options.AppCode); appCode != "" && appCode != "all" {
|
||||||
|
options.AppCode = appctx.Normalize(appCode)
|
||||||
|
} else {
|
||||||
|
options.AppCode = ""
|
||||||
|
}
|
||||||
|
items, total, err := s.store.ListCoinSellerRechargeOrders(options)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return coinSellerRechargeOrderDTOsFromModel(items), total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetOrder(id uint) (*coinSellerRechargeOrderDTO, error) {
|
||||||
|
if s == nil || s.store == nil {
|
||||||
|
return nil, errors.New("admin store is not configured")
|
||||||
|
}
|
||||||
|
item, err := s.store.GetCoinSellerRechargeOrder(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dto := coinSellerRechargeOrderDTOFromModel(*item)
|
||||||
|
return &dto, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) VerifyOrder(ctx context.Context, actor shared.Actor, id uint, requestID string) (*coinSellerRechargeOrderDTO, error) {
|
||||||
|
if s == nil || s.store == nil {
|
||||||
|
return nil, errors.New("admin store is not configured")
|
||||||
|
}
|
||||||
|
if actor.UserID == 0 || !hasPermission(actor.Permissions, permissionCoinSellerRechargeVerify) {
|
||||||
|
return nil, errCoinSellerRechargeForbidden
|
||||||
|
}
|
||||||
|
if s.wallet == nil {
|
||||||
|
return nil, errors.New("finance order wallet verifier is not configured")
|
||||||
|
}
|
||||||
|
order, err := s.store.GetCoinSellerRechargeOrder(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if order.GrantStatus == model.CoinSellerRechargeGrantStatusGranted {
|
||||||
|
return nil, repository.ErrCoinSellerRechargeOrderGranted
|
||||||
|
}
|
||||||
|
resp, err := s.wallet.VerifyCoinSellerRechargeReceipt(appctx.WithContext(ctx, order.AppCode), &walletv1.VerifyCoinSellerRechargeReceiptRequest{
|
||||||
|
RequestId: strings.TrimSpace(requestID),
|
||||||
|
AppCode: order.AppCode,
|
||||||
|
ProviderCode: receiptProviderCode(*order),
|
||||||
|
ExternalOrderNo: order.ExternalOrderNo,
|
||||||
|
Chain: receiptChain(*order),
|
||||||
|
UsdMinorAmount: order.USDMinorAmount,
|
||||||
|
OrderDateMs: order.OrderDateMS,
|
||||||
|
ProviderCountryCode: order.ProviderCountryCode,
|
||||||
|
ProviderCurrencyCode: order.ProviderCurrencyCode,
|
||||||
|
ProviderPayType: order.ProviderPayType,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp == nil {
|
||||||
|
return nil, errors.New("充值凭证校验返回为空")
|
||||||
|
}
|
||||||
|
failureReason := strings.TrimSpace(resp.GetFailureReason())
|
||||||
|
if resp.GetVerified() {
|
||||||
|
if matchErr := verifyReceiptMatchesOrder(*order, resp); matchErr != nil {
|
||||||
|
failureReason = matchErr.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
verifyInput := coinSellerRechargeVerifyInput(actor, resp, failureReason, s.now().UnixMilli())
|
||||||
|
var updated *model.CoinSellerRechargeOrder
|
||||||
|
if resp.GetVerified() && failureReason == "" {
|
||||||
|
updated, err = s.store.MarkCoinSellerRechargeOrderVerified(id, verifyInput)
|
||||||
|
} else {
|
||||||
|
if failureReason == "" {
|
||||||
|
failureReason = "充值凭证未通过校验"
|
||||||
|
verifyInput.FailureReason = failureReason
|
||||||
|
}
|
||||||
|
updated, err = s.store.MarkCoinSellerRechargeOrderVerifyFailed(id, verifyInput)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dto := coinSellerRechargeOrderDTOFromModel(*updated)
|
||||||
|
return &dto, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GrantOrder(ctx context.Context, actor shared.Actor, id uint, requestID string) (*coinSellerRechargeOrderDTO, error) {
|
||||||
|
if s == nil || s.store == nil {
|
||||||
|
return nil, errors.New("admin store is not configured")
|
||||||
|
}
|
||||||
|
if actor.UserID == 0 || !hasPermission(actor.Permissions, permissionCoinSellerRechargeGrant) {
|
||||||
|
return nil, errCoinSellerRechargeForbidden
|
||||||
|
}
|
||||||
|
nowMS := s.now().UnixMilli()
|
||||||
|
claimed, err := s.store.ClaimCoinSellerRechargeOrderGrant(id, actor.UserID, actor.Username, nowMS)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if claimed.GrantStatus == model.CoinSellerRechargeGrantStatusGranted {
|
||||||
|
dto := coinSellerRechargeOrderDTOFromModel(*claimed)
|
||||||
|
return &dto, nil
|
||||||
|
}
|
||||||
|
execution, err := s.executeGrant(ctx, actor, *claimed, requestID)
|
||||||
|
if err != nil {
|
||||||
|
failed, markErr := s.store.MarkCoinSellerRechargeOrderGrantFailed(id, repository.CoinSellerRechargeOrderGrantInput{
|
||||||
|
GrantedByUserID: actor.UserID,
|
||||||
|
GrantedByName: actor.Username,
|
||||||
|
GrantedAtMS: s.now().UnixMilli(),
|
||||||
|
FailureReason: err.Error(),
|
||||||
|
})
|
||||||
|
if markErr != nil {
|
||||||
|
return nil, markErr
|
||||||
|
}
|
||||||
|
dto := coinSellerRechargeOrderDTOFromModel(*failed)
|
||||||
|
return &dto, err
|
||||||
|
}
|
||||||
|
updated, err := s.store.MarkCoinSellerRechargeOrderGranted(id, repository.CoinSellerRechargeOrderGrantInput{
|
||||||
|
GrantedByUserID: actor.UserID,
|
||||||
|
GrantedByName: actor.Username,
|
||||||
|
GrantedAtMS: s.now().UnixMilli(),
|
||||||
|
WalletCommandID: execution.CommandID,
|
||||||
|
WalletTransactionID: execution.TransactionID,
|
||||||
|
WalletAssetType: execution.AssetType,
|
||||||
|
WalletAmountDelta: execution.AmountDelta,
|
||||||
|
WalletBalanceAfter: execution.BalanceAfter,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dto := coinSellerRechargeOrderDTOFromModel(*updated)
|
||||||
|
return &dto, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type normalizedCreateOrderInput struct {
|
||||||
|
AppCode string
|
||||||
|
TargetUserID string
|
||||||
|
USDAmount string
|
||||||
|
USDMinorAmount int64
|
||||||
|
CoinAmount int64
|
||||||
|
ProviderCode string
|
||||||
|
Chain string
|
||||||
|
ExternalOrderNo string
|
||||||
|
ProviderCountryCode string
|
||||||
|
ProviderCurrencyCode string
|
||||||
|
ProviderAmountMinor int64
|
||||||
|
ProviderPayType string
|
||||||
|
OrderDateMS int64
|
||||||
|
Remark string
|
||||||
|
}
|
||||||
|
|
||||||
|
type normalizedReceiptVerificationInput struct {
|
||||||
|
AppCode string
|
||||||
|
USDMinorAmount int64
|
||||||
|
ProviderCode string
|
||||||
|
Chain string
|
||||||
|
ExternalOrderNo string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (input normalizedCreateOrderInput) receiptVerificationInput() normalizedReceiptVerificationInput {
|
||||||
|
return normalizedReceiptVerificationInput{
|
||||||
|
AppCode: input.AppCode,
|
||||||
|
USDMinorAmount: input.USDMinorAmount,
|
||||||
|
ProviderCode: input.ProviderCode,
|
||||||
|
Chain: input.Chain,
|
||||||
|
ExternalOrderNo: input.ExternalOrderNo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCreateOrderRequest(req createCoinSellerRechargeOrderRequest) (normalizedCreateOrderInput, error) {
|
||||||
|
appCode := appctx.Normalize(req.AppCode)
|
||||||
|
targetUserID := strings.TrimSpace(req.TargetUserID)
|
||||||
|
providerCode, chain, err := normalizeRechargeProvider(req.ProviderCode, req.Chain)
|
||||||
|
if err != nil {
|
||||||
|
return normalizedCreateOrderInput{}, err
|
||||||
|
}
|
||||||
|
usdAmount, usdMinorAmount, err := normalizeUSD(req.USDAmount)
|
||||||
|
if err != nil {
|
||||||
|
return normalizedCreateOrderInput{}, err
|
||||||
|
}
|
||||||
|
externalOrderNo := strings.TrimSpace(req.ExternalOrderNo)
|
||||||
|
if appCode == "" || targetUserID == "" || externalOrderNo == "" || req.CoinAmount < 0 {
|
||||||
|
return normalizedCreateOrderInput{}, errInvalidCoinSellerRechargeOrderInput
|
||||||
|
}
|
||||||
|
if len(externalOrderNo) > 128 {
|
||||||
|
return normalizedCreateOrderInput{}, errors.New("订单号过长")
|
||||||
|
}
|
||||||
|
if req.ProviderAmountMinor < 0 {
|
||||||
|
return normalizedCreateOrderInput{}, errors.New("三方实付金额不正确")
|
||||||
|
}
|
||||||
|
providerCountryCode := strings.ToUpper(strings.TrimSpace(req.ProviderCountryCode))
|
||||||
|
providerCurrencyCode := strings.ToUpper(strings.TrimSpace(req.ProviderCurrencyCode))
|
||||||
|
providerPayType := strings.TrimSpace(req.ProviderPayType)
|
||||||
|
remark := strings.TrimSpace(req.Remark)
|
||||||
|
if utf8.RuneCountInString(remark) > 512 {
|
||||||
|
return normalizedCreateOrderInput{}, errors.New("备注不能超过 512 字")
|
||||||
|
}
|
||||||
|
orderDateMS, err := normalizeOrderDateMS(req.OrderDate, req.OrderDateMS)
|
||||||
|
if err != nil {
|
||||||
|
return normalizedCreateOrderInput{}, err
|
||||||
|
}
|
||||||
|
switch providerCode {
|
||||||
|
case "usdt":
|
||||||
|
if chain == "" {
|
||||||
|
return normalizedCreateOrderInput{}, errors.New("USDT 充值必须选择链")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalizedCreateOrderInput{
|
||||||
|
AppCode: appCode,
|
||||||
|
TargetUserID: targetUserID,
|
||||||
|
USDAmount: usdAmount,
|
||||||
|
USDMinorAmount: usdMinorAmount,
|
||||||
|
CoinAmount: req.CoinAmount,
|
||||||
|
ProviderCode: providerCode,
|
||||||
|
Chain: chain,
|
||||||
|
ExternalOrderNo: externalOrderNo,
|
||||||
|
ProviderCountryCode: providerCountryCode,
|
||||||
|
ProviderCurrencyCode: providerCurrencyCode,
|
||||||
|
ProviderAmountMinor: req.ProviderAmountMinor,
|
||||||
|
ProviderPayType: providerPayType,
|
||||||
|
OrderDateMS: orderDateMS,
|
||||||
|
Remark: remark,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeReceiptVerificationRequest(req verifyCoinSellerRechargeReceiptRequest) (normalizedReceiptVerificationInput, error) {
|
||||||
|
appCode := appctx.Normalize(req.AppCode)
|
||||||
|
providerCode, chain, err := normalizeRechargeProvider(req.ProviderCode, req.Chain)
|
||||||
|
if err != nil {
|
||||||
|
return normalizedReceiptVerificationInput{}, err
|
||||||
|
}
|
||||||
|
externalOrderNo := strings.TrimSpace(req.ExternalOrderNo)
|
||||||
|
if appCode == "" || externalOrderNo == "" {
|
||||||
|
return normalizedReceiptVerificationInput{}, errInvalidCoinSellerRechargeOrderInput
|
||||||
|
}
|
||||||
|
if len(externalOrderNo) > 128 {
|
||||||
|
return normalizedReceiptVerificationInput{}, errors.New("订单号过长")
|
||||||
|
}
|
||||||
|
usdMinorAmount := req.USDMinorAmount
|
||||||
|
if usdMinorAmount <= 0 && req.USDAmount > 0 {
|
||||||
|
_, cents, err := normalizeUSD(req.USDAmount)
|
||||||
|
if err != nil {
|
||||||
|
return normalizedReceiptVerificationInput{}, err
|
||||||
|
}
|
||||||
|
usdMinorAmount = cents
|
||||||
|
}
|
||||||
|
if usdMinorAmount < 0 {
|
||||||
|
return normalizedReceiptVerificationInput{}, errors.New("充值美金数量不正确")
|
||||||
|
}
|
||||||
|
if providerCode == "usdt" {
|
||||||
|
if chain == "" {
|
||||||
|
return normalizedReceiptVerificationInput{}, errors.New("USDT 充值必须选择链")
|
||||||
|
}
|
||||||
|
if usdMinorAmount <= 0 {
|
||||||
|
return normalizedReceiptVerificationInput{}, errors.New("USDT 校验必须填写 USD 金额")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalizedReceiptVerificationInput{
|
||||||
|
AppCode: appCode,
|
||||||
|
USDMinorAmount: usdMinorAmount,
|
||||||
|
ProviderCode: providerCode,
|
||||||
|
Chain: chain,
|
||||||
|
ExternalOrderNo: externalOrderNo,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) verifyReceiptInput(ctx context.Context, input normalizedReceiptVerificationInput, requestID string) (coinSellerRechargeReceiptVerificationDTO, error) {
|
||||||
|
if s == nil || s.wallet == nil {
|
||||||
|
return coinSellerRechargeReceiptVerificationDTO{}, errors.New("finance order wallet verifier is not configured")
|
||||||
|
}
|
||||||
|
// admin-server 不直连三方支付和链节点;wallet-service 统一持有 provider 配置、收款地址和查单适配器。
|
||||||
|
resp, err := s.wallet.VerifyCoinSellerRechargeReceipt(appctx.WithContext(ctx, input.AppCode), &walletv1.VerifyCoinSellerRechargeReceiptRequest{
|
||||||
|
RequestId: strings.TrimSpace(requestID),
|
||||||
|
AppCode: input.AppCode,
|
||||||
|
ProviderCode: receiptVerificationProviderCode(input),
|
||||||
|
ExternalOrderNo: input.ExternalOrderNo,
|
||||||
|
Chain: receiptVerificationChain(input),
|
||||||
|
UsdMinorAmount: input.USDMinorAmount,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return coinSellerRechargeReceiptVerificationDTO{}, err
|
||||||
|
}
|
||||||
|
if resp == nil {
|
||||||
|
return coinSellerRechargeReceiptVerificationDTO{}, errors.New("充值凭证校验返回为空")
|
||||||
|
}
|
||||||
|
return receiptVerificationDTOFromProto(resp), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) resolveCoinSellerTarget(ctx context.Context, requestID string, appCode string, rawTarget string) (coinSellerTarget, error) {
|
||||||
|
if writer := s.legacyWriters[appCode]; writer != nil {
|
||||||
|
return writer.ResolveCoinSeller(ctx, rawTarget)
|
||||||
|
}
|
||||||
|
if requiresLegacyWriter(appCode) {
|
||||||
|
return coinSellerTarget{}, errors.New("legacy coin seller recharge writer is not configured")
|
||||||
|
}
|
||||||
|
if s.user == nil {
|
||||||
|
return coinSellerTarget{}, errors.New("finance order user resolver is not configured")
|
||||||
|
}
|
||||||
|
ctx = appctx.WithContext(ctx, appCode)
|
||||||
|
user, err := s.resolveWalletUser(ctx, strings.TrimSpace(requestID), rawTarget)
|
||||||
|
if err != nil {
|
||||||
|
return coinSellerTarget{}, err
|
||||||
|
}
|
||||||
|
if user.CountryID <= 0 || user.RegionID <= 0 {
|
||||||
|
return coinSellerTarget{}, errors.New("币商国家或区域不完整")
|
||||||
|
}
|
||||||
|
summary, err := s.user.GetUserRoleSummary(ctx, userclient.GetUserRoleSummaryRequest{
|
||||||
|
RequestID: strings.TrimSpace(requestID),
|
||||||
|
Caller: "admin-server",
|
||||||
|
UserID: user.UserID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return coinSellerTarget{}, err
|
||||||
|
}
|
||||||
|
if summary == nil || !summary.IsCoinSeller {
|
||||||
|
return coinSellerTarget{}, errors.New("目标用户不是启用中的币商")
|
||||||
|
}
|
||||||
|
return coinSellerTarget{
|
||||||
|
UserID: user.UserID,
|
||||||
|
DisplayUserID: firstNonEmpty(user.PrettyDisplayUserID, user.DisplayUserID, user.DefaultDisplayUserID, formatInt64(user.UserID)),
|
||||||
|
CountryID: user.CountryID,
|
||||||
|
RegionID: user.RegionID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) resolveWalletUser(ctx context.Context, requestID string, rawTarget string) (*userclient.User, error) {
|
||||||
|
keyword := strings.TrimSpace(rawTarget)
|
||||||
|
if keyword == "" {
|
||||||
|
return nil, errors.New("目标用户ID不正确")
|
||||||
|
}
|
||||||
|
numericID, numericOK := parsePositiveInt64(keyword)
|
||||||
|
userIDChecked := false
|
||||||
|
if numericOK && len(strings.TrimLeft(keyword, "0")) >= financeOrderInternalUserIDMinDigits {
|
||||||
|
userIDChecked = true
|
||||||
|
if user, found, err := s.getWalletUserByID(ctx, requestID, numericID); err != nil || found {
|
||||||
|
return user, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if identity, err := s.user.ResolveDisplayUserID(ctx, userclient.ResolveDisplayUserIDRequest{
|
||||||
|
RequestID: requestID,
|
||||||
|
Caller: "admin-server",
|
||||||
|
DisplayUserID: keyword,
|
||||||
|
}); err != nil {
|
||||||
|
if !isGRPCNotFound(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else if identity != nil && identity.UserID > 0 {
|
||||||
|
user, found, err := s.getWalletUserByID(ctx, requestID, identity.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if numericOK && !userIDChecked {
|
||||||
|
if user, found, err := s.getWalletUserByID(ctx, requestID, numericID); err != nil || found {
|
||||||
|
return user, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errors.New("目标用户不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) getWalletUserByID(ctx context.Context, requestID string, userID int64) (*userclient.User, bool, error) {
|
||||||
|
user, err := s.user.GetUser(ctx, userclient.GetUserRequest{
|
||||||
|
RequestID: requestID,
|
||||||
|
Caller: "admin-server",
|
||||||
|
UserID: userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if isGRPCNotFound(err) {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if user == nil || user.UserID <= 0 {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
return user, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type grantExecutionResult struct {
|
||||||
|
CommandID string
|
||||||
|
TransactionID string
|
||||||
|
AssetType string
|
||||||
|
AmountDelta int64
|
||||||
|
BalanceAfter int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) executeGrant(ctx context.Context, actor shared.Actor, order model.CoinSellerRechargeOrder, requestID string) (grantExecutionResult, error) {
|
||||||
|
commandID := coinSellerRechargeWalletCommandID(order.ID)
|
||||||
|
if order.CoinAmount == 0 {
|
||||||
|
// 金币数为 0 是补单:订单仍然绑定外部充值凭证并计入充值台账,但不能写 Lalu 钱包或 legacy 进货流水。
|
||||||
|
return grantExecutionResult{
|
||||||
|
CommandID: commandID,
|
||||||
|
AssetType: coinSellerRechargeAssetType,
|
||||||
|
AmountDelta: 0,
|
||||||
|
BalanceAfter: 0,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if writer := s.legacyWriters[order.AppCode]; writer != nil {
|
||||||
|
result, err := writer.GrantCoinSellerRecharge(ctx, legacyRechargeGrantInput{
|
||||||
|
OrderID: order.ID,
|
||||||
|
UserID: order.TargetUserID,
|
||||||
|
ExternalOrderNo: order.ExternalOrderNo,
|
||||||
|
ProviderCode: order.ProviderCode,
|
||||||
|
Chain: order.Chain,
|
||||||
|
USDAmount: order.USDAmount,
|
||||||
|
CoinAmount: order.CoinAmount,
|
||||||
|
OperatorUserID: actor.UserID,
|
||||||
|
GrantedAt: s.now(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return grantExecutionResult{}, err
|
||||||
|
}
|
||||||
|
return grantExecutionResult{
|
||||||
|
CommandID: commandID,
|
||||||
|
TransactionID: result.TransactionID,
|
||||||
|
AssetType: coinSellerRechargeAssetType,
|
||||||
|
AmountDelta: order.CoinAmount,
|
||||||
|
BalanceAfter: result.BalanceAfter,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if requiresLegacyWriter(order.AppCode) {
|
||||||
|
return grantExecutionResult{}, errors.New("legacy coin seller recharge writer is not configured")
|
||||||
|
}
|
||||||
|
if s.wallet == nil {
|
||||||
|
return grantExecutionResult{}, errors.New("finance order wallet executor is not configured")
|
||||||
|
}
|
||||||
|
ctx = appctx.WithContext(ctx, order.AppCode)
|
||||||
|
resp, err := s.wallet.AdminCreditCoinSellerStock(ctx, &walletv1.AdminCreditCoinSellerStockRequest{
|
||||||
|
CommandId: commandID,
|
||||||
|
SellerUserId: order.TargetUserID,
|
||||||
|
StockType: coinSellerRechargeStockType,
|
||||||
|
CoinAmount: order.CoinAmount,
|
||||||
|
PaidCurrencyCode: "USDT",
|
||||||
|
PaidAmountMicro: order.USDMinorAmount * 10_000,
|
||||||
|
PaymentRef: coinSellerRechargePaymentRef(order),
|
||||||
|
EvidenceRef: coinSellerRechargeEvidenceRef(order.ID),
|
||||||
|
OperatorUserId: int64(actor.UserID),
|
||||||
|
Reason: coinSellerRechargeReason(order),
|
||||||
|
AppCode: order.AppCode,
|
||||||
|
SellerCountryId: order.TargetCountryID,
|
||||||
|
SellerRegionId: order.TargetRegionID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return grantExecutionResult{}, err
|
||||||
|
}
|
||||||
|
return grantExecutionResult{
|
||||||
|
CommandID: commandID,
|
||||||
|
TransactionID: resp.GetTransactionId(),
|
||||||
|
AssetType: coinSellerRechargeAssetType,
|
||||||
|
AmountDelta: resp.GetCoinAmount(),
|
||||||
|
BalanceAfter: resp.GetBalanceAfter(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func coinSellerRechargeVerifyInput(actor shared.Actor, resp *walletv1.VerifyCoinSellerRechargeReceiptResponse, failureReason string, nowMS int64) repository.CoinSellerRechargeOrderVerifyInput {
|
||||||
|
verifiedAtMS := resp.GetVerifiedAtMs()
|
||||||
|
if verifiedAtMS <= 0 {
|
||||||
|
verifiedAtMS = nowMS
|
||||||
|
}
|
||||||
|
return repository.CoinSellerRechargeOrderVerifyInput{
|
||||||
|
VerifiedByUserID: actor.UserID,
|
||||||
|
VerifiedByName: actor.Username,
|
||||||
|
VerifiedAtMS: verifiedAtMS,
|
||||||
|
ProviderOrderID: resp.GetProviderOrderId(),
|
||||||
|
Status: resp.GetStatus(),
|
||||||
|
CurrencyCode: resp.GetCurrencyCode(),
|
||||||
|
ProviderAmountMinor: resp.GetProviderAmountMinor(),
|
||||||
|
ProviderPayloadJSON: receiptPayloadJSON(resp),
|
||||||
|
ReceiveAddress: resp.GetReceiveAddress(),
|
||||||
|
FailureReason: failureReason,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyReceiptMatchesOrder(order model.CoinSellerRechargeOrder, resp *walletv1.VerifyCoinSellerRechargeReceiptResponse) error {
|
||||||
|
if !strings.EqualFold(resp.GetExternalOrderNo(), order.ExternalOrderNo) {
|
||||||
|
return errors.New("校验返回订单号不匹配")
|
||||||
|
}
|
||||||
|
// USD/USDT 是后台账面金额,必须精确匹配;本地币种三方金额只在旧订单保存过期望值时才参与强校验。
|
||||||
|
currency := strings.ToUpper(strings.TrimSpace(firstNonEmpty(resp.GetCurrencyCode(), order.ProviderCurrencyCode)))
|
||||||
|
if order.ProviderCurrencyCode != "" && currency != "" && !strings.EqualFold(currency, order.ProviderCurrencyCode) {
|
||||||
|
return errors.New("校验返回币种不匹配")
|
||||||
|
}
|
||||||
|
amount := resp.GetProviderAmountMinor()
|
||||||
|
if amount <= 0 {
|
||||||
|
return errors.New("校验返回金额为空")
|
||||||
|
}
|
||||||
|
if currency == "USD" || currency == "USDT" {
|
||||||
|
if amount != order.USDMinorAmount {
|
||||||
|
return errors.New("校验返回金额与订单美元金额不匹配")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if order.ProviderAmountMinor <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if amount != order.ProviderAmountMinor {
|
||||||
|
return errors.New("校验返回金额与三方实付金额不匹配")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyReceiptMatchesInput(input normalizedCreateOrderInput, receipt coinSellerRechargeReceiptVerificationDTO) error {
|
||||||
|
if !strings.EqualFold(receipt.ExternalOrderNo, input.ExternalOrderNo) {
|
||||||
|
return errors.New("校验返回订单号不匹配")
|
||||||
|
}
|
||||||
|
// 新流程不要求运营录入本地币种实付金额;非 USD 金额只作为 provider 快照留痕,不阻断创建。
|
||||||
|
currency := strings.ToUpper(strings.TrimSpace(firstNonEmpty(receipt.CurrencyCode, input.ProviderCurrencyCode)))
|
||||||
|
if input.ProviderCurrencyCode != "" && currency != "" && !strings.EqualFold(currency, input.ProviderCurrencyCode) {
|
||||||
|
return errors.New("校验返回币种不匹配")
|
||||||
|
}
|
||||||
|
if receipt.ProviderAmountMinor <= 0 {
|
||||||
|
return errors.New("校验返回金额为空")
|
||||||
|
}
|
||||||
|
if currency == "USD" || currency == "USDT" {
|
||||||
|
if receipt.ProviderAmountMinor != input.USDMinorAmount {
|
||||||
|
return errors.New("校验返回金额与订单美元金额不匹配")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if input.ProviderAmountMinor > 0 && receipt.ProviderAmountMinor != input.ProviderAmountMinor {
|
||||||
|
return errors.New("校验返回金额与三方实付金额不匹配")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func receiptPayloadJSON(resp *walletv1.VerifyCoinSellerRechargeReceiptResponse) string {
|
||||||
|
if raw := strings.TrimSpace(resp.GetRawJson()); raw != "" {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"verified": resp.GetVerified(),
|
||||||
|
"provider_code": resp.GetProviderCode(),
|
||||||
|
"external_order_no": resp.GetExternalOrderNo(),
|
||||||
|
"provider_order_id": resp.GetProviderOrderId(),
|
||||||
|
"status": resp.GetStatus(),
|
||||||
|
"currency_code": resp.GetCurrencyCode(),
|
||||||
|
"provider_amount_minor": resp.GetProviderAmountMinor(),
|
||||||
|
"chain": resp.GetChain(),
|
||||||
|
"receive_address": resp.GetReceiveAddress(),
|
||||||
|
"failure_reason": resp.GetFailureReason(),
|
||||||
|
})
|
||||||
|
return string(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func receiptVerificationDTOFromProto(resp *walletv1.VerifyCoinSellerRechargeReceiptResponse) coinSellerRechargeReceiptVerificationDTO {
|
||||||
|
if resp == nil {
|
||||||
|
return coinSellerRechargeReceiptVerificationDTO{}
|
||||||
|
}
|
||||||
|
return coinSellerRechargeReceiptVerificationDTO{
|
||||||
|
Verified: resp.GetVerified(),
|
||||||
|
ProviderCode: resp.GetProviderCode(),
|
||||||
|
ExternalOrderNo: resp.GetExternalOrderNo(),
|
||||||
|
ProviderOrderID: resp.GetProviderOrderId(),
|
||||||
|
Status: resp.GetStatus(),
|
||||||
|
CurrencyCode: strings.ToUpper(strings.TrimSpace(resp.GetCurrencyCode())),
|
||||||
|
ProviderAmountMinor: resp.GetProviderAmountMinor(),
|
||||||
|
Chain: resp.GetChain(),
|
||||||
|
ReceiveAddress: resp.GetReceiveAddress(),
|
||||||
|
VerifiedAtMS: resp.GetVerifiedAtMs(),
|
||||||
|
FailureReason: resp.GetFailureReason(),
|
||||||
|
RawJSON: receiptPayloadJSON(resp),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRechargeProvider(providerCode string, chain string) (string, string, error) {
|
||||||
|
provider := strings.ToLower(strings.TrimSpace(providerCode))
|
||||||
|
provider = strings.ReplaceAll(provider, "-", "_")
|
||||||
|
normalizedChain := normalizeRechargeChain(chain)
|
||||||
|
if strings.TrimSpace(chain) != "" && normalizedChain == "" {
|
||||||
|
return "", "", errors.New("USDT 链不正确")
|
||||||
|
}
|
||||||
|
switch provider {
|
||||||
|
case "mifapy", "mifapay":
|
||||||
|
return "mifapay", "", nil
|
||||||
|
case "v5pay":
|
||||||
|
return "v5pay", "", nil
|
||||||
|
case "usdt":
|
||||||
|
return "usdt", normalizedChain, nil
|
||||||
|
case "usdt_trc20", "trc20", "tron":
|
||||||
|
return "usdt", "TRON", nil
|
||||||
|
case "usdt_bep20", "bep20", "bsc", "bnb":
|
||||||
|
return "usdt", "BSC", nil
|
||||||
|
default:
|
||||||
|
return "", "", errors.New("充值方式不正确")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRechargeChain(chain string) string {
|
||||||
|
chain = strings.ToLower(strings.TrimSpace(chain))
|
||||||
|
chain = strings.ReplaceAll(chain, "-", "_")
|
||||||
|
switch chain {
|
||||||
|
case "", "none":
|
||||||
|
return ""
|
||||||
|
case "tron", "trx", "trc20", "usdt_trc20":
|
||||||
|
return "TRON"
|
||||||
|
case "bsc", "bnb", "bep20", "usdt_bep20":
|
||||||
|
return "BSC"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func receiptProviderCode(order model.CoinSellerRechargeOrder) string {
|
||||||
|
if order.ProviderCode != "usdt" {
|
||||||
|
return order.ProviderCode
|
||||||
|
}
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(order.Chain)) {
|
||||||
|
case "TRON":
|
||||||
|
return "usdt_trc20"
|
||||||
|
case "BSC":
|
||||||
|
return "usdt_bep20"
|
||||||
|
default:
|
||||||
|
return "usdt"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func receiptVerificationProviderCode(input normalizedReceiptVerificationInput) string {
|
||||||
|
return receiptProviderCode(model.CoinSellerRechargeOrder{ProviderCode: input.ProviderCode, Chain: input.Chain})
|
||||||
|
}
|
||||||
|
|
||||||
|
func receiptChain(order model.CoinSellerRechargeOrder) string {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(order.Chain)) {
|
||||||
|
case "TRON":
|
||||||
|
return "trc20"
|
||||||
|
case "BSC":
|
||||||
|
return "bep20"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func receiptVerificationChain(input normalizedReceiptVerificationInput) string {
|
||||||
|
return receiptChain(model.CoinSellerRechargeOrder{ProviderCode: input.ProviderCode, Chain: input.Chain})
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeUSD(amount float64) (string, int64, error) {
|
||||||
|
if math.IsNaN(amount) || math.IsInf(amount, 0) || amount <= 0 {
|
||||||
|
return "", 0, errors.New("充值美金数量不正确")
|
||||||
|
}
|
||||||
|
cents := int64(math.Round(amount * 100))
|
||||||
|
if cents <= 0 {
|
||||||
|
return "", 0, errors.New("充值美金数量不正确")
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d.%02d", cents/100, cents%100), cents, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeOrderDateMS(orderDate string, orderDateMS int64) (int64, error) {
|
||||||
|
if orderDateMS > 0 {
|
||||||
|
return orderDateMS, nil
|
||||||
|
}
|
||||||
|
text := strings.TrimSpace(orderDate)
|
||||||
|
if text == "" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
for _, layout := range []string{"2006-01-02", "2006/01/02", time.RFC3339} {
|
||||||
|
if parsed, err := time.ParseInLocation(layout, text, time.UTC); err == nil {
|
||||||
|
return parsed.UTC().UnixMilli(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, errors.New("订单日期不正确")
|
||||||
|
}
|
||||||
|
|
||||||
|
func requiresLegacyWriter(appCode string) bool {
|
||||||
|
switch appctx.Normalize(appCode) {
|
||||||
|
case "aslan", "yumi":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func coinSellerRechargeWalletCommandID(orderID uint) string {
|
||||||
|
return fmt.Sprintf("admin-coin-seller-recharge:%d", orderID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func coinSellerRechargeEvidenceRef(orderID uint) string {
|
||||||
|
return fmt.Sprintf("admin-coin-seller-recharge:%d", orderID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func coinSellerRechargePaymentRef(order model.CoinSellerRechargeOrder) string {
|
||||||
|
if order.ProviderCode == "usdt" {
|
||||||
|
return "usdt:" + strings.ToLower(strings.TrimSpace(order.Chain)) + ":" + order.ExternalOrderNo
|
||||||
|
}
|
||||||
|
return order.ProviderCode + ":" + order.ExternalOrderNo
|
||||||
|
}
|
||||||
|
|
||||||
|
func coinSellerRechargeReason(order model.CoinSellerRechargeOrder) string {
|
||||||
|
return fmt.Sprintf("coin seller recharge order %d %s", order.ID, coinSellerRechargePaymentRef(order))
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePositiveInt64(value string) (int64, bool) {
|
||||||
|
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||||||
|
return parsed, err == nil && parsed > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatInt64(value int64) string {
|
||||||
|
return strconv.FormatInt(value, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasPermission(permissions []string, code string) bool {
|
||||||
|
for _, permission := range permissions {
|
||||||
|
if permission == code {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isGRPCNotFound(err error) bool {
|
||||||
|
return status.Code(err) == codes.NotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDuplicateKeyError(err error) bool {
|
||||||
|
text := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||||
|
return strings.Contains(text, "duplicate") || strings.Contains(text, "unique constraint")
|
||||||
|
}
|
||||||
211
server/admin/internal/modules/financeorder/service_test.go
Normal file
211
server/admin/internal/modules/financeorder/service_test.go
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
package financeorder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hyapp-admin-server/internal/model"
|
||||||
|
"hyapp-admin-server/internal/modules/shared"
|
||||||
|
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeCreateOrderRequiresExplicitUSDTChain(t *testing.T) {
|
||||||
|
_, err := normalizeCreateOrderRequest(createCoinSellerRechargeOrderRequest{
|
||||||
|
AppCode: "lalu",
|
||||||
|
TargetUserID: "10001",
|
||||||
|
USDAmount: 12.34,
|
||||||
|
CoinAmount: 1200,
|
||||||
|
ProviderCode: "usdt",
|
||||||
|
ExternalOrderNo: "0xabc",
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "必须选择链") {
|
||||||
|
t.Fatalf("expected explicit usdt chain error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
input, err := normalizeCreateOrderRequest(createCoinSellerRechargeOrderRequest{
|
||||||
|
AppCode: "lalu",
|
||||||
|
TargetUserID: "10001",
|
||||||
|
USDAmount: 12.34,
|
||||||
|
CoinAmount: 1200,
|
||||||
|
ProviderCode: "usdt",
|
||||||
|
Chain: "bsc",
|
||||||
|
ExternalOrderNo: "0xabc",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize usdt order failed: %v", err)
|
||||||
|
}
|
||||||
|
if input.ProviderCode != "usdt" || input.Chain != "BSC" || input.USDMinorAmount != 1234 {
|
||||||
|
t.Fatalf("normalized input mismatch: %+v", input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeCreateOrderDoesNotRequireProviderHints(t *testing.T) {
|
||||||
|
for _, providerCode := range []string{"mifapay", "v5pay"} {
|
||||||
|
input, err := normalizeCreateOrderRequest(createCoinSellerRechargeOrderRequest{
|
||||||
|
AppCode: "lalu",
|
||||||
|
TargetUserID: "10001",
|
||||||
|
USDAmount: 12.34,
|
||||||
|
CoinAmount: 1200,
|
||||||
|
ProviderCode: providerCode,
|
||||||
|
ExternalOrderNo: providerCode + "-order",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize %s without provider hints failed: %v", providerCode, err)
|
||||||
|
}
|
||||||
|
if input.ProviderCurrencyCode != "" || input.ProviderCountryCode != "" || input.OrderDateMS != 0 {
|
||||||
|
t.Fatalf("provider hints should remain optional for %s: %+v", providerCode, input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeCreateOrderAllowsZeroCoinAmount(t *testing.T) {
|
||||||
|
input, err := normalizeCreateOrderRequest(createCoinSellerRechargeOrderRequest{
|
||||||
|
AppCode: "lalu",
|
||||||
|
TargetUserID: "10001",
|
||||||
|
USDAmount: 12.34,
|
||||||
|
CoinAmount: 0,
|
||||||
|
ProviderCode: "mifapay",
|
||||||
|
ExternalOrderNo: "MIFA-001",
|
||||||
|
Remark: " 补单,不发金币 ",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("zero coin makeup order should be accepted: %v", err)
|
||||||
|
}
|
||||||
|
if input.CoinAmount != 0 || input.USDMinorAmount != 1234 || input.Remark != "补单,不发金币" {
|
||||||
|
t.Fatalf("normalized zero coin order mismatch: %+v", input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteGrantSkipsWalletAndLegacyForZeroCoinMakeupOrder(t *testing.T) {
|
||||||
|
service := NewService(nil, nil, nil)
|
||||||
|
result, err := service.executeGrant(context.Background(), shared.Actor{UserID: 7}, model.CoinSellerRechargeOrder{
|
||||||
|
ID: 42,
|
||||||
|
AppCode: "aslan",
|
||||||
|
TargetUserID: 10001,
|
||||||
|
CoinAmount: 0,
|
||||||
|
}, "req-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("zero coin makeup order should not require wallet or legacy writer: %v", err)
|
||||||
|
}
|
||||||
|
if result.CommandID != "admin-coin-seller-recharge:42" || result.TransactionID != "" || result.AmountDelta != 0 {
|
||||||
|
t.Fatalf("zero coin grant result mismatch: %+v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyReceiptMatchesOrderAmounts(t *testing.T) {
|
||||||
|
order := model.CoinSellerRechargeOrder{
|
||||||
|
ExternalOrderNo: "merchant-1",
|
||||||
|
USDMinorAmount: 1234,
|
||||||
|
ProviderCurrencyCode: "USD",
|
||||||
|
ProviderAmountMinor: 0,
|
||||||
|
ProviderPayloadJSON: "",
|
||||||
|
TargetDisplayUserID: "10001",
|
||||||
|
TargetCountryID: 1,
|
||||||
|
TargetRegionID: 2,
|
||||||
|
ProviderCountryCode: "SA",
|
||||||
|
ProviderPayType: "CARD",
|
||||||
|
ProviderOrderID: "merchant-1",
|
||||||
|
ProviderStatus: "paid",
|
||||||
|
WalletTransactionID: "",
|
||||||
|
WalletAssetType: "",
|
||||||
|
WalletAmountDelta: 0,
|
||||||
|
WalletBalanceAfter: 0,
|
||||||
|
OperatorName: "ops",
|
||||||
|
VerifiedByName: "",
|
||||||
|
GrantedByName: "",
|
||||||
|
ReceiveAddress: "",
|
||||||
|
FailureReason: "",
|
||||||
|
WalletCommandID: "",
|
||||||
|
TargetUserID: 10001,
|
||||||
|
ProviderCode: "mifapay",
|
||||||
|
Chain: "",
|
||||||
|
USDAmount: "12.34",
|
||||||
|
CoinAmount: 1200,
|
||||||
|
Status: model.CoinSellerRechargeOrderStatusCreated,
|
||||||
|
VerifyStatus: model.CoinSellerRechargeVerifyStatusPending,
|
||||||
|
GrantStatus: model.CoinSellerRechargeGrantStatusPending,
|
||||||
|
OperatorUserID: 7,
|
||||||
|
CreatedAtMS: 1,
|
||||||
|
UpdatedAtMS: 1,
|
||||||
|
}
|
||||||
|
if err := verifyReceiptMatchesOrder(order, &walletv1.VerifyCoinSellerRechargeReceiptResponse{
|
||||||
|
ExternalOrderNo: "merchant-1",
|
||||||
|
CurrencyCode: "USD",
|
||||||
|
ProviderAmountMinor: 1234,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("expected USD amount to match: %v", err)
|
||||||
|
}
|
||||||
|
if err := verifyReceiptMatchesOrder(order, &walletv1.VerifyCoinSellerRechargeReceiptResponse{
|
||||||
|
ExternalOrderNo: "merchant-1",
|
||||||
|
CurrencyCode: "USD",
|
||||||
|
ProviderAmountMinor: 1200,
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatalf("expected USD amount mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyReceiptMatchesNonUSDProviderAmount(t *testing.T) {
|
||||||
|
order := model.CoinSellerRechargeOrder{
|
||||||
|
ExternalOrderNo: "v5-1",
|
||||||
|
USDMinorAmount: 1000,
|
||||||
|
ProviderCurrencyCode: "IDR",
|
||||||
|
ProviderAmountMinor: 15000000,
|
||||||
|
ProviderCode: "v5pay",
|
||||||
|
TargetDisplayUserID: "10001",
|
||||||
|
TargetUserID: 10001,
|
||||||
|
USDAmount: "10.00",
|
||||||
|
CoinAmount: 1000,
|
||||||
|
Status: model.CoinSellerRechargeOrderStatusCreated,
|
||||||
|
VerifyStatus: model.CoinSellerRechargeVerifyStatusPending,
|
||||||
|
GrantStatus: model.CoinSellerRechargeGrantStatusPending,
|
||||||
|
OperatorUserID: 7,
|
||||||
|
}
|
||||||
|
if err := verifyReceiptMatchesOrder(order, &walletv1.VerifyCoinSellerRechargeReceiptResponse{
|
||||||
|
ExternalOrderNo: "v5-1",
|
||||||
|
CurrencyCode: "IDR",
|
||||||
|
ProviderAmountMinor: 15000000,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("expected provider amount to match: %v", err)
|
||||||
|
}
|
||||||
|
order.ProviderAmountMinor = 0
|
||||||
|
if err := verifyReceiptMatchesOrder(order, &walletv1.VerifyCoinSellerRechargeReceiptResponse{
|
||||||
|
ExternalOrderNo: "v5-1",
|
||||||
|
CurrencyCode: "IDR",
|
||||||
|
ProviderAmountMinor: 15000000,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("non-USD provider amount should be an audit snapshot when operator did not fill local minor amount: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyReceiptMatchesInputRequiresUSDExactMatch(t *testing.T) {
|
||||||
|
input := normalizedCreateOrderInput{
|
||||||
|
ExternalOrderNo: "usdt-tx",
|
||||||
|
USDMinorAmount: 1234,
|
||||||
|
ProviderCode: "usdt",
|
||||||
|
Chain: "TRON",
|
||||||
|
}
|
||||||
|
if err := verifyReceiptMatchesInput(input, coinSellerRechargeReceiptVerificationDTO{
|
||||||
|
ExternalOrderNo: "usdt-tx",
|
||||||
|
CurrencyCode: "USDT",
|
||||||
|
ProviderAmountMinor: 1234,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("expected usdt amount to match: %v", err)
|
||||||
|
}
|
||||||
|
if err := verifyReceiptMatchesInput(input, coinSellerRechargeReceiptVerificationDTO{
|
||||||
|
ExternalOrderNo: "usdt-tx",
|
||||||
|
CurrencyCode: "USDT",
|
||||||
|
ProviderAmountMinor: 1200,
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatalf("expected usdt amount mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReceiptProviderCodeForUSDTChains(t *testing.T) {
|
||||||
|
if got := receiptProviderCode(model.CoinSellerRechargeOrder{ProviderCode: "usdt", Chain: "TRON"}); got != "usdt_trc20" {
|
||||||
|
t.Fatalf("tron provider code mismatch: %s", got)
|
||||||
|
}
|
||||||
|
if got := receiptProviderCode(model.CoinSellerRechargeOrder{ProviderCode: "usdt", Chain: "BSC"}); got != "usdt_bep20" {
|
||||||
|
t.Fatalf("bsc provider code mismatch: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,277 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hyapp-admin-server/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrCoinSellerRechargeOrderNotVerified = errors.New("coin seller recharge order is not verified")
|
||||||
|
ErrCoinSellerRechargeOrderGranting = errors.New("coin seller recharge order is granting")
|
||||||
|
ErrCoinSellerRechargeOrderGranted = errors.New("coin seller recharge order already granted")
|
||||||
|
)
|
||||||
|
|
||||||
|
type CoinSellerRechargeOrderListOptions struct {
|
||||||
|
Page int
|
||||||
|
PageSize int
|
||||||
|
AppCode string
|
||||||
|
Status string
|
||||||
|
VerifyStatus string
|
||||||
|
GrantStatus string
|
||||||
|
ProviderCode string
|
||||||
|
Keyword string
|
||||||
|
TargetUserID int64
|
||||||
|
OperatorID uint
|
||||||
|
CreatedFromMS int64
|
||||||
|
CreatedToMS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type CoinSellerRechargeOrderVerifyInput struct {
|
||||||
|
VerifiedByUserID uint
|
||||||
|
VerifiedByName string
|
||||||
|
VerifiedAtMS int64
|
||||||
|
ProviderOrderID string
|
||||||
|
Status string
|
||||||
|
CurrencyCode string
|
||||||
|
ProviderAmountMinor int64
|
||||||
|
ProviderPayloadJSON string
|
||||||
|
ReceiveAddress string
|
||||||
|
FailureReason string
|
||||||
|
}
|
||||||
|
|
||||||
|
type CoinSellerRechargeOrderGrantInput struct {
|
||||||
|
GrantedByUserID uint
|
||||||
|
GrantedByName string
|
||||||
|
GrantedAtMS int64
|
||||||
|
WalletCommandID string
|
||||||
|
WalletTransactionID string
|
||||||
|
WalletAssetType string
|
||||||
|
WalletAmountDelta int64
|
||||||
|
WalletBalanceAfter int64
|
||||||
|
FailureReason string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateCoinSellerRechargeOrder(order *model.CoinSellerRechargeOrder) error {
|
||||||
|
if order == nil {
|
||||||
|
return errors.New("coin seller recharge order is required")
|
||||||
|
}
|
||||||
|
return s.db.Create(order).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetCoinSellerRechargeOrder(id uint) (*model.CoinSellerRechargeOrder, error) {
|
||||||
|
var order model.CoinSellerRechargeOrder
|
||||||
|
if err := s.db.First(&order, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &order, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListCoinSellerRechargeOrders(options CoinSellerRechargeOrderListOptions) ([]model.CoinSellerRechargeOrder, int64, error) {
|
||||||
|
page, pageSize := normalizePage(options.Page, options.PageSize)
|
||||||
|
query := applyCoinSellerRechargeOrderFilters(s.db.Model(&model.CoinSellerRechargeOrder{}), options)
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
var orders []model.CoinSellerRechargeOrder
|
||||||
|
err := query.
|
||||||
|
Order("created_at_ms DESC, id DESC").
|
||||||
|
Limit(pageSize).
|
||||||
|
Offset((page - 1) * pageSize).
|
||||||
|
Find(&orders).Error
|
||||||
|
return orders, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) MarkCoinSellerRechargeOrderVerified(id uint, input CoinSellerRechargeOrderVerifyInput) (*model.CoinSellerRechargeOrder, error) {
|
||||||
|
return s.updateCoinSellerRechargeVerifyState(id, input, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) MarkCoinSellerRechargeOrderVerifyFailed(id uint, input CoinSellerRechargeOrderVerifyInput) (*model.CoinSellerRechargeOrder, error) {
|
||||||
|
return s.updateCoinSellerRechargeVerifyState(id, input, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) updateCoinSellerRechargeVerifyState(id uint, input CoinSellerRechargeOrderVerifyInput, verified bool) (*model.CoinSellerRechargeOrder, error) {
|
||||||
|
if id == 0 {
|
||||||
|
return nil, errors.New("coin seller recharge order id is required")
|
||||||
|
}
|
||||||
|
var order model.CoinSellerRechargeOrder
|
||||||
|
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
// 校验可以在失败后重试,但已发放订单不能再改 provider 快照,避免审计证据和钱包事实分叉。
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, id).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if order.GrantStatus == model.CoinSellerRechargeGrantStatusGranted {
|
||||||
|
return ErrCoinSellerRechargeOrderGranted
|
||||||
|
}
|
||||||
|
status := model.CoinSellerRechargeOrderStatusVerifyFailed
|
||||||
|
verifyStatus := model.CoinSellerRechargeVerifyStatusFailed
|
||||||
|
if verified {
|
||||||
|
status = model.CoinSellerRechargeOrderStatusVerified
|
||||||
|
verifyStatus = model.CoinSellerRechargeVerifyStatusVerified
|
||||||
|
}
|
||||||
|
updates := map[string]any{
|
||||||
|
"status": status,
|
||||||
|
"verify_status": verifyStatus,
|
||||||
|
"failure_reason": strings.TrimSpace(input.FailureReason),
|
||||||
|
"provider_payload_json": strings.TrimSpace(input.ProviderPayloadJSON),
|
||||||
|
"verified_by_user_id": input.VerifiedByUserID,
|
||||||
|
"verified_by_name": strings.TrimSpace(input.VerifiedByName),
|
||||||
|
"verified_at_ms": input.VerifiedAtMS,
|
||||||
|
"updated_at_ms": input.VerifiedAtMS,
|
||||||
|
}
|
||||||
|
if input.ProviderAmountMinor > 0 {
|
||||||
|
updates["provider_amount_minor"] = input.ProviderAmountMinor
|
||||||
|
}
|
||||||
|
if input.ProviderOrderID != "" {
|
||||||
|
updates["provider_order_id"] = strings.TrimSpace(input.ProviderOrderID)
|
||||||
|
}
|
||||||
|
if input.Status != "" {
|
||||||
|
updates["provider_status"] = strings.TrimSpace(input.Status)
|
||||||
|
}
|
||||||
|
if input.CurrencyCode != "" {
|
||||||
|
updates["provider_currency_code"] = strings.ToUpper(strings.TrimSpace(input.CurrencyCode))
|
||||||
|
}
|
||||||
|
if input.ReceiveAddress != "" {
|
||||||
|
updates["receive_address"] = strings.TrimSpace(input.ReceiveAddress)
|
||||||
|
}
|
||||||
|
return tx.Model(&order).Updates(updates).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.GetCoinSellerRechargeOrder(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ClaimCoinSellerRechargeOrderGrant(id uint, operatorUserID uint, operatorName string, nowMS int64) (*model.CoinSellerRechargeOrder, error) {
|
||||||
|
if id == 0 {
|
||||||
|
return nil, errors.New("coin seller recharge order id is required")
|
||||||
|
}
|
||||||
|
var order model.CoinSellerRechargeOrder
|
||||||
|
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
// 发放是唯一账务副作用:先锁行并切到 processing,再释放事务调用下游,阻断并发双发。
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, id).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch order.GrantStatus {
|
||||||
|
case model.CoinSellerRechargeGrantStatusGranted:
|
||||||
|
return nil
|
||||||
|
case model.CoinSellerRechargeGrantStatusRunning:
|
||||||
|
return ErrCoinSellerRechargeOrderGranting
|
||||||
|
}
|
||||||
|
if order.VerifyStatus != model.CoinSellerRechargeVerifyStatusVerified {
|
||||||
|
return ErrCoinSellerRechargeOrderNotVerified
|
||||||
|
}
|
||||||
|
return tx.Model(&order).Updates(map[string]any{
|
||||||
|
"status": model.CoinSellerRechargeOrderStatusGranting,
|
||||||
|
"grant_status": model.CoinSellerRechargeGrantStatusRunning,
|
||||||
|
"failure_reason": "",
|
||||||
|
"granted_by_user_id": operatorUserID,
|
||||||
|
"granted_by_name": strings.TrimSpace(operatorName),
|
||||||
|
"updated_at_ms": nowMS,
|
||||||
|
}).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.GetCoinSellerRechargeOrder(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) MarkCoinSellerRechargeOrderGranted(id uint, input CoinSellerRechargeOrderGrantInput) (*model.CoinSellerRechargeOrder, error) {
|
||||||
|
if id == 0 {
|
||||||
|
return nil, errors.New("coin seller recharge order id is required")
|
||||||
|
}
|
||||||
|
var order model.CoinSellerRechargeOrder
|
||||||
|
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, id).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if order.GrantStatus == model.CoinSellerRechargeGrantStatusGranted {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return tx.Model(&order).Updates(map[string]any{
|
||||||
|
"status": model.CoinSellerRechargeOrderStatusGranted,
|
||||||
|
"grant_status": model.CoinSellerRechargeGrantStatusGranted,
|
||||||
|
"failure_reason": "",
|
||||||
|
"wallet_command_id": strings.TrimSpace(input.WalletCommandID),
|
||||||
|
"wallet_transaction_id": strings.TrimSpace(input.WalletTransactionID),
|
||||||
|
"wallet_asset_type": strings.TrimSpace(input.WalletAssetType),
|
||||||
|
"wallet_amount_delta": input.WalletAmountDelta,
|
||||||
|
"wallet_balance_after": input.WalletBalanceAfter,
|
||||||
|
"granted_by_user_id": input.GrantedByUserID,
|
||||||
|
"granted_by_name": strings.TrimSpace(input.GrantedByName),
|
||||||
|
"granted_at_ms": input.GrantedAtMS,
|
||||||
|
"updated_at_ms": input.GrantedAtMS,
|
||||||
|
}).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.GetCoinSellerRechargeOrder(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) MarkCoinSellerRechargeOrderGrantFailed(id uint, input CoinSellerRechargeOrderGrantInput) (*model.CoinSellerRechargeOrder, error) {
|
||||||
|
if id == 0 {
|
||||||
|
return nil, errors.New("coin seller recharge order id is required")
|
||||||
|
}
|
||||||
|
var order model.CoinSellerRechargeOrder
|
||||||
|
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, id).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if order.GrantStatus == model.CoinSellerRechargeGrantStatusGranted {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return tx.Model(&order).Updates(map[string]any{
|
||||||
|
"status": model.CoinSellerRechargeOrderStatusGrantFailed,
|
||||||
|
"grant_status": model.CoinSellerRechargeGrantStatusFailed,
|
||||||
|
"failure_reason": strings.TrimSpace(input.FailureReason),
|
||||||
|
"granted_by_user_id": input.GrantedByUserID,
|
||||||
|
"granted_by_name": strings.TrimSpace(input.GrantedByName),
|
||||||
|
"updated_at_ms": input.GrantedAtMS,
|
||||||
|
}).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.GetCoinSellerRechargeOrder(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyCoinSellerRechargeOrderFilters(query *gorm.DB, options CoinSellerRechargeOrderListOptions) *gorm.DB {
|
||||||
|
if appCode := strings.TrimSpace(options.AppCode); appCode != "" {
|
||||||
|
query = query.Where("app_code = ?", appCode)
|
||||||
|
}
|
||||||
|
if status := strings.TrimSpace(options.Status); status != "" && status != "all" {
|
||||||
|
query = query.Where("status = ?", status)
|
||||||
|
}
|
||||||
|
if verifyStatus := strings.TrimSpace(options.VerifyStatus); verifyStatus != "" && verifyStatus != "all" {
|
||||||
|
query = query.Where("verify_status = ?", verifyStatus)
|
||||||
|
}
|
||||||
|
if grantStatus := strings.TrimSpace(options.GrantStatus); grantStatus != "" && grantStatus != "all" {
|
||||||
|
query = query.Where("grant_status = ?", grantStatus)
|
||||||
|
}
|
||||||
|
if providerCode := strings.TrimSpace(options.ProviderCode); providerCode != "" && providerCode != "all" {
|
||||||
|
query = query.Where("provider_code = ?", providerCode)
|
||||||
|
}
|
||||||
|
if options.TargetUserID > 0 {
|
||||||
|
query = query.Where("target_user_id = ?", options.TargetUserID)
|
||||||
|
}
|
||||||
|
if options.OperatorID > 0 {
|
||||||
|
query = query.Where("operator_user_id = ?", options.OperatorID)
|
||||||
|
}
|
||||||
|
if options.CreatedFromMS > 0 {
|
||||||
|
query = query.Where("created_at_ms >= ?", options.CreatedFromMS)
|
||||||
|
}
|
||||||
|
if options.CreatedToMS > 0 {
|
||||||
|
query = query.Where("created_at_ms < ?", options.CreatedToMS)
|
||||||
|
}
|
||||||
|
if keyword := strings.TrimSpace(options.Keyword); keyword != "" {
|
||||||
|
like := "%" + keyword + "%"
|
||||||
|
query = query.Where("external_order_no LIKE ? OR target_display_user_id LIKE ? OR operator_name LIKE ? OR verified_by_name LIKE ? OR granted_by_name LIKE ? OR remark LIKE ? OR CAST(target_user_id AS CHAR) LIKE ? OR CAST(id AS CHAR) LIKE ?", like, like, like, like, like, like, like, like)
|
||||||
|
}
|
||||||
|
return query
|
||||||
|
}
|
||||||
@ -83,6 +83,7 @@ func (s *Store) AutoMigrate() error {
|
|||||||
&model.TemporaryPaymentLinkOwner{},
|
&model.TemporaryPaymentLinkOwner{},
|
||||||
&model.LegacyGooglePaidDetail{},
|
&model.LegacyGooglePaidDetail{},
|
||||||
&model.FinanceApplication{},
|
&model.FinanceApplication{},
|
||||||
|
&model.CoinSellerRechargeOrder{},
|
||||||
&model.UserWithdrawalApplication{},
|
&model.UserWithdrawalApplication{},
|
||||||
&model.AdminJob{},
|
&model.AdminJob{},
|
||||||
)
|
)
|
||||||
|
|||||||
@ -122,6 +122,10 @@ var defaultPermissions = []model.Permission{
|
|||||||
{Name: "财务范围查看", Code: "finance:view", Kind: "menu"},
|
{Name: "财务范围查看", Code: "finance:view", Kind: "menu"},
|
||||||
{Name: "财务申请发起", Code: "finance-application:create", Kind: "button"},
|
{Name: "财务申请发起", Code: "finance-application:create", Kind: "button"},
|
||||||
{Name: "财务申请审核", Code: "finance-application:audit", Kind: "button"},
|
{Name: "财务申请审核", Code: "finance-application:audit", Kind: "button"},
|
||||||
|
{Name: "币商充值订单查看", Code: "finance-order:coin-seller-recharge:view", Kind: "menu"},
|
||||||
|
{Name: "币商充值订单创建", Code: "finance-order:coin-seller-recharge:create", Kind: "button"},
|
||||||
|
{Name: "币商充值订单校验", Code: "finance-order:coin-seller-recharge:verify", Kind: "button"},
|
||||||
|
{Name: "币商充值订单发放", Code: "finance-order:coin-seller-recharge:grant", Kind: "button"},
|
||||||
{Name: "用户提现申请查看", Code: "finance-withdrawal:view", Kind: "menu"},
|
{Name: "用户提现申请查看", Code: "finance-withdrawal:view", Kind: "menu"},
|
||||||
{Name: "财务增加用户金币", Code: "finance-operation:user-coin-credit", Kind: "button"},
|
{Name: "财务增加用户金币", Code: "finance-operation:user-coin-credit", Kind: "button"},
|
||||||
{Name: "财务减少用户金币", Code: "finance-operation:user-coin-debit", Kind: "button"},
|
{Name: "财务减少用户金币", Code: "finance-operation:user-coin-debit", Kind: "button"},
|
||||||
@ -318,6 +322,7 @@ func (s *Store) seedMenus() error {
|
|||||||
{ParentID: &paymentID, Title: "三方支付", Code: "payment-third-party", Path: "/payment/third-party", Icon: "wallet", PermissionCode: "payment-third-party:view", Sort: 69, Visible: true},
|
{ParentID: &paymentID, Title: "三方支付", Code: "payment-third-party", Path: "/payment/third-party", Icon: "wallet", PermissionCode: "payment-third-party:view", Sort: 69, Visible: true},
|
||||||
{ParentID: &paymentID, Title: "三方临时支付链接", Code: "payment-temporary-links", Path: "/payment/temporary-links", Icon: "receipt", PermissionCode: "payment-temporary-link:view", Sort: 70, Visible: true},
|
{ParentID: &paymentID, Title: "三方临时支付链接", Code: "payment-temporary-links", Path: "/payment/temporary-links", Icon: "receipt", PermissionCode: "payment-temporary-link:view", Sort: 70, Visible: true},
|
||||||
{ParentID: &paymentID, Title: "支付内购商品", Code: "payment-recharge-products", Path: "/payment/recharge-products", Icon: "wallet", PermissionCode: "payment-product:view", Sort: 71, Visible: true},
|
{ParentID: &paymentID, Title: "支付内购商品", Code: "payment-recharge-products", Path: "/payment/recharge-products", Icon: "wallet", PermissionCode: "payment-product:view", Sort: 71, Visible: true},
|
||||||
|
{ParentID: &paymentID, Title: "币商充值订单", Code: "finance-coin-seller-recharge-orders", Path: "/finance/orders/coin-seller-recharges", Icon: "receipt", PermissionCode: "finance-order:coin-seller-recharge:view", Sort: 72, Visible: true},
|
||||||
{ParentID: &activityID, Title: "每日任务", Code: "daily-task-list", Path: "/activities/daily-tasks", Icon: "task", PermissionCode: "daily-task:view", Sort: 69, Visible: true},
|
{ParentID: &activityID, Title: "每日任务", Code: "daily-task-list", Path: "/activities/daily-tasks", Icon: "task", PermissionCode: "daily-task:view", Sort: 69, Visible: true},
|
||||||
{ParentID: &activityID, Title: "注册奖励", Code: "registration-reward", Path: "/activities/registration-reward", Icon: "gift", PermissionCode: "registration-reward:view", Sort: 70, Visible: true},
|
{ParentID: &activityID, Title: "注册奖励", Code: "registration-reward", Path: "/activities/registration-reward", Icon: "gift", PermissionCode: "registration-reward:view", Sort: 70, Visible: true},
|
||||||
{ParentID: &activityID, Title: "成就配置", Code: "achievement-config", Path: "/activities/achievements", Icon: "military_tech", PermissionCode: "achievement:view", Sort: 71, Visible: true},
|
{ParentID: &activityID, Title: "成就配置", Code: "achievement-config", Path: "/activities/achievements", Icon: "military_tech", PermissionCode: "achievement:view", Sort: 71, Visible: true},
|
||||||
@ -645,7 +650,7 @@ func defaultRolePermissionCodes(code string) []string {
|
|||||||
"agency:view", "agency:create", "agency:status", "agency:delete",
|
"agency:view", "agency:create", "agency:status", "agency:delete",
|
||||||
"bd:view", "bd:create", "bd:update",
|
"bd:view", "bd:create", "bd:update",
|
||||||
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate",
|
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate",
|
||||||
"coin-ledger:view", "coin-seller-ledger:view", "host-withdrawal:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "payment-temporary-link:create", "finance-application:create", "finance-withdrawal:view", "finance-operation:user-coin-credit", "finance-operation:user-coin-debit", "finance-operation:user-wallet-credit", "finance-operation:user-wallet-debit", "finance-operation:coin-seller-coin-credit", "finance-operation:coin-seller-coin-debit", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
|
"coin-ledger:view", "coin-seller-ledger:view", "host-withdrawal:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "payment-temporary-link:create", "finance-application:create", "finance-order:coin-seller-recharge:view", "finance-order:coin-seller-recharge:create", "finance-order:coin-seller-recharge:verify", "finance-order:coin-seller-recharge:grant", "finance-withdrawal:view", "finance-operation:user-coin-credit", "finance-operation:user-coin-debit", "finance-operation:user-wallet-credit", "finance-operation:user-wallet-debit", "finance-operation:coin-seller-coin-credit", "finance-operation:coin-seller-coin-debit", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
|
||||||
"lucky-gift:view", "lucky-gift:update", "wheel:view", "wheel:update",
|
"lucky-gift:view", "lucky-gift:update", "wheel:view", "wheel:update",
|
||||||
"game:view", "game:create", "game:update", "game:status", "game:delete",
|
"game:view", "game:create", "game:update", "game:status", "game:delete",
|
||||||
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
|
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
|
||||||
@ -662,7 +667,7 @@ func defaultRolePermissionCodes(code string) []string {
|
|||||||
"upload:create",
|
"upload:create",
|
||||||
}
|
}
|
||||||
case "auditor":
|
case "auditor":
|
||||||
return []string{"overview:view", "log:view", "user:view", "team:view", "app-user:view", "level-config:view", "pretty-id:view", "risk-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "room-whitelist:view", "room-robot:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "host-withdrawal:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "full-server-notice:view", "lucky-gift:view", "wheel:view", "payment-bill:view", "payment-third-party:view", "payment-temporary-link:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "cp-weekly-rank:view", "vip-config:view", "weekly-star:view", "agency-opening:view", "role:view", "permission:view", "job:view"}
|
return []string{"overview:view", "log:view", "user:view", "team:view", "app-user:view", "level-config:view", "pretty-id:view", "risk-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "room-whitelist:view", "room-robot:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "host-withdrawal:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "full-server-notice:view", "lucky-gift:view", "wheel:view", "payment-bill:view", "payment-third-party:view", "payment-temporary-link:view", "finance-order:coin-seller-recharge:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "cp-weekly-rank:view", "vip-config:view", "weekly-star:view", "agency-opening:view", "role:view", "permission:view", "job:view"}
|
||||||
case "readonly":
|
case "readonly":
|
||||||
return []string{
|
return []string{
|
||||||
"overview:view",
|
"overview:view",
|
||||||
@ -707,6 +712,7 @@ func defaultRolePermissionCodes(code string) []string {
|
|||||||
"payment-bill:view",
|
"payment-bill:view",
|
||||||
"payment-third-party:view",
|
"payment-third-party:view",
|
||||||
"payment-temporary-link:view",
|
"payment-temporary-link:view",
|
||||||
|
"finance-order:coin-seller-recharge:view",
|
||||||
"payment-product:view",
|
"payment-product:view",
|
||||||
"game:view",
|
"game:view",
|
||||||
"daily-task:view",
|
"daily-task:view",
|
||||||
@ -754,7 +760,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
|
|||||||
"region:view", "region:create", "region:update", "region:status",
|
"region:view", "region:create", "region:update", "region:status",
|
||||||
"host-agency-policy:view", "host-agency-policy:create", "host-agency-policy:update", "host-agency-policy:delete", "host-agency-policy:publish", "team-salary-policy:view", "team-salary-policy:create", "team-salary-policy:update", "team-salary-policy:delete", "host-salary-settlement:view", "host-salary-settlement:settle",
|
"host-agency-policy:view", "host-agency-policy:create", "host-agency-policy:update", "host-agency-policy:delete", "host-agency-policy:publish", "team-salary-policy:view", "team-salary-policy:create", "team-salary-policy:update", "team-salary-policy:delete", "host-salary-settlement:view", "host-salary-settlement:settle",
|
||||||
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate",
|
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit", "coin-seller:exchange-rate",
|
||||||
"coin-ledger:view", "coin-seller-ledger:view", "host-withdrawal:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "payment-temporary-link:create", "finance-application:create", "finance-operation:user-coin-credit", "finance-operation:user-coin-debit", "finance-operation:user-wallet-credit", "finance-operation:user-wallet-debit", "finance-operation:coin-seller-coin-credit", "finance-operation:coin-seller-coin-debit", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
|
"coin-ledger:view", "coin-seller-ledger:view", "host-withdrawal:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "full-server-notice:view", "full-server-notice:send", "payment-bill:view", "payment-third-party:view", "payment-third-party:update", "payment-temporary-link:view", "payment-temporary-link:create", "finance-application:create", "finance-order:coin-seller-recharge:view", "finance-order:coin-seller-recharge:create", "finance-order:coin-seller-recharge:verify", "finance-order:coin-seller-recharge:grant", "finance-operation:user-coin-credit", "finance-operation:user-coin-debit", "finance-operation:user-wallet-credit", "finance-operation:user-wallet-debit", "finance-operation:coin-seller-coin-credit", "finance-operation:coin-seller-coin-debit", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
|
||||||
"lucky-gift:view", "lucky-gift:update", "wheel:view", "wheel:update",
|
"lucky-gift:view", "lucky-gift:update", "wheel:view", "wheel:update",
|
||||||
"game:view", "game:create", "game:update", "game:status", "game:delete",
|
"game:view", "game:create", "game:update", "game:status", "game:delete",
|
||||||
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
|
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
|
||||||
@ -768,7 +774,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
|
|||||||
"agency-opening:view", "agency-opening:create", "agency-opening:update",
|
"agency-opening:view", "agency-opening:create", "agency-opening:update",
|
||||||
}
|
}
|
||||||
case "auditor", "readonly":
|
case "auditor", "readonly":
|
||||||
return []string{"team:view", "level-config:view", "pretty-id:view", "risk-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "room-whitelist:view", "room-robot:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "host-withdrawal:view", "coin-seller:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "full-server-notice:view", "lucky-gift:view", "wheel:view", "payment-bill:view", "payment-third-party:view", "payment-temporary-link:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "cp-weekly-rank:view", "vip-config:view", "weekly-star:view", "agency-opening:view"}
|
return []string{"team:view", "level-config:view", "pretty-id:view", "risk-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "room-whitelist:view", "room-robot:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "host-withdrawal:view", "coin-seller:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "full-server-notice:view", "lucky-gift:view", "wheel:view", "payment-bill:view", "payment-third-party:view", "payment-temporary-link:view", "finance-order:coin-seller-recharge:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "cp-weekly-rank:view", "vip-config:view", "weekly-star:view", "agency-opening:view"}
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import (
|
|||||||
"hyapp-admin-server/internal/modules/dashboard"
|
"hyapp-admin-server/internal/modules/dashboard"
|
||||||
"hyapp-admin-server/internal/modules/databi"
|
"hyapp-admin-server/internal/modules/databi"
|
||||||
"hyapp-admin-server/internal/modules/financeapplication"
|
"hyapp-admin-server/internal/modules/financeapplication"
|
||||||
|
"hyapp-admin-server/internal/modules/financeorder"
|
||||||
"hyapp-admin-server/internal/modules/financewithdrawal"
|
"hyapp-admin-server/internal/modules/financewithdrawal"
|
||||||
"hyapp-admin-server/internal/modules/firstrechargereward"
|
"hyapp-admin-server/internal/modules/firstrechargereward"
|
||||||
"hyapp-admin-server/internal/modules/fullservernotice"
|
"hyapp-admin-server/internal/modules/fullservernotice"
|
||||||
@ -82,6 +83,7 @@ type Handlers struct {
|
|||||||
Databi *databi.Handler
|
Databi *databi.Handler
|
||||||
FirstRechargeReward *firstrechargereward.Handler
|
FirstRechargeReward *firstrechargereward.Handler
|
||||||
FinanceApplication *financeapplication.Handler
|
FinanceApplication *financeapplication.Handler
|
||||||
|
FinanceOrder *financeorder.Handler
|
||||||
FinanceWithdrawal *financewithdrawal.Handler
|
FinanceWithdrawal *financewithdrawal.Handler
|
||||||
FullServerNotice *fullservernotice.Handler
|
FullServerNotice *fullservernotice.Handler
|
||||||
Game *gamemanagement.Handler
|
Game *gamemanagement.Handler
|
||||||
@ -151,6 +153,7 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
|
|||||||
dailytask.RegisterRoutes(protected, h.DailyTask)
|
dailytask.RegisterRoutes(protected, h.DailyTask)
|
||||||
firstrechargereward.RegisterRoutes(protected, h.FirstRechargeReward)
|
firstrechargereward.RegisterRoutes(protected, h.FirstRechargeReward)
|
||||||
financeapplication.RegisterRoutes(protected, h.FinanceApplication)
|
financeapplication.RegisterRoutes(protected, h.FinanceApplication)
|
||||||
|
financeorder.RegisterRoutes(protected, h.FinanceOrder)
|
||||||
financewithdrawal.RegisterRoutes(protected, h.FinanceWithdrawal)
|
financewithdrawal.RegisterRoutes(protected, h.FinanceWithdrawal)
|
||||||
fullservernotice.RegisterRoutes(protected, h.FullServerNotice)
|
fullservernotice.RegisterRoutes(protected, h.FullServerNotice)
|
||||||
registrationreward.RegisterRoutes(protected, h.RegistrationReward)
|
registrationreward.RegisterRoutes(protected, h.RegistrationReward)
|
||||||
|
|||||||
119
server/admin/migrations/080_coin_seller_recharge_orders.sql
Normal file
119
server/admin/migrations/080_coin_seller_recharge_orders.sql
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 币商充值订单是 admin 自己的跨 APP 绑定台账;Lalu 入账走 wallet-service,Aslan/Yumi 入账走 legacy 账套,但订单号占用和审计事实统一落在这里。
|
||||||
|
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_coin_seller_recharge_orders (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
|
||||||
|
target_user_id BIGINT NOT NULL COMMENT '币商真实 user_id',
|
||||||
|
target_display_user_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '币商短 ID/展示 ID 快照',
|
||||||
|
target_country_id BIGINT NOT NULL DEFAULT 0 COMMENT '币商国家 ID 快照',
|
||||||
|
target_region_id BIGINT NOT NULL DEFAULT 0 COMMENT '币商区域 ID 快照',
|
||||||
|
usd_amount DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '运营确认的 USD/USDT 等值金额',
|
||||||
|
usd_minor_amount BIGINT NOT NULL DEFAULT 0 COMMENT 'USD/USDT 美分口径金额',
|
||||||
|
coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '发放给币商的金币库存数量',
|
||||||
|
provider_code VARCHAR(32) NOT NULL COMMENT 'mifapay/v5pay/usdt',
|
||||||
|
chain VARCHAR(16) NOT NULL DEFAULT '' COMMENT 'USDT 链:TRON/BSC',
|
||||||
|
external_order_no VARCHAR(128) NOT NULL COMMENT '三方平台商户单号或链上 tx_hash',
|
||||||
|
provider_order_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '三方/链上返回的订单 ID 快照',
|
||||||
|
provider_status VARCHAR(64) NOT NULL DEFAULT '' COMMENT '三方/链上状态快照',
|
||||||
|
provider_country_code VARCHAR(16) NOT NULL DEFAULT '' COMMENT 'V5Pay 等三方查询国家',
|
||||||
|
provider_currency_code VARCHAR(16) NOT NULL DEFAULT '' COMMENT '三方实付币种',
|
||||||
|
provider_amount_minor BIGINT NOT NULL DEFAULT 0 COMMENT '三方实付最小单位金额或校验返回金额',
|
||||||
|
provider_pay_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT '三方支付方式/产品类型',
|
||||||
|
receive_address VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'USDT 收款地址快照',
|
||||||
|
order_date_ms BIGINT NOT NULL DEFAULT 0 COMMENT 'MiFaPay 等按日查询的订单日期,UTC epoch ms',
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'created',
|
||||||
|
verify_status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||||
|
grant_status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||||
|
remark TEXT NULL COMMENT '运营备注,用于补单和异常说明',
|
||||||
|
failure_reason TEXT NULL,
|
||||||
|
provider_payload_json TEXT NULL COMMENT '三方或链上返回原始快照',
|
||||||
|
wallet_command_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '发放幂等命令 ID',
|
||||||
|
wallet_transaction_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'wallet-service 或 legacy 流水 ID',
|
||||||
|
wallet_asset_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT '账务资产类型',
|
||||||
|
wallet_amount_delta BIGINT NOT NULL DEFAULT 0 COMMENT '金币库存变动数量',
|
||||||
|
wallet_balance_after BIGINT NOT NULL DEFAULT 0 COMMENT '发放后余额快照',
|
||||||
|
operator_user_id BIGINT UNSIGNED NOT NULL COMMENT '创建人 admin id',
|
||||||
|
operator_name VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人名称',
|
||||||
|
verified_by_user_id BIGINT UNSIGNED NULL COMMENT '校验人 admin id',
|
||||||
|
verified_by_name VARCHAR(64) NOT NULL DEFAULT '' COMMENT '校验人名称',
|
||||||
|
granted_by_user_id BIGINT UNSIGNED NULL COMMENT '发放人 admin id',
|
||||||
|
granted_by_name VARCHAR(64) NOT NULL DEFAULT '' COMMENT '发放人名称',
|
||||||
|
verified_at_ms BIGINT NULL COMMENT '校验时间,UTC epoch ms',
|
||||||
|
granted_at_ms BIGINT NULL COMMENT '发放时间,UTC epoch ms',
|
||||||
|
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||||
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_admin_coin_seller_recharge_external (app_code, provider_code, chain, external_order_no),
|
||||||
|
KEY idx_admin_coin_seller_recharge_app_time (app_code, created_at_ms),
|
||||||
|
KEY idx_admin_coin_seller_recharge_target (target_user_id),
|
||||||
|
KEY idx_admin_coin_seller_recharge_display (target_display_user_id),
|
||||||
|
KEY idx_admin_coin_seller_recharge_provider (provider_code),
|
||||||
|
KEY idx_admin_coin_seller_recharge_external (external_order_no),
|
||||||
|
KEY idx_admin_coin_seller_recharge_status_time (status, created_at_ms),
|
||||||
|
KEY idx_admin_coin_seller_recharge_verify (verify_status),
|
||||||
|
KEY idx_admin_coin_seller_recharge_grant (grant_status),
|
||||||
|
KEY idx_admin_coin_seller_recharge_command (wallet_command_id),
|
||||||
|
KEY idx_admin_coin_seller_recharge_wallet_tx (wallet_transaction_id),
|
||||||
|
KEY idx_admin_coin_seller_recharge_operator (operator_user_id),
|
||||||
|
KEY idx_admin_coin_seller_recharge_verifier (verified_by_user_id),
|
||||||
|
KEY idx_admin_coin_seller_recharge_granter (granted_by_user_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='币商充值订单与外部账单绑定台账';
|
||||||
|
|
||||||
|
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
|
||||||
|
('币商充值订单查看', 'finance-order:coin-seller-recharge:view', 'menu', '允许查看币商充值订单绑定台账', @now_ms, @now_ms),
|
||||||
|
('币商充值订单创建', 'finance-order:coin-seller-recharge:create', 'button', '允许创建币商充值订单并绑定外部订单号', @now_ms, @now_ms),
|
||||||
|
('币商充值订单校验', 'finance-order:coin-seller-recharge:verify', 'button', '允许校验三方订单或 USDT 交易凭证', @now_ms, @now_ms),
|
||||||
|
('币商充值订单发放', 'finance-order:coin-seller-recharge:grant', 'button', '允许校验通过后确认发放币商金币库存', @now_ms, @now_ms)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
kind = VALUES(kind),
|
||||||
|
description = VALUES(description),
|
||||||
|
updated_at_ms = @now_ms;
|
||||||
|
|
||||||
|
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
|
||||||
|
SELECT parent.id, '币商充值订单', 'finance-coin-seller-recharge-orders', '/finance/orders/coin-seller-recharges', 'receipt', 'finance-order:coin-seller-recharge:view', 72, TRUE, @now_ms, @now_ms
|
||||||
|
FROM admin_menus parent
|
||||||
|
WHERE parent.code = 'payment'
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
parent_id = VALUES(parent_id),
|
||||||
|
title = VALUES(title),
|
||||||
|
path = VALUES(path),
|
||||||
|
icon = VALUES(icon),
|
||||||
|
permission_code = VALUES(permission_code),
|
||||||
|
sort = VALUES(sort),
|
||||||
|
visible = VALUES(visible),
|
||||||
|
updated_at_ms = @now_ms;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||||
|
SELECT admin_role.id, admin_permission.id
|
||||||
|
FROM admin_roles admin_role
|
||||||
|
JOIN admin_permissions admin_permission
|
||||||
|
WHERE admin_role.code = 'platform-admin'
|
||||||
|
AND admin_permission.code IN (
|
||||||
|
'finance-order:coin-seller-recharge:view',
|
||||||
|
'finance-order:coin-seller-recharge:create',
|
||||||
|
'finance-order:coin-seller-recharge:verify',
|
||||||
|
'finance-order:coin-seller-recharge:grant'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||||
|
SELECT admin_role.id, admin_permission.id
|
||||||
|
FROM admin_roles admin_role
|
||||||
|
JOIN admin_permissions admin_permission
|
||||||
|
WHERE admin_role.code = 'ops-admin'
|
||||||
|
AND admin_permission.code IN (
|
||||||
|
'finance-order:coin-seller-recharge:view',
|
||||||
|
'finance-order:coin-seller-recharge:create',
|
||||||
|
'finance-order:coin-seller-recharge:verify',
|
||||||
|
'finance-order:coin-seller-recharge:grant'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||||
|
SELECT admin_role.id, admin_permission.id
|
||||||
|
FROM admin_roles admin_role
|
||||||
|
JOIN admin_permissions admin_permission
|
||||||
|
WHERE admin_role.code IN ('auditor', 'readonly')
|
||||||
|
AND admin_permission.code = 'finance-order:coin-seller-recharge:view';
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 备注是币商充值补单和异常处理的审计字段;已经跑过 080 的环境需要补列,新库由 080 直接创建。
|
||||||
|
SET @ddl := IF(
|
||||||
|
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_coin_seller_recharge_orders' AND COLUMN_NAME = 'remark') = 0,
|
||||||
|
'ALTER TABLE admin_coin_seller_recharge_orders ADD COLUMN remark TEXT NULL COMMENT ''运营备注,用于补单和异常说明'' AFTER grant_status',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @ddl;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
@ -123,8 +123,8 @@ tencent_rtc:
|
|||||||
tencent-cos:
|
tencent-cos:
|
||||||
# Docker 本地联调 App 文件/头像上传;secret 只保存在 gateway 服务端配置。
|
# Docker 本地联调 App 文件/头像上传;secret 只保存在 gateway 服务端配置。
|
||||||
enabled: true
|
enabled: true
|
||||||
secret-id: "IKIDMchhZEfrsiNo472DAtTpzzmLjttkOnyu"
|
secret-id: "IKID8OgrsumTzIRO9j8LemiT3eAUceWGslW4"
|
||||||
secret-key: "nMkbLsGRO6ZqulSyJQJ0UjjU0KSKxOgl"
|
secret-key: "ZQ5XyJSTJ9sPxLXMD2aFoBOQIzXv9C8s"
|
||||||
bucket-name: "yumi-assets-1420526837"
|
bucket-name: "yumi-assets-1420526837"
|
||||||
region: "me-saudi-arabia"
|
region: "me-saudi-arabia"
|
||||||
access-url: "https://media.haiyihy.com"
|
access-url: "https://media.haiyihy.com"
|
||||||
|
|||||||
@ -129,8 +129,8 @@ tencent_rtc:
|
|||||||
tencent-cos:
|
tencent-cos:
|
||||||
# App 文件/头像上传使用腾讯云 COS;secret 只保存在 gateway 服务端配置。
|
# App 文件/头像上传使用腾讯云 COS;secret 只保存在 gateway 服务端配置。
|
||||||
enabled: true
|
enabled: true
|
||||||
secret-id: "IKIDMchhZEfrsiNo472DAtTpzzmLjttkOnyu"
|
secret-id: "IKID8OgrsumTzIRO9j8LemiT3eAUceWGslW4"
|
||||||
secret-key: "nMkbLsGRO6ZqulSyJQJ0UjjU0KSKxOgl"
|
secret-key: "ZQ5XyJSTJ9sPxLXMD2aFoBOQIzXv9C8s"
|
||||||
bucket-name: "yumi-assets-1420526837"
|
bucket-name: "yumi-assets-1420526837"
|
||||||
region: "me-saudi-arabia"
|
region: "me-saudi-arabia"
|
||||||
access-url: "https://media.haiyihy.com"
|
access-url: "https://media.haiyihy.com"
|
||||||
|
|||||||
@ -105,9 +105,39 @@ tron_grid:
|
|||||||
usdt_contract_address: "TCSTWxGagPpCS8RaZeBXMW9d69gUsK9QGb"
|
usdt_contract_address: "TCSTWxGagPpCS8RaZeBXMW9d69gUsK9QGb"
|
||||||
http_timeout: "10s"
|
http_timeout: "10s"
|
||||||
|
|
||||||
|
bsc_usdt:
|
||||||
|
enabled: false
|
||||||
|
rpc_url: "https://bsc-dataseed.binance.org"
|
||||||
|
usdt_contract_address: "0x55d398326f99059fF775485246999027B3197955"
|
||||||
|
http_timeout: "10s"
|
||||||
|
|
||||||
|
bian:
|
||||||
|
enabled: false
|
||||||
|
api_base_url: "https://api.binance.com"
|
||||||
|
recv_window: 10000
|
||||||
|
http_timeout: "10s"
|
||||||
|
lalu:
|
||||||
|
api_key: ""
|
||||||
|
api_secret_key: ""
|
||||||
|
aslan:
|
||||||
|
api_key: ""
|
||||||
|
api_secret_key: ""
|
||||||
|
yumi:
|
||||||
|
api_key: ""
|
||||||
|
api_secret_key: ""
|
||||||
|
|
||||||
external_recharge:
|
external_recharge:
|
||||||
usdt_trc20_enabled: true
|
usdt_trc20_enabled: true
|
||||||
usdt_trc20_address: ""
|
usdt_trc20_address: ""
|
||||||
|
usdt_trc20_addresses:
|
||||||
|
lalu: ""
|
||||||
|
aslan: ""
|
||||||
|
yumi: ""
|
||||||
|
usdt_bep20_enabled: false
|
||||||
|
usdt_bep20_address: ""
|
||||||
|
usdt_bep20_addresses:
|
||||||
|
aslan: ""
|
||||||
|
yumi: ""
|
||||||
mifapay_notify_url: ""
|
mifapay_notify_url: ""
|
||||||
mifapay_return_url: ""
|
mifapay_return_url: ""
|
||||||
v5pay_notify_url: "https://api-test.global-interaction.com/api/v1/payment/v5pay/notify"
|
v5pay_notify_url: "https://api-test.global-interaction.com/api/v1/payment/v5pay/notify"
|
||||||
|
|||||||
@ -104,9 +104,39 @@ tron_grid:
|
|||||||
usdt_contract_address: "TCSTWxGagPpCS8RaZeBXMW9d69gUsK9QGb"
|
usdt_contract_address: "TCSTWxGagPpCS8RaZeBXMW9d69gUsK9QGb"
|
||||||
http_timeout: "10s"
|
http_timeout: "10s"
|
||||||
|
|
||||||
|
bsc_usdt:
|
||||||
|
enabled: false
|
||||||
|
rpc_url: "https://bsc-dataseed.binance.org"
|
||||||
|
usdt_contract_address: "0x55d398326f99059fF775485246999027B3197955"
|
||||||
|
http_timeout: "10s"
|
||||||
|
|
||||||
|
bian:
|
||||||
|
enabled: false
|
||||||
|
api_base_url: "https://api.binance.com"
|
||||||
|
recv_window: 10000
|
||||||
|
http_timeout: "10s"
|
||||||
|
lalu:
|
||||||
|
api_key: ""
|
||||||
|
api_secret_key: ""
|
||||||
|
aslan:
|
||||||
|
api_key: ""
|
||||||
|
api_secret_key: ""
|
||||||
|
yumi:
|
||||||
|
api_key: ""
|
||||||
|
api_secret_key: ""
|
||||||
|
|
||||||
external_recharge:
|
external_recharge:
|
||||||
usdt_trc20_enabled: true
|
usdt_trc20_enabled: true
|
||||||
usdt_trc20_address: "TRC20_PLATFORM_RECEIVE_ADDRESS"
|
usdt_trc20_address: "TRC20_PLATFORM_RECEIVE_ADDRESS"
|
||||||
|
usdt_trc20_addresses:
|
||||||
|
lalu: "LALU_TRC20_PLATFORM_RECEIVE_ADDRESS"
|
||||||
|
aslan: "ASLAN_TRC20_PLATFORM_RECEIVE_ADDRESS"
|
||||||
|
yumi: "YUMI_TRC20_PLATFORM_RECEIVE_ADDRESS"
|
||||||
|
usdt_bep20_enabled: false
|
||||||
|
usdt_bep20_address: "BEP20_PLATFORM_RECEIVE_ADDRESS"
|
||||||
|
usdt_bep20_addresses:
|
||||||
|
aslan: "ASLAN_BEP20_PLATFORM_RECEIVE_ADDRESS"
|
||||||
|
yumi: "YUMI_BEP20_PLATFORM_RECEIVE_ADDRESS"
|
||||||
mifapay_notify_url: "https://api.global-interaction.com/api/v1/payment/mifapay/notify"
|
mifapay_notify_url: "https://api.global-interaction.com/api/v1/payment/mifapay/notify"
|
||||||
mifapay_return_url: "https://h5.global-interaction.com/recharge/index.html"
|
mifapay_return_url: "https://h5.global-interaction.com/recharge/index.html"
|
||||||
v5pay_notify_url: "https://api.global-interaction.com/api/v1/payment/v5pay/notify"
|
v5pay_notify_url: "https://api.global-interaction.com/api/v1/payment/v5pay/notify"
|
||||||
|
|||||||
@ -101,14 +101,40 @@ v5pay:
|
|||||||
tron_grid:
|
tron_grid:
|
||||||
enabled: true
|
enabled: true
|
||||||
api_base_url: "https://api.trongrid.io"
|
api_base_url: "https://api.trongrid.io"
|
||||||
usdt_contract_address: "TCSTWxGagPpCS8RaZeBXMW9d69gUsK9QGb"
|
usdt_contract_address: "TNwqCkF8cnM4ХiwjnbbKTfdZv FDaVoa1TN"
|
||||||
http_timeout: "10s"
|
http_timeout: "10s"
|
||||||
|
|
||||||
bian_api_secret_key: "hIOXHJ3dgIQQu0L5RVxxiPOltUP4gP3QAkhU2SzsCo9qhJvrkD2cF4BKMCqgFRHh"
|
|
||||||
bian_secret_key: "4TJPRJ0m6vVcwtpf4PjVTA9xRf2YZzYXIpaFsVLgTmE6JHo0ITpU7GD4QJolGWMB"
|
bsc_usdt:
|
||||||
|
enabled: false
|
||||||
|
rpc_url: "https://bsc-dataseed.binance.org"
|
||||||
|
usdt_contract_address: "TNwqCkF8cnM4ХiwjnbbKTfdZv FDaVoa1TN"
|
||||||
|
http_timeout: "10s"
|
||||||
|
|
||||||
|
bian:
|
||||||
|
enabled: true
|
||||||
|
api_base_url: "https://api.binance.com"
|
||||||
|
recv_window: 10000
|
||||||
|
http_timeout: "10s"
|
||||||
|
lalu:
|
||||||
|
api_key: "${HYAPP_WALLET_BINANCE_LALU_API_KEY}"
|
||||||
|
api_secret_key: "${HYAPP_WALLET_BINANCE_LALU_API_SECRET_KEY}"
|
||||||
|
aslan:
|
||||||
|
api_key: "${HYAPP_WALLET_BINANCE_ASLAN_API_KEY}"
|
||||||
|
api_secret_key: "${HYAPP_WALLET_BINANCE_ASLAN_API_SECRET_KEY}"
|
||||||
|
yumi:
|
||||||
|
api_key: "${HYAPP_WALLET_BINANCE_YUMI_API_KEY}"
|
||||||
|
api_secret_key: "${HYAPP_WALLET_BINANCE_YUMI_API_SECRET_KEY}"
|
||||||
|
|
||||||
external_recharge:
|
external_recharge:
|
||||||
usdt_trc20_enabled: true
|
usdt_trc20_enabled: true
|
||||||
usdt_trc20_address: "cccccccccccc"
|
usdt_trc20_address: "TNwqCkF8cnM4XiwjnbbKTfdZvFDqVoa1TN"
|
||||||
|
usdt_trc20_addresses:
|
||||||
|
lalu: "TNwqCkF8cnM4XiwjnbbKTfdZvFDqVoa1TN"
|
||||||
|
aslan: "TLv7wERsM42ZeobMrrKVC5C72d3gFUa44J"
|
||||||
|
yumi: "TCSTWxGagPpCS8RaZeBXMW9d69gUsK9QGb"
|
||||||
|
usdt_bep20_enabled: false
|
||||||
|
usdt_bep20_address: "TNwqCkF8cnM4ХiwjnbbKTfdZv FDaVoa1TN"
|
||||||
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_notify_url: "http://127.0.0.1:13000/api/v1/payment/v5pay/notify"
|
||||||
|
|||||||
@ -17,6 +17,8 @@ import (
|
|||||||
servicegrpc "hyapp/pkg/servicekit/grpcserver"
|
servicegrpc "hyapp/pkg/servicekit/grpcserver"
|
||||||
servicehealth "hyapp/pkg/servicekit/health"
|
servicehealth "hyapp/pkg/servicekit/health"
|
||||||
"hyapp/services/wallet-service/internal/client"
|
"hyapp/services/wallet-service/internal/client"
|
||||||
|
"hyapp/services/wallet-service/internal/client/binance"
|
||||||
|
"hyapp/services/wallet-service/internal/client/bscusdt"
|
||||||
"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"
|
||||||
@ -98,12 +100,16 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
server := servicegrpc.New("wallet-service")
|
server := servicegrpc.New("wallet-service")
|
||||||
svc := walletservice.New(repository, client.NewActivityAchievementClient(activityConn))
|
svc := walletservice.New(repository, client.NewActivityAchievementClient(activityConn))
|
||||||
svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{
|
svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{
|
||||||
USDTTRC20Enabled: cfg.ExternalRecharge.USDTTRC20Enabled,
|
USDTTRC20Enabled: cfg.ExternalRecharge.USDTTRC20Enabled,
|
||||||
USDTTRC20Address: cfg.ExternalRecharge.USDTTRC20Address,
|
USDTTRC20Address: cfg.ExternalRecharge.USDTTRC20Address,
|
||||||
MifaPayNotifyURL: cfg.ExternalRecharge.MifaPayNotifyURL,
|
USDTTRC20Addresses: cfg.ExternalRecharge.USDTTRC20Addresses,
|
||||||
MifaPayReturnURL: cfg.ExternalRecharge.MifaPayReturnURL,
|
USDTBEP20Enabled: cfg.ExternalRecharge.USDTBEP20Enabled,
|
||||||
V5PayNotifyURL: cfg.ExternalRecharge.V5PayNotifyURL,
|
USDTBEP20Address: cfg.ExternalRecharge.USDTBEP20Address,
|
||||||
V5PayReturnURL: cfg.ExternalRecharge.V5PayReturnURL,
|
USDTBEP20Addresses: cfg.ExternalRecharge.USDTBEP20Addresses,
|
||||||
|
MifaPayNotifyURL: cfg.ExternalRecharge.MifaPayNotifyURL,
|
||||||
|
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)
|
||||||
@ -143,6 +149,16 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
if cfg.TronGrid.Enabled {
|
if cfg.TronGrid.Enabled {
|
||||||
svc.SetTronUSDTClient(trongrid.New(cfg.TronGrid))
|
svc.SetTronUSDTClient(trongrid.New(cfg.TronGrid))
|
||||||
}
|
}
|
||||||
|
if cfg.BSCUSDT.Enabled {
|
||||||
|
svc.SetBSCUSDTClient(bscusdt.New(cfg.BSCUSDT))
|
||||||
|
}
|
||||||
|
if cfg.Binance.Enabled {
|
||||||
|
if cfg.Binance.HasAnyAccount() {
|
||||||
|
svc.SetBinanceClient(binance.New(cfg.Binance))
|
||||||
|
} else {
|
||||||
|
logx.Warn(context.Background(), "binance_enabled_without_credentials")
|
||||||
|
}
|
||||||
|
}
|
||||||
walletv1.RegisterWalletServiceServer(server, grpcserver.NewServer(svc))
|
walletv1.RegisterWalletServiceServer(server, grpcserver.NewServer(svc))
|
||||||
walletv1.RegisterWalletCronServiceServer(server, grpcserver.NewCronServer(svc))
|
walletv1.RegisterWalletCronServiceServer(server, grpcserver.NewCronServer(svc))
|
||||||
health, healthHTTP, err := servicehealth.New(server, servicehealth.Config{
|
health, healthHTTP, err := servicehealth.New(server, servicehealth.Config{
|
||||||
|
|||||||
360
services/wallet-service/internal/client/binance/client.go
Normal file
360
services/wallet-service/internal/client/binance/client.go
Normal file
@ -0,0 +1,360 @@
|
|||||||
|
package binance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
|
"hyapp/services/wallet-service/internal/config"
|
||||||
|
"hyapp/services/wallet-service/internal/service/wallet/ports"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultDepositLookback = 90 * 24 * time.Hour
|
||||||
|
depositHistoryLimit = 1000
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client 是 Binance 只读查单适配器;它只使用 signed read API,不保存、不生成任何链上私钥。
|
||||||
|
type Client struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
apiBaseURL string
|
||||||
|
recvWindow int64
|
||||||
|
accounts map[string]account
|
||||||
|
}
|
||||||
|
|
||||||
|
type account struct {
|
||||||
|
apiKey string
|
||||||
|
apiSecretKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// New 从 wallet-service 配置创建 Binance client;具体 App 是否有账号在请求时按 appCode 决定。
|
||||||
|
func New(cfg config.BinanceConfig) *Client {
|
||||||
|
timeout := cfg.HTTPTimeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
httpClient: &http.Client{Timeout: timeout},
|
||||||
|
apiBaseURL: strings.TrimRight(strings.TrimSpace(firstNonEmpty(cfg.APIBaseURL, "https://api.binance.com")), "/"),
|
||||||
|
recvWindow: firstPositiveInt64(cfg.RecvWindow, 10000),
|
||||||
|
accounts: map[string]account{
|
||||||
|
"lalu": newAccount(cfg.Lalu),
|
||||||
|
"aslan": newAccount(cfg.Aslan),
|
||||||
|
"yumi": newAccount(cfg.Yumi),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAccount(cfg config.BinanceAccountConfig) account {
|
||||||
|
return account{
|
||||||
|
apiKey: strings.TrimSpace(cfg.APIKey),
|
||||||
|
apiSecretKey: strings.TrimSpace(cfg.APISecretKey),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindUSDTDeposit 查询 Binance deposit history,并按后台订单输入校验币种、网络、状态、地址和金额。
|
||||||
|
func (c *Client) FindUSDTDeposit(ctx context.Context, req ports.BinanceDepositLookupRequest) (ports.BinanceDepositRecord, error) {
|
||||||
|
if c == nil || c.apiBaseURL == "" {
|
||||||
|
return ports.BinanceDepositRecord{}, xerr.New(xerr.Unavailable, "binance client is not configured")
|
||||||
|
}
|
||||||
|
account, ok := c.accountForApp(req.AppCode)
|
||||||
|
if !ok {
|
||||||
|
return ports.BinanceDepositRecord{}, xerr.New(xerr.Unavailable, "binance account is not configured")
|
||||||
|
}
|
||||||
|
req.ExternalID = strings.TrimSpace(req.ExternalID)
|
||||||
|
req.Coin = strings.ToUpper(strings.TrimSpace(req.Coin))
|
||||||
|
req.Network = strings.ToUpper(strings.TrimSpace(req.Network))
|
||||||
|
req.ToAddress = strings.TrimSpace(req.ToAddress)
|
||||||
|
if req.ExternalID == "" || req.Coin == "" || req.Network == "" || req.ToAddress == "" || req.AmountMinor <= 0 {
|
||||||
|
return ports.BinanceDepositRecord{}, xerr.New(xerr.InvalidArgument, "binance deposit verification request is incomplete")
|
||||||
|
}
|
||||||
|
serverTimeMS, err := c.serverTime(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return ports.BinanceDepositRecord{}, err
|
||||||
|
}
|
||||||
|
// Binance 的 deposit history 查询窗口有限;后台凭证校验只做近期有界查询,避免一次手工校验变成无界扫描。
|
||||||
|
records, err := c.depositHistory(ctx, account, req.Coin, serverTimeMS-int64(defaultDepositLookback/time.Millisecond), serverTimeMS)
|
||||||
|
if err != nil {
|
||||||
|
return ports.BinanceDepositRecord{}, err
|
||||||
|
}
|
||||||
|
return matchDepositRecord(req, records)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) accountForApp(appCode string) (account, bool) {
|
||||||
|
value, ok := c.accounts[strings.ToLower(strings.TrimSpace(appCode))]
|
||||||
|
if !ok || strings.TrimSpace(value.apiKey) == "" || strings.TrimSpace(value.apiSecretKey) == "" {
|
||||||
|
return account{}, false
|
||||||
|
}
|
||||||
|
return value, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) serverTime(ctx context.Context) (int64, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.apiBaseURL+"/api/v3/time", nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
body, err := c.do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
var parsed struct {
|
||||||
|
ServerTime int64 `json:"serverTime"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if parsed.ServerTime <= 0 {
|
||||||
|
return 0, xerr.New(xerr.Unavailable, "binance server time is invalid")
|
||||||
|
}
|
||||||
|
return parsed.ServerTime, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) depositHistory(ctx context.Context, account account, coin string, startTimeMS int64, endTimeMS int64) ([]depositRecord, error) {
|
||||||
|
query := url.Values{}
|
||||||
|
query.Set("coin", strings.ToUpper(strings.TrimSpace(coin)))
|
||||||
|
query.Set("startTime", strconv.FormatInt(startTimeMS, 10))
|
||||||
|
query.Set("endTime", strconv.FormatInt(endTimeMS, 10))
|
||||||
|
query.Set("limit", strconv.Itoa(depositHistoryLimit))
|
||||||
|
body, err := c.signedGET(ctx, account, "/sapi/v1/capital/deposit/hisrec", query, endTimeMS)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var raws []json.RawMessage
|
||||||
|
if err := json.Unmarshal(body, &raws); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
records := make([]depositRecord, 0, len(raws))
|
||||||
|
for _, raw := range raws {
|
||||||
|
var wire depositRecordWire
|
||||||
|
if err := json.Unmarshal(raw, &wire); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
record := wire.toRecord()
|
||||||
|
record.RawJSON = string(raw)
|
||||||
|
records = append(records, record)
|
||||||
|
}
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) signedGET(ctx context.Context, account account, path string, values url.Values, timestampMS int64) ([]byte, error) {
|
||||||
|
values.Set("recvWindow", strconv.FormatInt(c.recvWindow, 10))
|
||||||
|
values.Set("timestamp", strconv.FormatInt(timestampMS, 10))
|
||||||
|
queryString := values.Encode()
|
||||||
|
signature := signQueryString(queryString, account.apiSecretKey)
|
||||||
|
endpoint := c.apiBaseURL + path + "?" + queryString + "&signature=" + signature
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("X-MBX-APIKEY", account.apiKey)
|
||||||
|
return c.do(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) do(req *http.Request) ([]byte, error) {
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerr.New(xerr.Unavailable, "binance request failed")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, xerr.New(xerr.Unavailable, "binance returned non-success")
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchDepositRecord(req ports.BinanceDepositLookupRequest, records []depositRecord) (ports.BinanceDepositRecord, error) {
|
||||||
|
var candidate *depositRecord
|
||||||
|
for index := range records {
|
||||||
|
if depositTxIDMatches(records[index].TxID, req.ExternalID) {
|
||||||
|
candidate = &records[index]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if candidate == nil {
|
||||||
|
return ports.BinanceDepositRecord{}, xerr.New(xerr.NotFound, "binance deposit not found")
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(candidate.Coin, req.Coin) {
|
||||||
|
return ports.BinanceDepositRecord{}, xerr.New(xerr.Conflict, "binance deposit coin mismatch")
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(candidate.Network, req.Network) {
|
||||||
|
return ports.BinanceDepositRecord{}, xerr.New(xerr.Conflict, "binance deposit network mismatch")
|
||||||
|
}
|
||||||
|
if candidate.Status != 1 {
|
||||||
|
return ports.BinanceDepositRecord{}, xerr.New(xerr.Conflict, "binance deposit status is not successful")
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(candidate.Address), strings.TrimSpace(req.ToAddress)) {
|
||||||
|
return ports.BinanceDepositRecord{}, xerr.New(xerr.Conflict, "binance deposit address mismatch")
|
||||||
|
}
|
||||||
|
amountMinor, err := parseUSDTAmountMinor(candidate.Amount)
|
||||||
|
if err != nil {
|
||||||
|
return ports.BinanceDepositRecord{}, err
|
||||||
|
}
|
||||||
|
if amountMinor != req.AmountMinor {
|
||||||
|
return ports.BinanceDepositRecord{}, xerr.New(xerr.Conflict, "binance deposit amount mismatch")
|
||||||
|
}
|
||||||
|
return ports.BinanceDepositRecord{
|
||||||
|
ID: candidate.ID,
|
||||||
|
TxID: candidate.TxID,
|
||||||
|
Coin: candidate.Coin,
|
||||||
|
Network: candidate.Network,
|
||||||
|
Status: candidate.Status,
|
||||||
|
Address: candidate.Address,
|
||||||
|
Amount: candidate.Amount,
|
||||||
|
AmountMinor: amountMinor,
|
||||||
|
TransferType: candidate.TransferType,
|
||||||
|
InsertTimeMS: candidate.InsertTimeMS,
|
||||||
|
CompleteTimeMS: candidate.CompleteTimeMS,
|
||||||
|
ConfirmTimes: candidate.ConfirmTimes,
|
||||||
|
UnlockConfirm: candidate.UnlockConfirm,
|
||||||
|
WalletType: candidate.WalletType,
|
||||||
|
RawJSON: strings.TrimSpace(candidate.RawJSON),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func depositTxIDMatches(txID string, externalID string) bool {
|
||||||
|
normalizedExternalID := normalizeOffchainExternalID(externalID)
|
||||||
|
if normalizedExternalID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
normalizedTxID := strings.ToLower(strings.TrimSpace(txID))
|
||||||
|
return strings.Contains(normalizedTxID, normalizedExternalID) || normalizeOffchainExternalID(txID) == normalizedExternalID
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeOffchainExternalID(value string) string {
|
||||||
|
value = strings.ToLower(strings.TrimSpace(value))
|
||||||
|
value = strings.TrimPrefix(value, "off-chain transfer")
|
||||||
|
value = strings.TrimSpace(strings.Trim(value, ":#"))
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUSDTAmountMinor(value string) (int64, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" || strings.HasPrefix(value, "-") {
|
||||||
|
return 0, xerr.New(xerr.InvalidArgument, "binance deposit amount is invalid")
|
||||||
|
}
|
||||||
|
parts := strings.Split(value, ".")
|
||||||
|
if len(parts) > 2 {
|
||||||
|
return 0, xerr.New(xerr.InvalidArgument, "binance deposit amount is invalid")
|
||||||
|
}
|
||||||
|
whole, err := strconv.ParseInt(firstNonEmpty(parts[0], "0"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, xerr.New(xerr.InvalidArgument, "binance deposit amount is invalid")
|
||||||
|
}
|
||||||
|
frac := ""
|
||||||
|
if len(parts) == 2 {
|
||||||
|
frac = parts[1]
|
||||||
|
}
|
||||||
|
if len(frac) > 2 && strings.Trim(frac[2:], "0") != "" {
|
||||||
|
return 0, xerr.New(xerr.Conflict, "binance deposit amount has sub-cent precision")
|
||||||
|
}
|
||||||
|
if len(frac) > 2 {
|
||||||
|
frac = frac[:2]
|
||||||
|
}
|
||||||
|
for len(frac) < 2 {
|
||||||
|
frac += "0"
|
||||||
|
}
|
||||||
|
minorFrac, err := strconv.ParseInt(firstNonEmpty(frac, "0"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, xerr.New(xerr.InvalidArgument, "binance deposit amount is invalid")
|
||||||
|
}
|
||||||
|
return whole*100 + minorFrac, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func signQueryString(queryString string, secret string) string {
|
||||||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
_, _ = mac.Write([]byte(queryString))
|
||||||
|
return hex.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstPositiveInt64(values ...int64) int64 {
|
||||||
|
for _, value := range values {
|
||||||
|
if value > 0 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type depositRecord struct {
|
||||||
|
ID string
|
||||||
|
Amount string
|
||||||
|
Coin string
|
||||||
|
Network string
|
||||||
|
Status int64
|
||||||
|
Address string
|
||||||
|
TxID string
|
||||||
|
TransferType int64
|
||||||
|
InsertTimeMS int64
|
||||||
|
CompleteTimeMS int64
|
||||||
|
ConfirmTimes string
|
||||||
|
UnlockConfirm int64
|
||||||
|
WalletType int64
|
||||||
|
RawJSON string
|
||||||
|
}
|
||||||
|
|
||||||
|
type depositRecordWire struct {
|
||||||
|
ID json.RawMessage `json:"id"`
|
||||||
|
Amount string `json:"amount"`
|
||||||
|
Coin string `json:"coin"`
|
||||||
|
Network string `json:"network"`
|
||||||
|
Status int64 `json:"status"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
TxID string `json:"txId"`
|
||||||
|
TransferType int64 `json:"transferType"`
|
||||||
|
InsertTimeMS int64 `json:"insertTime"`
|
||||||
|
CompleteTimeMS int64 `json:"completeTime"`
|
||||||
|
ConfirmTimes string `json:"confirmTimes"`
|
||||||
|
UnlockConfirm int64 `json:"unlockConfirm"`
|
||||||
|
WalletType int64 `json:"walletType"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wire depositRecordWire) toRecord() depositRecord {
|
||||||
|
return depositRecord{
|
||||||
|
ID: rawScalarString(wire.ID),
|
||||||
|
Amount: strings.TrimSpace(wire.Amount),
|
||||||
|
Coin: strings.ToUpper(strings.TrimSpace(wire.Coin)),
|
||||||
|
Network: strings.ToUpper(strings.TrimSpace(wire.Network)),
|
||||||
|
Status: wire.Status,
|
||||||
|
Address: strings.TrimSpace(wire.Address),
|
||||||
|
TxID: strings.TrimSpace(wire.TxID),
|
||||||
|
TransferType: wire.TransferType,
|
||||||
|
InsertTimeMS: wire.InsertTimeMS,
|
||||||
|
CompleteTimeMS: wire.CompleteTimeMS,
|
||||||
|
ConfirmTimes: strings.TrimSpace(wire.ConfirmTimes),
|
||||||
|
UnlockConfirm: wire.UnlockConfirm,
|
||||||
|
WalletType: wire.WalletType,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rawScalarString(raw json.RawMessage) string {
|
||||||
|
if len(raw) == 0 || string(raw) == "null" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var text string
|
||||||
|
if err := json.Unmarshal(raw, &text); err == nil {
|
||||||
|
return strings.TrimSpace(text)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(raw))
|
||||||
|
}
|
||||||
171
services/wallet-service/internal/client/binance/client_test.go
Normal file
171
services/wallet-service/internal/client/binance/client_test.go
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
package binance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
|
"hyapp/services/wallet-service/internal/config"
|
||||||
|
"hyapp/services/wallet-service/internal/service/wallet/ports"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFindUSDTDepositMatchesOffchainTransferAndSignedRequest(t *testing.T) {
|
||||||
|
var sawDepositRequest bool
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/v3/time":
|
||||||
|
_, _ = w.Write([]byte(`{"serverTime":1783345000000}`))
|
||||||
|
case "/sapi/v1/capital/deposit/hisrec":
|
||||||
|
sawDepositRequest = true
|
||||||
|
if r.Header.Get("X-MBX-APIKEY") != "api-lalu" {
|
||||||
|
t.Fatalf("X-MBX-APIKEY mismatch")
|
||||||
|
}
|
||||||
|
values := r.URL.Query()
|
||||||
|
signature := values.Get("signature")
|
||||||
|
values.Del("signature")
|
||||||
|
if signature != signQueryString(values.Encode(), "secret-lalu") {
|
||||||
|
t.Fatalf("signature mismatch: query=%s signature=%s", values.Encode(), signature)
|
||||||
|
}
|
||||||
|
if values.Get("timestamp") != "1783345000000" || values.Get("recvWindow") != "10000" || values.Get("coin") != "USDT" || values.Get("limit") != "1000" {
|
||||||
|
t.Fatalf("signed deposit query mismatch: %s", values.Encode())
|
||||||
|
}
|
||||||
|
if values.Get("endTime") != "1783345000000" || values.Get("startTime") != "1775569000000" {
|
||||||
|
t.Fatalf("deposit bounded window mismatch: %s", values.Encode())
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`[{
|
||||||
|
"id":"deposit-1",
|
||||||
|
"amount":"500.00000000",
|
||||||
|
"coin":"USDT",
|
||||||
|
"network":"TRX",
|
||||||
|
"status":1,
|
||||||
|
"address":"TNwqCkF8cnM4XiwjnbbKTfdZvFDqVoa1TN",
|
||||||
|
"txId":"Off-chain transfer 388611987194",
|
||||||
|
"transferType":1,
|
||||||
|
"insertTime":1783344000000,
|
||||||
|
"completeTime":1783344025000,
|
||||||
|
"confirmTimes":"1/1",
|
||||||
|
"unlockConfirm":0,
|
||||||
|
"walletType":0
|
||||||
|
}]`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := New(config.BinanceConfig{
|
||||||
|
Enabled: true,
|
||||||
|
APIBaseURL: server.URL,
|
||||||
|
RecvWindow: 10000,
|
||||||
|
HTTPTimeout: time.Second,
|
||||||
|
Lalu: config.BinanceAccountConfig{
|
||||||
|
APIKey: "api-lalu",
|
||||||
|
APISecretKey: "secret-lalu",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
record, err := client.FindUSDTDeposit(context.Background(), ports.BinanceDepositLookupRequest{
|
||||||
|
AppCode: "lalu",
|
||||||
|
ExternalID: "388611987194",
|
||||||
|
Coin: "USDT",
|
||||||
|
Network: "TRX",
|
||||||
|
ToAddress: "TNwqCkF8cnM4XiwjnbbKTfdZvFDqVoa1TN",
|
||||||
|
AmountMinor: 50000,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindUSDTDeposit failed: %v", err)
|
||||||
|
}
|
||||||
|
if !sawDepositRequest {
|
||||||
|
t.Fatalf("deposit history endpoint was not called")
|
||||||
|
}
|
||||||
|
if record.TxID != "Off-chain transfer 388611987194" || record.AmountMinor != 50000 || record.Network != "TRX" || !strings.Contains(record.RawJSON, `"txId"`) {
|
||||||
|
t.Fatalf("deposit record mismatch: %+v", record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindUSDTDepositRejectsMismatches(t *testing.T) {
|
||||||
|
baseReq := ports.BinanceDepositLookupRequest{
|
||||||
|
AppCode: "lalu",
|
||||||
|
ExternalID: "388611987194",
|
||||||
|
Coin: "USDT",
|
||||||
|
Network: "TRX",
|
||||||
|
ToAddress: "TNwqCkF8cnM4XiwjnbbKTfdZvFDqVoa1TN",
|
||||||
|
AmountMinor: 50000,
|
||||||
|
}
|
||||||
|
baseRecord := depositRecord{
|
||||||
|
Amount: "500",
|
||||||
|
Coin: "USDT",
|
||||||
|
Network: "TRX",
|
||||||
|
Status: 1,
|
||||||
|
Address: "TNwqCkF8cnM4XiwjnbbKTfdZvFDqVoa1TN",
|
||||||
|
TxID: "Off-chain transfer 388611987194",
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*depositRecord)
|
||||||
|
message string
|
||||||
|
}{
|
||||||
|
{name: "status", mutate: func(r *depositRecord) { r.Status = 0 }, message: "status is not successful"},
|
||||||
|
{name: "network", mutate: func(r *depositRecord) { r.Network = "BSC" }, message: "network mismatch"},
|
||||||
|
{name: "address", mutate: func(r *depositRecord) { r.Address = "TDifferentAddress" }, message: "address mismatch"},
|
||||||
|
{name: "amount", mutate: func(r *depositRecord) { r.Amount = "499.99" }, message: "amount mismatch"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
record := baseRecord
|
||||||
|
tc.mutate(&record)
|
||||||
|
_, err := matchDepositRecord(baseReq, []depositRecord{record})
|
||||||
|
if !xerr.IsCode(err, xerr.Conflict) || !strings.Contains(err.Error(), tc.message) {
|
||||||
|
t.Fatalf("expected conflict %q, got %v", tc.message, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindUSDTDepositMissingAccountReturnsConfiguredError(t *testing.T) {
|
||||||
|
client := New(config.BinanceConfig{
|
||||||
|
Enabled: true,
|
||||||
|
APIBaseURL: "https://binance.invalid",
|
||||||
|
Lalu: config.BinanceAccountConfig{
|
||||||
|
APIKey: "api-lalu",
|
||||||
|
APISecretKey: "secret-lalu",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
_, err := client.FindUSDTDeposit(context.Background(), ports.BinanceDepositLookupRequest{
|
||||||
|
AppCode: "aslan",
|
||||||
|
ExternalID: "388611987194",
|
||||||
|
Coin: "USDT",
|
||||||
|
Network: "TRX",
|
||||||
|
ToAddress: "TNwqCkF8cnM4XiwjnbbKTfdZvFDqVoa1TN",
|
||||||
|
AmountMinor: 50000,
|
||||||
|
})
|
||||||
|
if !xerr.IsCode(err, xerr.Unavailable) || !strings.Contains(err.Error(), "binance account is not configured") {
|
||||||
|
t.Fatalf("missing account should be explicit, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDepositTxIDMatchesOffchainVariants(t *testing.T) {
|
||||||
|
if !depositTxIDMatches("Off-chain transfer 388611987194", "388611987194") {
|
||||||
|
t.Fatalf("plain off-chain id should match Binance txId")
|
||||||
|
}
|
||||||
|
if !depositTxIDMatches("Off-chain transfer 388611987194", "Off-chain transfer 388611987194") {
|
||||||
|
t.Fatalf("full off-chain txId should match itself")
|
||||||
|
}
|
||||||
|
if depositTxIDMatches("Off-chain transfer 388611987194", "388611987195") {
|
||||||
|
t.Fatalf("different off-chain id must not match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignQueryStringStable(t *testing.T) {
|
||||||
|
values := url.Values{}
|
||||||
|
values.Set("coin", "USDT")
|
||||||
|
values.Set("timestamp", "1783345000000")
|
||||||
|
signature := signQueryString(values.Encode(), "secret-lalu")
|
||||||
|
if signature != signQueryString("coin=USDT×tamp=1783345000000", "secret-lalu") {
|
||||||
|
t.Fatalf("signature must be based on encoded query string")
|
||||||
|
}
|
||||||
|
}
|
||||||
178
services/wallet-service/internal/client/bscusdt/client.go
Normal file
178
services/wallet-service/internal/client/bscusdt/client.go
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
package bscusdt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
|
"hyapp/services/wallet-service/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const transferEventTopic = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
|
||||||
|
|
||||||
|
// Client 通过 BSC JSON-RPC 校验 USDT-BEP20 Transfer 事件;它只读取公开 receipt,不触碰私钥。
|
||||||
|
type Client struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
rpcURL string
|
||||||
|
contractAddress string
|
||||||
|
}
|
||||||
|
|
||||||
|
// New 创建 BSC USDT 只读客户端。RPCURL 可以是自建节点、BscScan proxy 或兼容网关。
|
||||||
|
func New(cfg config.BSCUSDTConfig) *Client {
|
||||||
|
timeout := cfg.HTTPTimeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
httpClient: &http.Client{Timeout: timeout},
|
||||||
|
rpcURL: strings.TrimSpace(cfg.RPCURL),
|
||||||
|
contractAddress: normalizeEVMAddress(cfg.USDTContractAddress),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyUSDTTransfer 校验 tx_hash 已成功执行,且 USDT Transfer 足额转入平台共享地址。
|
||||||
|
func (c *Client) VerifyUSDTTransfer(ctx context.Context, txHash string, toAddress string, amountMinor int64) (string, error) {
|
||||||
|
if c == nil || c.rpcURL == "" {
|
||||||
|
return "", xerr.New(xerr.Unavailable, "bsc rpc client is not configured")
|
||||||
|
}
|
||||||
|
txHash = strings.TrimSpace(txHash)
|
||||||
|
toAddress = normalizeEVMAddress(toAddress)
|
||||||
|
if txHash == "" || toAddress == "" || amountMinor <= 0 {
|
||||||
|
return "", xerr.New(xerr.InvalidArgument, "bsc usdt transfer verification request is incomplete")
|
||||||
|
}
|
||||||
|
// 订单金额以 USD cent 保存,USDT 使用 6 位精度;1 cent = 10,000 atomic units。
|
||||||
|
requiredAtomicAmount := big.NewInt(amountMinor)
|
||||||
|
requiredAtomicAmount.Mul(requiredAtomicAmount, big.NewInt(10_000))
|
||||||
|
receipt, rawJSON, err := c.transactionReceipt(ctx, txHash)
|
||||||
|
if err != nil {
|
||||||
|
return rawJSON, err
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(receipt.Status), "0x1") {
|
||||||
|
return rawJSON, xerr.New(xerr.Conflict, "bsc transaction is not successful")
|
||||||
|
}
|
||||||
|
for _, log := range receipt.Logs {
|
||||||
|
if !strings.EqualFold(normalizeEVMAddress(log.Address), c.contractAddress) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(log.Topics) < 3 || !strings.EqualFold(strings.TrimSpace(log.Topics[0]), transferEventTopic) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if topicAddress(log.Topics[2]) != toAddress {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
amount, ok := parseHexBigInt(log.Data)
|
||||||
|
if !ok || amount.Cmp(requiredAtomicAmount) < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return rawJSON, nil
|
||||||
|
}
|
||||||
|
return rawJSON, xerr.New(xerr.Conflict, "bsc usdt transfer does not match order")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) transactionReceipt(ctx context.Context, txHash string) (ethReceipt, string, error) {
|
||||||
|
body, err := json.Marshal(jsonRPCRequest{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: 1,
|
||||||
|
Method: "eth_getTransactionReceipt",
|
||||||
|
Params: []string{txHash},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return ethReceipt{}, "", err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.rpcURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return ethReceipt{}, "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return ethReceipt{}, "", xerr.New(xerr.Unavailable, "bsc rpc request failed")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||||
|
if err != nil {
|
||||||
|
return ethReceipt{}, "", err
|
||||||
|
}
|
||||||
|
rawJSON := string(raw)
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return ethReceipt{}, rawJSON, xerr.New(xerr.Unavailable, "bsc rpc returned non-success")
|
||||||
|
}
|
||||||
|
var parsed struct {
|
||||||
|
Result *ethReceipt `json:"result"`
|
||||||
|
Error *struct {
|
||||||
|
Code int64 `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||||
|
return ethReceipt{}, rawJSON, err
|
||||||
|
}
|
||||||
|
if parsed.Error != nil {
|
||||||
|
return ethReceipt{}, rawJSON, xerr.New(xerr.Unavailable, "bsc rpc error: "+strings.TrimSpace(parsed.Error.Message))
|
||||||
|
}
|
||||||
|
if parsed.Result == nil {
|
||||||
|
return ethReceipt{}, rawJSON, xerr.New(xerr.Conflict, "bsc transaction receipt not found")
|
||||||
|
}
|
||||||
|
return *parsed.Result, rawJSON, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeEVMAddress(value string) string {
|
||||||
|
value = strings.ToLower(strings.TrimSpace(value))
|
||||||
|
if strings.HasPrefix(value, "0x") && len(value) == 42 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func topicAddress(topic string) string {
|
||||||
|
topic = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(topic)), "0x")
|
||||||
|
if len(topic) < 40 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "0x" + topic[len(topic)-40:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseHexBigInt(value string) (*big.Int, bool) {
|
||||||
|
value = strings.TrimPrefix(strings.TrimSpace(value), "0x")
|
||||||
|
if value == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if _, err := hex.DecodeString(padEvenHex(value)); err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
amount := new(big.Int)
|
||||||
|
if _, ok := amount.SetString(value, 16); !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return amount, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func padEvenHex(value string) string {
|
||||||
|
if len(value)%2 == 0 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return "0" + value
|
||||||
|
}
|
||||||
|
|
||||||
|
type jsonRPCRequest struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Params []string `json:"params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ethReceipt struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Logs []struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Topics []string `json:"topics"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
} `json:"logs"`
|
||||||
|
}
|
||||||
@ -0,0 +1,87 @@
|
|||||||
|
package bscusdt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hyapp/services/wallet-service/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVerifyUSDTTransferMatchesBEP20ReceiptLog(t *testing.T) {
|
||||||
|
txHash := "0x" + strings.Repeat("1", 64)
|
||||||
|
contract := "0x55d398326f99059fF775485246999027B3197955"
|
||||||
|
toAddress := "0x1111111111111111111111111111111111111111"
|
||||||
|
valueData := fmt.Sprintf("0x%064x", int64(150*10_000))
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Fatalf("expected JSON-RPC POST, got %s", r.Method)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(fmt.Sprintf(`{
|
||||||
|
"jsonrpc":"2.0",
|
||||||
|
"id":1,
|
||||||
|
"result":{
|
||||||
|
"status":"0x1",
|
||||||
|
"logs":[{
|
||||||
|
"address":%q,
|
||||||
|
"topics":[%q,"0x0000000000000000000000002222222222222222222222222222222222222222",%q],
|
||||||
|
"data":%q
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}`, contract, transferEventTopic, addressTopic(toAddress), valueData)))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := New(config.BSCUSDTConfig{
|
||||||
|
RPCURL: server.URL,
|
||||||
|
USDTContractAddress: contract,
|
||||||
|
HTTPTimeout: time.Second,
|
||||||
|
})
|
||||||
|
rawJSON, err := client.VerifyUSDTTransfer(context.Background(), txHash, toAddress, 150)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyUSDTTransfer failed: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rawJSON, `"status":"0x1"`) {
|
||||||
|
t.Fatalf("raw json should preserve provider response, got %s", rawJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyUSDTTransferRejectsReceiptAmountMismatch(t *testing.T) {
|
||||||
|
txHash := "0x" + strings.Repeat("2", 64)
|
||||||
|
contract := "0x55d398326f99059fF775485246999027B3197955"
|
||||||
|
toAddress := "0x1111111111111111111111111111111111111111"
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(fmt.Sprintf(`{
|
||||||
|
"jsonrpc":"2.0",
|
||||||
|
"id":1,
|
||||||
|
"result":{
|
||||||
|
"status":"0x1",
|
||||||
|
"logs":[{
|
||||||
|
"address":%q,
|
||||||
|
"topics":[%q,"0x0000000000000000000000002222222222222222222222222222222222222222",%q],
|
||||||
|
"data":"0x1"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}`, contract, transferEventTopic, addressTopic(toAddress))))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
client := New(config.BSCUSDTConfig{
|
||||||
|
RPCURL: server.URL,
|
||||||
|
USDTContractAddress: contract,
|
||||||
|
HTTPTimeout: time.Second,
|
||||||
|
})
|
||||||
|
rawJSON, err := client.VerifyUSDTTransfer(context.Background(), txHash, toAddress, 150)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected amount mismatch to be rejected")
|
||||||
|
}
|
||||||
|
if !strings.Contains(rawJSON, `"logs"`) {
|
||||||
|
t.Fatalf("raw json should still be returned on semantic mismatch, got %s", rawJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addressTopic(address string) string {
|
||||||
|
return "0x000000000000000000000000" + strings.TrimPrefix(strings.ToLower(address), "0x")
|
||||||
|
}
|
||||||
@ -2,6 +2,8 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -33,6 +35,10 @@ type Config struct {
|
|||||||
V5Pay V5PayConfig `yaml:"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"`
|
||||||
|
// BSCUSDT 控制 USDT-BEP20 tx_hash 链上只读校验;默认关闭,避免生产误依赖公共 RPC。
|
||||||
|
BSCUSDT BSCUSDTConfig `yaml:"bsc_usdt"`
|
||||||
|
// Binance 控制 Binance 只读查单;配置键仍兼容现有 bian.lalu/aslan/yumi。
|
||||||
|
Binance BinanceConfig `yaml:"bian"`
|
||||||
// ExternalRecharge 控制 H5 外部充值公开地址和回跳地址。
|
// ExternalRecharge 控制 H5 外部充值公开地址和回跳地址。
|
||||||
ExternalRecharge ExternalRechargeConfig `yaml:"external_recharge"`
|
ExternalRecharge ExternalRechargeConfig `yaml:"external_recharge"`
|
||||||
// ExternalRechargeReconcileWorker 控制三方支付回调丢失后的服务端查单补偿。
|
// ExternalRechargeReconcileWorker 控制三方支付回调丢失后的服务端查单补偿。
|
||||||
@ -173,14 +179,66 @@ type TronGridConfig struct {
|
|||||||
HTTPTimeout time.Duration `yaml:"http_timeout"`
|
HTTPTimeout time.Duration `yaml:"http_timeout"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BSCUSDTConfig 保存 BSC JSON-RPC 只读配置;不包含私钥,也不发起转账。
|
||||||
|
type BSCUSDTConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
RPCURL string `yaml:"rpc_url"`
|
||||||
|
USDTContractAddress string `yaml:"usdt_contract_address"`
|
||||||
|
HTTPTimeout time.Duration `yaml:"http_timeout"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BinanceConfig 保存 Binance 只读查询配置;不同 App 独立 key,避免跨账套查错收款记录。
|
||||||
|
type BinanceConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
APIBaseURL string `yaml:"api_base_url"`
|
||||||
|
RecvWindow int64 `yaml:"recv_window"`
|
||||||
|
HTTPTimeout time.Duration `yaml:"http_timeout"`
|
||||||
|
Lalu BinanceAccountConfig `yaml:"lalu"`
|
||||||
|
Aslan BinanceAccountConfig `yaml:"aslan"`
|
||||||
|
Yumi BinanceAccountConfig `yaml:"yumi"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BinanceAccountConfig 是单个 Binance 账号的只读 API 凭证;日志和错误都不能输出这些字段。
|
||||||
|
type BinanceAccountConfig struct {
|
||||||
|
APIKey string `yaml:"api_key"`
|
||||||
|
APISecretKey string `yaml:"api_secret_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Account 按 appCode 选择对应 Binance 账号;未知 app 明确返回 false,避免默认落到 lalu。
|
||||||
|
func (cfg BinanceConfig) Account(appCode string) (BinanceAccountConfig, bool) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(appCode)) {
|
||||||
|
case "lalu":
|
||||||
|
return cfg.Lalu, binanceAccountReady(cfg.Lalu)
|
||||||
|
case "aslan":
|
||||||
|
return cfg.Aslan, binanceAccountReady(cfg.Aslan)
|
||||||
|
case "yumi":
|
||||||
|
return cfg.Yumi, binanceAccountReady(cfg.Yumi)
|
||||||
|
default:
|
||||||
|
return BinanceAccountConfig{}, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasAnyAccount 用于启动装配判断是否需要注入 Binance client;缺失某个 App key 时仍让业务返回明确配置错误。
|
||||||
|
func (cfg BinanceConfig) HasAnyAccount() bool {
|
||||||
|
return binanceAccountReady(cfg.Lalu) || binanceAccountReady(cfg.Aslan) || binanceAccountReady(cfg.Yumi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func binanceAccountReady(account BinanceAccountConfig) bool {
|
||||||
|
return strings.TrimSpace(account.APIKey) != "" && strings.TrimSpace(account.APISecretKey) != ""
|
||||||
|
}
|
||||||
|
|
||||||
// ExternalRechargeConfig 保存外部充值页面和支付回调所需的公开配置。
|
// ExternalRechargeConfig 保存外部充值页面和支付回调所需的公开配置。
|
||||||
type ExternalRechargeConfig struct {
|
type ExternalRechargeConfig struct {
|
||||||
USDTTRC20Enabled bool `yaml:"usdt_trc20_enabled"`
|
USDTTRC20Enabled bool `yaml:"usdt_trc20_enabled"`
|
||||||
USDTTRC20Address string `yaml:"usdt_trc20_address"`
|
USDTTRC20Address string `yaml:"usdt_trc20_address"`
|
||||||
MifaPayNotifyURL string `yaml:"mifapay_notify_url"`
|
USDTTRC20Addresses map[string]string `yaml:"usdt_trc20_addresses"`
|
||||||
MifaPayReturnURL string `yaml:"mifapay_return_url"`
|
USDTBEP20Enabled bool `yaml:"usdt_bep20_enabled"`
|
||||||
V5PayNotifyURL string `yaml:"v5pay_notify_url"`
|
USDTBEP20Address string `yaml:"usdt_bep20_address"`
|
||||||
V5PayReturnURL string `yaml:"v5pay_return_url"`
|
USDTBEP20Addresses map[string]string `yaml:"usdt_bep20_addresses"`
|
||||||
|
MifaPayNotifyURL string `yaml:"mifapay_notify_url"`
|
||||||
|
MifaPayReturnURL string `yaml:"mifapay_return_url"`
|
||||||
|
V5PayNotifyURL string `yaml:"v5pay_notify_url"`
|
||||||
|
V5PayReturnURL string `yaml:"v5pay_return_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExternalRechargeReconcileWorkerConfig 保存 H5 外部充值查单补偿策略。
|
// ExternalRechargeReconcileWorkerConfig 保存 H5 外部充值查单补偿策略。
|
||||||
@ -231,8 +289,21 @@ func Default() Config {
|
|||||||
USDTContractAddress: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
|
USDTContractAddress: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
|
||||||
HTTPTimeout: 10 * time.Second,
|
HTTPTimeout: 10 * time.Second,
|
||||||
},
|
},
|
||||||
|
BSCUSDT: BSCUSDTConfig{
|
||||||
|
Enabled: false,
|
||||||
|
RPCURL: "https://bsc-dataseed.binance.org",
|
||||||
|
USDTContractAddress: "0x55d398326f99059fF775485246999027B3197955",
|
||||||
|
HTTPTimeout: 10 * time.Second,
|
||||||
|
},
|
||||||
|
Binance: BinanceConfig{
|
||||||
|
Enabled: true,
|
||||||
|
APIBaseURL: "https://api.binance.com",
|
||||||
|
RecvWindow: 10000,
|
||||||
|
HTTPTimeout: 10 * time.Second,
|
||||||
|
},
|
||||||
ExternalRecharge: ExternalRechargeConfig{
|
ExternalRecharge: ExternalRechargeConfig{
|
||||||
USDTTRC20Enabled: true,
|
USDTTRC20Enabled: true,
|
||||||
|
USDTBEP20Enabled: false,
|
||||||
},
|
},
|
||||||
ExternalRechargeReconcileWorker: ExternalRechargeReconcileWorkerConfig{
|
ExternalRechargeReconcileWorker: ExternalRechargeReconcileWorkerConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
@ -303,7 +374,10 @@ func Load(path string) (Config, error) {
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := configx.LoadYAML(path, &cfg); err != nil {
|
if err := loadRuntimeEnv(path); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
if err := configx.LoadYAMLWithEnv(path, &cfg); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -380,7 +454,22 @@ func Load(path string) (Config, error) {
|
|||||||
if cfg.TronGrid.HTTPTimeout <= 0 {
|
if cfg.TronGrid.HTTPTimeout <= 0 {
|
||||||
cfg.TronGrid.HTTPTimeout = Default().TronGrid.HTTPTimeout
|
cfg.TronGrid.HTTPTimeout = Default().TronGrid.HTTPTimeout
|
||||||
}
|
}
|
||||||
|
cfg.BSCUSDT.RPCURL = strings.TrimRight(strings.TrimSpace(cfg.BSCUSDT.RPCURL), "/")
|
||||||
|
if cfg.BSCUSDT.RPCURL == "" {
|
||||||
|
cfg.BSCUSDT.RPCURL = Default().BSCUSDT.RPCURL
|
||||||
|
}
|
||||||
|
cfg.BSCUSDT.USDTContractAddress = strings.TrimSpace(cfg.BSCUSDT.USDTContractAddress)
|
||||||
|
if cfg.BSCUSDT.USDTContractAddress == "" {
|
||||||
|
cfg.BSCUSDT.USDTContractAddress = Default().BSCUSDT.USDTContractAddress
|
||||||
|
}
|
||||||
|
if cfg.BSCUSDT.HTTPTimeout <= 0 {
|
||||||
|
cfg.BSCUSDT.HTTPTimeout = Default().BSCUSDT.HTTPTimeout
|
||||||
|
}
|
||||||
|
cfg.Binance = normalizeBinanceConfig(cfg.Binance)
|
||||||
cfg.ExternalRecharge.USDTTRC20Address = strings.TrimSpace(cfg.ExternalRecharge.USDTTRC20Address)
|
cfg.ExternalRecharge.USDTTRC20Address = strings.TrimSpace(cfg.ExternalRecharge.USDTTRC20Address)
|
||||||
|
cfg.ExternalRecharge.USDTTRC20Addresses = normalizeExternalRechargeAddressMap(cfg.ExternalRecharge.USDTTRC20Addresses)
|
||||||
|
cfg.ExternalRecharge.USDTBEP20Address = strings.TrimSpace(cfg.ExternalRecharge.USDTBEP20Address)
|
||||||
|
cfg.ExternalRecharge.USDTBEP20Addresses = normalizeExternalRechargeAddressMap(cfg.ExternalRecharge.USDTBEP20Addresses)
|
||||||
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.V5PayNotifyURL = strings.TrimSpace(cfg.ExternalRecharge.V5PayNotifyURL)
|
||||||
@ -423,6 +512,52 @@ func Load(path string) (Config, error) {
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadRuntimeEnv(configPath string) error {
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
for _, start := range runtimeEnvSearchRoots(configPath) {
|
||||||
|
for _, path := range dotEnvCandidates(start) {
|
||||||
|
if _, ok := seen[path]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[path] = struct{}{}
|
||||||
|
if err := configx.LoadDotEnvFile(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runtimeEnvSearchRoots(configPath string) []string {
|
||||||
|
roots := make([]string, 0, 2)
|
||||||
|
if cwd, err := os.Getwd(); err == nil {
|
||||||
|
roots = append(roots, cwd)
|
||||||
|
}
|
||||||
|
if absConfigPath, err := filepath.Abs(configPath); err == nil {
|
||||||
|
roots = append(roots, filepath.Dir(absConfigPath))
|
||||||
|
}
|
||||||
|
return roots
|
||||||
|
}
|
||||||
|
|
||||||
|
func dotEnvCandidates(start string) []string {
|
||||||
|
start, err := filepath.Abs(start)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
candidates := make([]string, 0, 4)
|
||||||
|
for {
|
||||||
|
candidates = append(candidates, filepath.Join(start, ".env"))
|
||||||
|
if _, err := os.Stat(filepath.Join(start, ".git")); err == nil {
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(start)
|
||||||
|
if parent == start {
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
start = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeExternalRechargeReconcileWorkerConfig(cfg ExternalRechargeReconcileWorkerConfig, defaults ExternalRechargeReconcileWorkerConfig) ExternalRechargeReconcileWorkerConfig {
|
func normalizeExternalRechargeReconcileWorkerConfig(cfg ExternalRechargeReconcileWorkerConfig, defaults ExternalRechargeReconcileWorkerConfig) ExternalRechargeReconcileWorkerConfig {
|
||||||
if cfg.AppCode = strings.TrimSpace(cfg.AppCode); cfg.AppCode == "" {
|
if cfg.AppCode = strings.TrimSpace(cfg.AppCode); cfg.AppCode == "" {
|
||||||
cfg.AppCode = defaults.AppCode
|
cfg.AppCode = defaults.AppCode
|
||||||
@ -492,6 +627,49 @@ func normalizeV5PayConfig(cfg V5PayConfig) V5PayConfig {
|
|||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeBinanceConfig(cfg BinanceConfig) BinanceConfig {
|
||||||
|
defaults := Default().Binance
|
||||||
|
cfg.APIBaseURL = strings.TrimRight(strings.TrimSpace(cfg.APIBaseURL), "/")
|
||||||
|
if cfg.APIBaseURL == "" {
|
||||||
|
cfg.APIBaseURL = defaults.APIBaseURL
|
||||||
|
}
|
||||||
|
if cfg.RecvWindow <= 0 {
|
||||||
|
cfg.RecvWindow = defaults.RecvWindow
|
||||||
|
}
|
||||||
|
if cfg.HTTPTimeout <= 0 {
|
||||||
|
cfg.HTTPTimeout = defaults.HTTPTimeout
|
||||||
|
}
|
||||||
|
cfg.Lalu = normalizeBinanceAccountConfig(cfg.Lalu)
|
||||||
|
cfg.Aslan = normalizeBinanceAccountConfig(cfg.Aslan)
|
||||||
|
cfg.Yumi = normalizeBinanceAccountConfig(cfg.Yumi)
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeBinanceAccountConfig(account BinanceAccountConfig) BinanceAccountConfig {
|
||||||
|
account.APIKey = strings.TrimSpace(account.APIKey)
|
||||||
|
account.APISecretKey = strings.TrimSpace(account.APISecretKey)
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeExternalRechargeAddressMap(values map[string]string) map[string]string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
normalized := make(map[string]string, len(values))
|
||||||
|
for appCode, address := range values {
|
||||||
|
appCode = strings.ToLower(strings.TrimSpace(appCode))
|
||||||
|
address = strings.TrimSpace(address)
|
||||||
|
if appCode == "" || address == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
normalized[appCode] = address
|
||||||
|
}
|
||||||
|
if len(normalized) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
func applyV5PayEndpointConfig(cfg V5PayConfig, endpoint V5PayEndpointConfig) V5PayConfig {
|
func applyV5PayEndpointConfig(cfg V5PayConfig, endpoint V5PayEndpointConfig) V5PayConfig {
|
||||||
if value := firstNonEmpty(strings.TrimSpace(endpoint.MerchantNo), strings.TrimSpace(endpoint.MerNo)); value != "" {
|
if value := firstNonEmpty(strings.TrimSpace(endpoint.MerchantNo), strings.TrimSpace(endpoint.MerNo)); value != "" {
|
||||||
cfg.MerchantNo = value
|
cfg.MerchantNo = value
|
||||||
|
|||||||
@ -26,6 +26,9 @@ func TestLoadLocalEnablesOutboxMQ(t *testing.T) {
|
|||||||
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)
|
||||||
}
|
}
|
||||||
|
if cfg.BSCUSDT.Enabled || cfg.ExternalRecharge.USDTBEP20Enabled {
|
||||||
|
t.Fatalf("bsc usdt receipt verification must stay disabled by default: bsc=%+v external=%+v", cfg.BSCUSDT, cfg.ExternalRecharge)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadTencentExampleSplitsRealtimeOutboxAndRaisesThroughput(t *testing.T) {
|
func TestLoadTencentExampleSplitsRealtimeOutboxAndRaisesThroughput(t *testing.T) {
|
||||||
@ -50,6 +53,46 @@ func TestLoadTencentExampleSplitsRealtimeOutboxAndRaisesThroughput(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBinanceConfigDefaultsAndAccountSelection(t *testing.T) {
|
||||||
|
cfg := normalizeBinanceConfig(BinanceConfig{
|
||||||
|
Lalu: BinanceAccountConfig{
|
||||||
|
APIKey: " lalu-key ",
|
||||||
|
APISecretKey: " lalu-secret ",
|
||||||
|
},
|
||||||
|
Aslan: BinanceAccountConfig{
|
||||||
|
APIKey: "aslan-key",
|
||||||
|
APISecretKey: "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if cfg.APIBaseURL != "https://api.binance.com" || cfg.RecvWindow != 10000 || cfg.HTTPTimeout == 0 {
|
||||||
|
t.Fatalf("binance defaults mismatch: %+v", cfg)
|
||||||
|
}
|
||||||
|
if !cfg.HasAnyAccount() {
|
||||||
|
t.Fatalf("binance should detect at least one complete account")
|
||||||
|
}
|
||||||
|
lalu, ok := cfg.Account(" LALU ")
|
||||||
|
if !ok || lalu.APIKey != "lalu-key" || lalu.APISecretKey != "lalu-secret" {
|
||||||
|
t.Fatalf("lalu account selection mismatch: ok=%v account=%+v", ok, lalu)
|
||||||
|
}
|
||||||
|
if _, ok := cfg.Account("aslan"); ok {
|
||||||
|
t.Fatalf("incomplete aslan account must not be selectable")
|
||||||
|
}
|
||||||
|
if _, ok := cfg.Account("unknown"); ok {
|
||||||
|
t.Fatalf("unknown app must not fall back to lalu")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExternalRechargeAddressMapNormalizesAppOverrides(t *testing.T) {
|
||||||
|
addresses := normalizeExternalRechargeAddressMap(map[string]string{
|
||||||
|
" ASLAN ": " TLv7wERsM42ZeobMrrKVC5C72d3gFUa44J ",
|
||||||
|
"": "TEmptyApp",
|
||||||
|
"yumi": "",
|
||||||
|
})
|
||||||
|
if len(addresses) != 1 || addresses["aslan"] != "TLv7wERsM42ZeobMrrKVC5C72d3gFUa44J" {
|
||||||
|
t.Fatalf("address map normalization mismatch: %+v", addresses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadMifaPayMerchantConfig(t *testing.T) {
|
func TestLoadMifaPayMerchantConfig(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
path string
|
path string
|
||||||
|
|||||||
@ -62,6 +62,8 @@ const (
|
|||||||
PaymentProviderV5Pay = "v5pay"
|
PaymentProviderV5Pay = "v5pay"
|
||||||
// PaymentProviderUSDTTRC20 是用户提交 TRC20 链上交易哈希的 USDT 充值渠道。
|
// PaymentProviderUSDTTRC20 是用户提交 TRC20 链上交易哈希的 USDT 充值渠道。
|
||||||
PaymentProviderUSDTTRC20 = "usdt_trc20"
|
PaymentProviderUSDTTRC20 = "usdt_trc20"
|
||||||
|
// PaymentProviderUSDTBEP20 是用户提交 BSC BEP20 链上交易哈希的 USDT 充值渠道。
|
||||||
|
PaymentProviderUSDTBEP20 = "usdt_bep20"
|
||||||
// ThirdPartyPaymentStatusActive 表示渠道或支付方式可用于 H5 下单。
|
// ThirdPartyPaymentStatusActive 表示渠道或支付方式可用于 H5 下单。
|
||||||
ThirdPartyPaymentStatusActive = "active"
|
ThirdPartyPaymentStatusActive = "active"
|
||||||
// ThirdPartyPaymentStatusDisabled 表示渠道或支付方式已被后台关闭。
|
// ThirdPartyPaymentStatusDisabled 表示渠道或支付方式已被后台关闭。
|
||||||
|
|||||||
@ -221,6 +221,36 @@ type H5RechargeOptions struct {
|
|||||||
USDTTRC20Address string
|
USDTTRC20Address string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VerifyCoinSellerRechargeReceiptCommand 是运营提交的币商充值凭证查询条件;校验只读,不创建订单、不入账。
|
||||||
|
type VerifyCoinSellerRechargeReceiptCommand struct {
|
||||||
|
RequestID string
|
||||||
|
AppCode string
|
||||||
|
ProviderCode string
|
||||||
|
ExternalOrderNo string
|
||||||
|
Chain string
|
||||||
|
USDMinorAmount int64
|
||||||
|
OrderDateMS int64
|
||||||
|
ProviderCountryCode string
|
||||||
|
ProviderCurrencyCode string
|
||||||
|
ProviderPayType string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CoinSellerRechargeReceiptVerification 是三方或链上返回的只读凭证快照。
|
||||||
|
type CoinSellerRechargeReceiptVerification struct {
|
||||||
|
Verified bool
|
||||||
|
ProviderCode string
|
||||||
|
ExternalOrderNo string
|
||||||
|
ProviderOrderID string
|
||||||
|
Status string
|
||||||
|
CurrencyCode string
|
||||||
|
ProviderAmountMinor int64
|
||||||
|
RawJSON string
|
||||||
|
Chain string
|
||||||
|
ReceiveAddress string
|
||||||
|
VerifiedAtMS int64
|
||||||
|
FailureReason string
|
||||||
|
}
|
||||||
|
|
||||||
// ListTemporaryExternalRechargeOrdersQuery 是后台临时支付链接列表的筛选条件。
|
// ListTemporaryExternalRechargeOrdersQuery 是后台临时支付链接列表的筛选条件。
|
||||||
type ListTemporaryExternalRechargeOrdersQuery struct {
|
type ListTemporaryExternalRechargeOrdersQuery struct {
|
||||||
AppCode string
|
AppCode string
|
||||||
@ -379,6 +409,8 @@ func NormalizePaymentProvider(provider string) string {
|
|||||||
return PaymentProviderV5Pay
|
return PaymentProviderV5Pay
|
||||||
case PaymentProviderUSDTTRC20:
|
case PaymentProviderUSDTTRC20:
|
||||||
return PaymentProviderUSDTTRC20
|
return PaymentProviderUSDTTRC20
|
||||||
|
case PaymentProviderUSDTBEP20:
|
||||||
|
return PaymentProviderUSDTBEP20
|
||||||
case PaymentProviderGooglePlay:
|
case PaymentProviderGooglePlay:
|
||||||
return PaymentProviderGooglePlay
|
return PaymentProviderGooglePlay
|
||||||
default:
|
default:
|
||||||
|
|||||||
@ -20,6 +20,12 @@ type V5PayClient = ports.V5PayClient
|
|||||||
// TronUSDTClient 是旧门面包暴露的 TRON USDT client 端口别名。
|
// TronUSDTClient 是旧门面包暴露的 TRON USDT client 端口别名。
|
||||||
type TronUSDTClient = ports.TronUSDTClient
|
type TronUSDTClient = ports.TronUSDTClient
|
||||||
|
|
||||||
|
// BSCUSDTClient 是旧门面包暴露的 BSC USDT client 端口别名。
|
||||||
|
type BSCUSDTClient = ports.BSCUSDTClient
|
||||||
|
|
||||||
|
// BinanceClient 是旧门面包暴露的 Binance 只读查单 client 端口别名。
|
||||||
|
type BinanceClient = ports.BinanceClient
|
||||||
|
|
||||||
type MifaPayCreateOrderRequest = ports.MifaPayCreateOrderRequest
|
type MifaPayCreateOrderRequest = ports.MifaPayCreateOrderRequest
|
||||||
type MifaPayCreateOrderResponse = ports.MifaPayCreateOrderResponse
|
type MifaPayCreateOrderResponse = ports.MifaPayCreateOrderResponse
|
||||||
type MifaPayQueryOrderRequest = ports.MifaPayQueryOrderRequest
|
type MifaPayQueryOrderRequest = ports.MifaPayQueryOrderRequest
|
||||||
@ -34,3 +40,6 @@ type V5PayNotifyData = ports.V5PayNotifyData
|
|||||||
type V5PayProductTypesRequest = ports.V5PayProductTypesRequest
|
type V5PayProductTypesRequest = ports.V5PayProductTypesRequest
|
||||||
type V5PayProductType = ports.V5PayProductType
|
type V5PayProductType = ports.V5PayProductType
|
||||||
type V5PayProductTypesResponse = ports.V5PayProductTypesResponse
|
type V5PayProductTypesResponse = ports.V5PayProductTypesResponse
|
||||||
|
|
||||||
|
type BinanceDepositLookupRequest = ports.BinanceDepositLookupRequest
|
||||||
|
type BinanceDepositRecord = ports.BinanceDepositRecord
|
||||||
|
|||||||
@ -2,23 +2,59 @@ package wallet
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExternalRechargeConfig 保存 H5 外部充值需要公开给业务链路的运行配置。
|
// ExternalRechargeConfig 保存 H5 外部充值需要公开给业务链路的运行配置。
|
||||||
type ExternalRechargeConfig struct {
|
type ExternalRechargeConfig struct {
|
||||||
USDTTRC20Enabled bool
|
USDTTRC20Enabled bool
|
||||||
USDTTRC20Address string
|
USDTTRC20Address string
|
||||||
MifaPayNotifyURL string
|
USDTTRC20Addresses map[string]string
|
||||||
MifaPayReturnURL string
|
USDTBEP20Enabled bool
|
||||||
V5PayNotifyURL string
|
USDTBEP20Address string
|
||||||
V5PayReturnURL string
|
USDTBEP20Addresses map[string]string
|
||||||
|
MifaPayNotifyURL string
|
||||||
|
MifaPayReturnURL string
|
||||||
|
V5PayNotifyURL string
|
||||||
|
V5PayReturnURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeExternalRechargeConfig(config ExternalRechargeConfig) ExternalRechargeConfig {
|
func normalizeExternalRechargeConfig(config ExternalRechargeConfig) ExternalRechargeConfig {
|
||||||
config.USDTTRC20Address = strings.TrimSpace(config.USDTTRC20Address)
|
config.USDTTRC20Address = strings.TrimSpace(config.USDTTRC20Address)
|
||||||
|
config.USDTTRC20Addresses = normalizeRuntimeRechargeAddressMap(config.USDTTRC20Addresses)
|
||||||
|
config.USDTBEP20Address = strings.TrimSpace(config.USDTBEP20Address)
|
||||||
|
config.USDTBEP20Addresses = normalizeRuntimeRechargeAddressMap(config.USDTBEP20Addresses)
|
||||||
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.V5PayNotifyURL = strings.TrimSpace(config.V5PayNotifyURL)
|
||||||
config.V5PayReturnURL = strings.TrimSpace(config.V5PayReturnURL)
|
config.V5PayReturnURL = strings.TrimSpace(config.V5PayReturnURL)
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (config ExternalRechargeConfig) usdtTRC20AddressForApp(app string) string {
|
||||||
|
return firstNonEmpty(config.USDTTRC20Addresses[appcode.Normalize(app)], config.USDTTRC20Address)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (config ExternalRechargeConfig) usdtBEP20AddressForApp(app string) string {
|
||||||
|
return firstNonEmpty(config.USDTBEP20Addresses[appcode.Normalize(app)], config.USDTBEP20Address)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRuntimeRechargeAddressMap(values map[string]string) map[string]string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
normalized := make(map[string]string, len(values))
|
||||||
|
for app, address := range values {
|
||||||
|
app = appcode.Normalize(app)
|
||||||
|
address = strings.TrimSpace(address)
|
||||||
|
if app == "" || address == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
normalized[app] = address
|
||||||
|
}
|
||||||
|
if len(normalized) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|||||||
@ -43,10 +43,11 @@ func (s *Service) CreateH5RechargeOrder(ctx context.Context, command ledger.Crea
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) createUSDTRechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand, product ledger.RechargeProduct) (ledger.ExternalRechargeOrder, error) {
|
func (s *Service) createUSDTRechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand, product ledger.RechargeProduct) (ledger.ExternalRechargeOrder, error) {
|
||||||
if !s.externalRecharge.USDTTRC20Enabled || s.externalRecharge.USDTTRC20Address == "" {
|
receiveAddress := s.externalRecharge.usdtTRC20AddressForApp(command.AppCode)
|
||||||
|
if !s.externalRecharge.USDTTRC20Enabled || receiveAddress == "" {
|
||||||
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "usdt trc20 recharge is not configured")
|
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "usdt trc20 recharge is not configured")
|
||||||
}
|
}
|
||||||
return s.repository.CreateExternalRechargeOrder(ctx, command, product, nil, amountMicroToUSDMinor(product.AmountMicro), s.externalRecharge.USDTTRC20Address)
|
return s.repository.CreateExternalRechargeOrder(ctx, command, product, nil, amountMicroToUSDMinor(product.AmountMicro), receiveAddress)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) createMifaPayRechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand, product ledger.RechargeProduct) (ledger.ExternalRechargeOrder, error) {
|
func (s *Service) createMifaPayRechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand, product ledger.RechargeProduct) (ledger.ExternalRechargeOrder, error) {
|
||||||
|
|||||||
@ -80,8 +80,9 @@ func (s *Service) ListH5RechargeOptions(ctx context.Context, query ledger.H5Rech
|
|||||||
}
|
}
|
||||||
options.PaymentMethods = s.filterV5PayRuntimeProductTypes(ctx, filtered)
|
options.PaymentMethods = s.filterV5PayRuntimeProductTypes(ctx, filtered)
|
||||||
}
|
}
|
||||||
options.USDTTRC20Enabled = s.externalRecharge.USDTTRC20Enabled && s.externalRecharge.USDTTRC20Address != ""
|
usdtTRC20Address := s.externalRecharge.usdtTRC20AddressForApp(query.AppCode)
|
||||||
options.USDTTRC20Address = s.externalRecharge.USDTTRC20Address
|
options.USDTTRC20Enabled = s.externalRecharge.USDTTRC20Enabled && usdtTRC20Address != ""
|
||||||
|
options.USDTTRC20Address = usdtTRC20Address
|
||||||
return options, nil
|
return options, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,413 @@
|
|||||||
|
package wallet
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
|
"hyapp/services/wallet-service/internal/domain/ledger"
|
||||||
|
"hyapp/services/wallet-service/internal/service/wallet/ports"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
mifaPayReceiptOrderDateLookbackDays = 30
|
||||||
|
v5PayReceiptCandidateLimit = 32
|
||||||
|
)
|
||||||
|
|
||||||
|
// VerifyCoinSellerRechargeReceipt 只读取三方支付商或链上公开数据,给运营返回可复核凭证快照。
|
||||||
|
func (s *Service) VerifyCoinSellerRechargeReceipt(ctx context.Context, command ledger.VerifyCoinSellerRechargeReceiptCommand) (ledger.CoinSellerRechargeReceiptVerification, error) {
|
||||||
|
command = normalizeVerifyCoinSellerRechargeReceiptCommand(command)
|
||||||
|
if err := validateVerifyCoinSellerRechargeReceiptCommand(command); err != nil {
|
||||||
|
return ledger.CoinSellerRechargeReceiptVerification{}, err
|
||||||
|
}
|
||||||
|
ctx = appcode.WithContext(ctx, command.AppCode)
|
||||||
|
switch command.ProviderCode {
|
||||||
|
case ledger.PaymentProviderMifaPay:
|
||||||
|
return s.verifyMifaPayRechargeReceipt(ctx, command)
|
||||||
|
case ledger.PaymentProviderV5Pay:
|
||||||
|
return s.verifyV5PayRechargeReceipt(ctx, command)
|
||||||
|
case ledger.PaymentProviderUSDTTRC20:
|
||||||
|
return s.verifyUSDTRechargeReceipt(ctx, command, "trc20", s.externalRecharge.USDTTRC20Enabled, s.externalRecharge.usdtTRC20AddressForApp(command.AppCode), s.tronUSDT)
|
||||||
|
case ledger.PaymentProviderUSDTBEP20:
|
||||||
|
return s.verifyUSDTRechargeReceipt(ctx, command, "bep20", s.externalRecharge.USDTBEP20Enabled, s.externalRecharge.usdtBEP20AddressForApp(command.AppCode), s.bscUSDT)
|
||||||
|
default:
|
||||||
|
return ledger.CoinSellerRechargeReceiptVerification{}, xerr.New(xerr.InvalidArgument, "receipt provider is invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeVerifyCoinSellerRechargeReceiptCommand(command ledger.VerifyCoinSellerRechargeReceiptCommand) ledger.VerifyCoinSellerRechargeReceiptCommand {
|
||||||
|
command.AppCode = appcode.Normalize(command.AppCode)
|
||||||
|
command.RequestID = strings.TrimSpace(command.RequestID)
|
||||||
|
command.ProviderCode = ledger.NormalizePaymentProvider(command.ProviderCode)
|
||||||
|
command.ExternalOrderNo = strings.TrimSpace(command.ExternalOrderNo)
|
||||||
|
command.Chain = normalizeReceiptChain(command.Chain)
|
||||||
|
command.ProviderCountryCode = strings.ToUpper(strings.TrimSpace(command.ProviderCountryCode))
|
||||||
|
command.ProviderCurrencyCode = strings.ToUpper(strings.TrimSpace(command.ProviderCurrencyCode))
|
||||||
|
command.ProviderPayType = strings.TrimSpace(command.ProviderPayType)
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateVerifyCoinSellerRechargeReceiptCommand(command ledger.VerifyCoinSellerRechargeReceiptCommand) error {
|
||||||
|
if command.ProviderCode == "" || command.ExternalOrderNo == "" {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "receipt verification request is incomplete")
|
||||||
|
}
|
||||||
|
if len(command.ExternalOrderNo) > 128 {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "external_order_no is too long")
|
||||||
|
}
|
||||||
|
switch command.ProviderCode {
|
||||||
|
case ledger.PaymentProviderUSDTTRC20, ledger.PaymentProviderUSDTBEP20:
|
||||||
|
if command.USDMinorAmount <= 0 {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "usdt receipt amount is invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) verifyMifaPayRechargeReceipt(ctx context.Context, command ledger.VerifyCoinSellerRechargeReceiptCommand) (ledger.CoinSellerRechargeReceiptVerification, error) {
|
||||||
|
if s.mifaPay == nil {
|
||||||
|
return ledger.CoinSellerRechargeReceiptVerification{}, xerr.New(xerr.Unavailable, "mifapay client is not configured")
|
||||||
|
}
|
||||||
|
snapshot := s.receiptSnapshot(command)
|
||||||
|
orderDateMS := command.OrderDateMS
|
||||||
|
if orderDateMS <= 0 {
|
||||||
|
// MiFaPay 查单需要订单日期;后台新流程不让运营填写日期,因此优先用本地支付订单快照补齐。
|
||||||
|
orderDateMS = s.externalRechargeOrderCreatedAtMS(ctx, command.AppCode, command.ExternalOrderNo, ledger.PaymentProviderMifaPay)
|
||||||
|
}
|
||||||
|
var lastNotFound string
|
||||||
|
for _, orderCreatedAtMS := range s.mifaPayReceiptOrderDateCandidates(orderDateMS) {
|
||||||
|
query, err := s.mifaPay.QueryOrder(ctx, MifaPayQueryOrderRequest{
|
||||||
|
OrderID: command.ExternalOrderNo,
|
||||||
|
OrderCreatedAtMS: orderCreatedAtMS,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if providerOrderNotFound(err) {
|
||||||
|
lastNotFound = err.Error()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 签名、HTTP、网络或未知 provider 拒绝都直接返回给后台;这些错误不能被日期回查吞掉。
|
||||||
|
snapshot.FailureReason = err.Error()
|
||||||
|
return snapshot, nil
|
||||||
|
}
|
||||||
|
return fillMifaPayReceiptSnapshot(snapshot, command, query), nil
|
||||||
|
}
|
||||||
|
if orderDateMS > 0 {
|
||||||
|
snapshot.FailureReason = firstNonEmpty(lastNotFound, "mifapay order not found")
|
||||||
|
} else {
|
||||||
|
snapshot.FailureReason = "mifapay order not found in recent 30 days"
|
||||||
|
}
|
||||||
|
return snapshot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fillMifaPayReceiptSnapshot(snapshot ledger.CoinSellerRechargeReceiptVerification, command ledger.VerifyCoinSellerRechargeReceiptCommand, query MifaPayQueryOrderResponse) ledger.CoinSellerRechargeReceiptVerification {
|
||||||
|
snapshot.ProviderOrderID = strings.TrimSpace(query.ProviderOrderID)
|
||||||
|
snapshot.Status = strings.TrimSpace(query.Status)
|
||||||
|
snapshot.CurrencyCode = strings.ToUpper(strings.TrimSpace(query.Currency))
|
||||||
|
snapshot.RawJSON = strings.TrimSpace(query.RawJSON)
|
||||||
|
if amount, err := strconv.ParseInt(strings.TrimSpace(query.Amount), 10, 64); err == nil {
|
||||||
|
snapshot.ProviderAmountMinor = amount
|
||||||
|
}
|
||||||
|
if query.OrderID != "" && !strings.EqualFold(query.OrderID, command.ExternalOrderNo) {
|
||||||
|
snapshot.FailureReason = "mifapay order id mismatch"
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
if !isMifaPayReceiptPaid(query.Status) {
|
||||||
|
snapshot.FailureReason = "mifapay order status " + firstNonEmpty(strings.TrimSpace(query.Status), "unknown")
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
if command.ProviderCurrencyCode != "" && snapshot.CurrencyCode != "" && !strings.EqualFold(snapshot.CurrencyCode, command.ProviderCurrencyCode) {
|
||||||
|
snapshot.FailureReason = "mifapay currency mismatch"
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
if command.ProviderPayType != "" && query.PayType != "" && !strings.EqualFold(query.PayType, command.ProviderPayType) {
|
||||||
|
snapshot.FailureReason = "mifapay pay_type mismatch"
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
snapshot.Verified = true
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) mifaPayReceiptOrderDateCandidates(orderDateMS int64) []int64 {
|
||||||
|
if orderDateMS > 0 {
|
||||||
|
return []int64{orderDateMS}
|
||||||
|
}
|
||||||
|
// 本地没有快照时只按 UTC 自然日做 30 天 bounded 回查,防止一次后台校验演变成无界 provider 扫描。
|
||||||
|
now := s.now().UTC()
|
||||||
|
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
candidates := make([]int64, 0, mifaPayReceiptOrderDateLookbackDays+1)
|
||||||
|
for dayOffset := 0; dayOffset <= mifaPayReceiptOrderDateLookbackDays; dayOffset++ {
|
||||||
|
candidates = append(candidates, startOfToday.AddDate(0, 0, -dayOffset).UnixMilli())
|
||||||
|
}
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) verifyV5PayRechargeReceipt(ctx context.Context, command ledger.VerifyCoinSellerRechargeReceiptCommand) (ledger.CoinSellerRechargeReceiptVerification, error) {
|
||||||
|
if s.v5Pay == nil {
|
||||||
|
return ledger.CoinSellerRechargeReceiptVerification{}, xerr.New(xerr.Unavailable, "v5pay client is not configured")
|
||||||
|
}
|
||||||
|
snapshot := s.receiptSnapshot(command)
|
||||||
|
candidates, err := s.v5PayReceiptQueryCandidates(ctx, command)
|
||||||
|
if err != nil {
|
||||||
|
return ledger.CoinSellerRechargeReceiptVerification{}, err
|
||||||
|
}
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
snapshot.FailureReason = "v5pay country/currency candidates are not configured"
|
||||||
|
return snapshot, nil
|
||||||
|
}
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
query, err := s.v5Pay.QueryOrder(ctx, V5PayQueryOrderRequest{
|
||||||
|
OrderID: command.ExternalOrderNo,
|
||||||
|
CountryCode: candidate.CountryCode,
|
||||||
|
CurrencyCode: candidate.CurrencyCode,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if providerOrderNotFound(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
snapshot.FailureReason = err.Error()
|
||||||
|
return snapshot, nil
|
||||||
|
}
|
||||||
|
return fillV5PayReceiptSnapshot(snapshot, command, query), nil
|
||||||
|
}
|
||||||
|
snapshot.FailureReason = "v5pay order not found for enabled country/currency candidates"
|
||||||
|
return snapshot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fillV5PayReceiptSnapshot(snapshot ledger.CoinSellerRechargeReceiptVerification, command ledger.VerifyCoinSellerRechargeReceiptCommand, query V5PayQueryOrderResponse) ledger.CoinSellerRechargeReceiptVerification {
|
||||||
|
snapshot.ProviderOrderID = strings.TrimSpace(query.ProviderOrderID)
|
||||||
|
snapshot.Status = strings.TrimSpace(query.Status)
|
||||||
|
snapshot.CurrencyCode = strings.ToUpper(strings.TrimSpace(query.Currency))
|
||||||
|
snapshot.RawJSON = strings.TrimSpace(query.RawJSON)
|
||||||
|
if amount, err := parseProviderAmountMinor(query.Amount); err == nil {
|
||||||
|
snapshot.ProviderAmountMinor = amount
|
||||||
|
}
|
||||||
|
if query.OrderID != "" && !strings.EqualFold(query.OrderID, command.ExternalOrderNo) {
|
||||||
|
snapshot.FailureReason = "v5pay order id mismatch"
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
if !isV5PayReceiptPaid(query.Status) {
|
||||||
|
snapshot.FailureReason = "v5pay order status " + firstNonEmpty(strings.TrimSpace(query.Status), "unknown")
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
if command.ProviderCurrencyCode != "" && snapshot.CurrencyCode != "" && !strings.EqualFold(snapshot.CurrencyCode, command.ProviderCurrencyCode) {
|
||||||
|
snapshot.FailureReason = "v5pay currency mismatch"
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
if command.ProviderPayType != "" && query.ProductType != "" && !strings.EqualFold(query.ProductType, command.ProviderPayType) {
|
||||||
|
snapshot.FailureReason = "v5pay product_type mismatch"
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
snapshot.Verified = true
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
type v5PayReceiptQueryCandidate struct {
|
||||||
|
CountryCode string
|
||||||
|
CurrencyCode string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) v5PayReceiptQueryCandidates(ctx context.Context, command ledger.VerifyCoinSellerRechargeReceiptCommand) ([]v5PayReceiptQueryCandidate, error) {
|
||||||
|
candidates := make([]v5PayReceiptQueryCandidate, 0, 4)
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
addCandidate := func(countryCode string, currencyCode string) {
|
||||||
|
if len(candidates) >= v5PayReceiptCandidateLimit {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
countryCode = strings.ToUpper(strings.TrimSpace(countryCode))
|
||||||
|
currencyCode = strings.ToUpper(strings.TrimSpace(currencyCode))
|
||||||
|
if countryCode == "" || currencyCode == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := countryCode + "|" + currencyCode
|
||||||
|
if _, ok := seen[key]; ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
candidates = append(candidates, v5PayReceiptQueryCandidate{CountryCode: countryCode, CurrencyCode: currencyCode})
|
||||||
|
}
|
||||||
|
|
||||||
|
if command.ProviderCountryCode != "" && command.ProviderCurrencyCode != "" {
|
||||||
|
addCandidate(command.ProviderCountryCode, command.ProviderCurrencyCode)
|
||||||
|
return candidates, nil
|
||||||
|
}
|
||||||
|
// V5Pay 查单需要国家/币种;运营只填订单号时,先从本地外部充值订单补条件,命中率最高且不会扩大查询面。
|
||||||
|
if order, ok := s.externalRechargeOrder(ctx, command.AppCode, command.ExternalOrderNo, ledger.PaymentProviderV5Pay); ok {
|
||||||
|
addCandidate(order.CountryCode, order.CurrencyCode)
|
||||||
|
}
|
||||||
|
if s.repository == nil {
|
||||||
|
return candidates, nil
|
||||||
|
}
|
||||||
|
channels, err := s.repository.ListThirdPartyPaymentChannels(ctx, ledger.ListThirdPartyPaymentChannelsQuery{
|
||||||
|
AppCode: command.AppCode,
|
||||||
|
ProviderCode: ledger.PaymentProviderV5Pay,
|
||||||
|
Status: ledger.ThirdPartyPaymentStatusActive,
|
||||||
|
IncludeDisabledMethods: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// 本地快照缺失时,只枚举当前 APP 启用的 V5Pay 国家/币种组合,并受候选上限保护,避免误配导致查单风暴。
|
||||||
|
for _, preferProvidedFields := range []bool{true, false} {
|
||||||
|
for _, channel := range channels {
|
||||||
|
if channel.ProviderCode != ledger.PaymentProviderV5Pay || channel.Status != ledger.ThirdPartyPaymentStatusActive {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, method := range channel.Methods {
|
||||||
|
if method.ProviderCode != ledger.PaymentProviderV5Pay || method.Status != ledger.ThirdPartyPaymentStatusActive {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matchesProvidedCountry := command.ProviderCountryCode == "" || strings.EqualFold(method.CountryCode, command.ProviderCountryCode)
|
||||||
|
matchesProvidedCurrency := command.ProviderCurrencyCode == "" || strings.EqualFold(method.CurrencyCode, command.ProviderCurrencyCode)
|
||||||
|
if preferProvidedFields != (matchesProvidedCountry && matchesProvidedCurrency) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addCandidate(method.CountryCode, method.CurrencyCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) externalRechargeOrderCreatedAtMS(ctx context.Context, appCode string, orderID string, providerCode string) int64 {
|
||||||
|
if order, ok := s.externalRechargeOrder(ctx, appCode, orderID, providerCode); ok {
|
||||||
|
return order.CreatedAtMS
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) externalRechargeOrder(ctx context.Context, appCode string, orderID string, providerCode string) (ledger.ExternalRechargeOrder, bool) {
|
||||||
|
if s == nil || s.repository == nil {
|
||||||
|
return ledger.ExternalRechargeOrder{}, false
|
||||||
|
}
|
||||||
|
order, err := s.repository.GetExternalRechargeOrder(ctx, appCode, orderID)
|
||||||
|
if err != nil || order.ProviderCode != providerCode {
|
||||||
|
return ledger.ExternalRechargeOrder{}, false
|
||||||
|
}
|
||||||
|
return order, true
|
||||||
|
}
|
||||||
|
|
||||||
|
type receiptUSDTClient interface {
|
||||||
|
VerifyUSDTTransfer(ctx context.Context, txHash string, toAddress string, amountMinor int64) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) verifyUSDTRechargeReceipt(ctx context.Context, command ledger.VerifyCoinSellerRechargeReceiptCommand, chain string, enabled bool, receiveAddress string, client receiptUSDTClient) (ledger.CoinSellerRechargeReceiptVerification, error) {
|
||||||
|
if !enabled || strings.TrimSpace(receiveAddress) == "" {
|
||||||
|
return ledger.CoinSellerRechargeReceiptVerification{}, xerr.New(xerr.Unavailable, "usdt "+chain+" receipt verification is not configured")
|
||||||
|
}
|
||||||
|
snapshot := s.receiptSnapshot(command)
|
||||||
|
snapshot.Chain = chain
|
||||||
|
snapshot.CurrencyCode = ledger.PaidCurrencyUSDT
|
||||||
|
snapshot.ProviderAmountMinor = command.USDMinorAmount
|
||||||
|
snapshot.ProviderOrderID = command.ExternalOrderNo
|
||||||
|
snapshot.ReceiveAddress = strings.TrimSpace(receiveAddress)
|
||||||
|
if chain == "trc20" && !isLikelyChainTransactionHash(command.ExternalOrderNo) {
|
||||||
|
return s.verifyBinanceTRXDepositReceipt(ctx, command, snapshot)
|
||||||
|
}
|
||||||
|
if client == nil {
|
||||||
|
return ledger.CoinSellerRechargeReceiptVerification{}, xerr.New(xerr.Unavailable, "usdt "+chain+" client is not configured")
|
||||||
|
}
|
||||||
|
rawJSON, err := client.VerifyUSDTTransfer(ctx, command.ExternalOrderNo, snapshot.ReceiveAddress, command.USDMinorAmount)
|
||||||
|
snapshot.RawJSON = strings.TrimSpace(rawJSON)
|
||||||
|
if err != nil {
|
||||||
|
snapshot.Status = "unverified"
|
||||||
|
snapshot.FailureReason = err.Error()
|
||||||
|
return snapshot, nil
|
||||||
|
}
|
||||||
|
snapshot.Status = "confirmed"
|
||||||
|
snapshot.Verified = true
|
||||||
|
return snapshot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) verifyBinanceTRXDepositReceipt(ctx context.Context, command ledger.VerifyCoinSellerRechargeReceiptCommand, snapshot ledger.CoinSellerRechargeReceiptVerification) (ledger.CoinSellerRechargeReceiptVerification, error) {
|
||||||
|
if s.binance == nil {
|
||||||
|
return ledger.CoinSellerRechargeReceiptVerification{}, xerr.New(xerr.Unavailable, "binance client is not configured")
|
||||||
|
}
|
||||||
|
// Binance off-chain 截图在付款方展示为 withdrawal,但收款账号侧只能通过 deposit history 查到账记录。
|
||||||
|
// 这里保持 provider 仍为 usdt_trc20,只把非链上 hash 的订单号收敛到 Binance TRX 入金校验。
|
||||||
|
record, err := s.binance.FindUSDTDeposit(ctx, ports.BinanceDepositLookupRequest{
|
||||||
|
AppCode: command.AppCode,
|
||||||
|
ExternalID: command.ExternalOrderNo,
|
||||||
|
Coin: ledger.PaidCurrencyUSDT,
|
||||||
|
Network: "TRX",
|
||||||
|
ToAddress: snapshot.ReceiveAddress,
|
||||||
|
AmountMinor: command.USDMinorAmount,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
snapshot.Status = "unverified"
|
||||||
|
snapshot.FailureReason = err.Error()
|
||||||
|
return snapshot, nil
|
||||||
|
}
|
||||||
|
snapshot.Verified = true
|
||||||
|
snapshot.Status = "confirmed"
|
||||||
|
snapshot.ProviderOrderID = record.TxID
|
||||||
|
snapshot.ProviderAmountMinor = record.AmountMinor
|
||||||
|
snapshot.ReceiveAddress = record.Address
|
||||||
|
snapshot.RawJSON = strings.TrimSpace(record.RawJSON)
|
||||||
|
return snapshot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) receiptSnapshot(command ledger.VerifyCoinSellerRechargeReceiptCommand) ledger.CoinSellerRechargeReceiptVerification {
|
||||||
|
return ledger.CoinSellerRechargeReceiptVerification{
|
||||||
|
ProviderCode: command.ProviderCode,
|
||||||
|
ExternalOrderNo: command.ExternalOrderNo,
|
||||||
|
Chain: command.Chain,
|
||||||
|
VerifiedAtMS: s.now().UTC().UnixMilli(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLikelyChainTransactionHash(value string) bool {
|
||||||
|
value = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(value)), "0x")
|
||||||
|
if len(value) != 64 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range value {
|
||||||
|
if (r < '0' || r > '9') && (r < 'a' || r > 'f') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isMifaPayReceiptPaid(status string) bool {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(status)) {
|
||||||
|
case "1", "SUCCESS":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isV5PayReceiptPaid(status string) bool {
|
||||||
|
return strings.TrimSpace(status) == "2"
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeReceiptChain(chain string) string {
|
||||||
|
chain = strings.ToLower(strings.TrimSpace(chain))
|
||||||
|
chain = strings.ReplaceAll(chain, "-", "_")
|
||||||
|
switch chain {
|
||||||
|
case "trc20", "trx", "tron", "usdt_trc20":
|
||||||
|
return "trc20"
|
||||||
|
case "bep20", "bsc", "bnb", "usdt_bep20":
|
||||||
|
return "bep20"
|
||||||
|
default:
|
||||||
|
return chain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func providerOrderNotFound(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if xerr.IsCode(err, xerr.NotFound) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
message := strings.ToLower(err.Error())
|
||||||
|
return strings.Contains(message, "not found") ||
|
||||||
|
strings.Contains(message, "not exist") ||
|
||||||
|
strings.Contains(message, "does not exist") ||
|
||||||
|
strings.Contains(message, "no data") ||
|
||||||
|
strings.Contains(message, "order不存在") ||
|
||||||
|
strings.Contains(message, "订单不存在")
|
||||||
|
}
|
||||||
@ -0,0 +1,400 @@
|
|||||||
|
package wallet_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
|
"hyapp/services/wallet-service/internal/domain/ledger"
|
||||||
|
walletservice "hyapp/services/wallet-service/internal/service/wallet"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVerifyCoinSellerRechargeReceiptMifaPayReturnsPaidSnapshot(t *testing.T) {
|
||||||
|
mifaPay := &fakeMifaPayClient{
|
||||||
|
queryResp: walletservice.MifaPayQueryOrderResponse{
|
||||||
|
OrderID: "receipt-mifa-1",
|
||||||
|
ProviderOrderID: "mb_receipt_mifa_1",
|
||||||
|
Status: "1",
|
||||||
|
Amount: "125000",
|
||||||
|
Currency: "PHP",
|
||||||
|
PayType: "GCASH",
|
||||||
|
RawJSON: `{"status":"1"}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := walletservice.New(nil)
|
||||||
|
svc.SetMifaPayClient(mifaPay)
|
||||||
|
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
|
||||||
|
AppCode: "lalu",
|
||||||
|
ProviderCode: ledger.PaymentProviderMifaPay,
|
||||||
|
ExternalOrderNo: "receipt-mifa-1",
|
||||||
|
OrderDateMS: 1767225600000,
|
||||||
|
ProviderCurrencyCode: "php",
|
||||||
|
ProviderPayType: "gcash",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyCoinSellerRechargeReceipt mifapay failed: %v", err)
|
||||||
|
}
|
||||||
|
if !receipt.Verified || receipt.ProviderOrderID != "mb_receipt_mifa_1" || receipt.ProviderAmountMinor != 125000 || receipt.CurrencyCode != "PHP" || receipt.RawJSON == "" {
|
||||||
|
t.Fatalf("mifapay receipt snapshot mismatch: %+v", receipt)
|
||||||
|
}
|
||||||
|
if mifaPay.queryReq.OrderID != "receipt-mifa-1" || mifaPay.queryReq.OrderCreatedAtMS != 1767225600000 {
|
||||||
|
t.Fatalf("mifapay query request mismatch: %+v", mifaPay.queryReq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyCoinSellerRechargeReceiptMifaPayWithoutDateUsesRecentLookup(t *testing.T) {
|
||||||
|
mifaPay := &fakeMifaPayClient{
|
||||||
|
querySeq: []fakeMifaPayQueryResult{
|
||||||
|
{err: xerr.New(xerr.NotFound, "mifapay order not found")},
|
||||||
|
{resp: walletservice.MifaPayQueryOrderResponse{
|
||||||
|
OrderID: "receipt-mifa-no-date",
|
||||||
|
ProviderOrderID: "mb_receipt_mifa_no_date",
|
||||||
|
Status: "1",
|
||||||
|
Amount: "125000",
|
||||||
|
Currency: "PHP",
|
||||||
|
PayType: "GCASH",
|
||||||
|
RawJSON: `{"status":"1"}`,
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := walletservice.New(nil)
|
||||||
|
svc.SetMifaPayClient(mifaPay)
|
||||||
|
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
|
||||||
|
AppCode: "lalu",
|
||||||
|
ProviderCode: ledger.PaymentProviderMifaPay,
|
||||||
|
ExternalOrderNo: "receipt-mifa-no-date",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyCoinSellerRechargeReceipt mifapay without date failed: %v", err)
|
||||||
|
}
|
||||||
|
if !receipt.Verified || receipt.ProviderOrderID != "mb_receipt_mifa_no_date" {
|
||||||
|
t.Fatalf("mifapay no-date receipt snapshot mismatch: %+v", receipt)
|
||||||
|
}
|
||||||
|
if len(mifaPay.queryReqs) != 2 {
|
||||||
|
t.Fatalf("mifapay no-date lookup should continue after not found, got %+v", mifaPay.queryReqs)
|
||||||
|
}
|
||||||
|
firstDate := time.UnixMilli(mifaPay.queryReqs[0].OrderCreatedAtMS).UTC()
|
||||||
|
secondDate := time.UnixMilli(mifaPay.queryReqs[1].OrderCreatedAtMS).UTC()
|
||||||
|
if firstDate.Hour() != 0 || secondDate.Hour() != 0 || !sameUTCDate(secondDate, firstDate.AddDate(0, 0, -1)) {
|
||||||
|
t.Fatalf("mifapay no-date lookup should walk backwards by UTC date: %+v", mifaPay.queryReqs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyCoinSellerRechargeReceiptMifaPayWithoutDateReturnsUnpaidSnapshot(t *testing.T) {
|
||||||
|
mifaPay := &fakeMifaPayClient{
|
||||||
|
querySeq: []fakeMifaPayQueryResult{
|
||||||
|
{err: xerr.New(xerr.NotFound, "mifapay order not found")},
|
||||||
|
{resp: walletservice.MifaPayQueryOrderResponse{
|
||||||
|
OrderID: "receipt-mifa-unpaid",
|
||||||
|
Status: "0",
|
||||||
|
Amount: "125000",
|
||||||
|
Currency: "PHP",
|
||||||
|
RawJSON: `{"status":"0"}`,
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := walletservice.New(nil)
|
||||||
|
svc.SetMifaPayClient(mifaPay)
|
||||||
|
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
|
||||||
|
AppCode: "lalu",
|
||||||
|
ProviderCode: ledger.PaymentProviderMifaPay,
|
||||||
|
ExternalOrderNo: "receipt-mifa-unpaid",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyCoinSellerRechargeReceipt mifapay unpaid failed: %v", err)
|
||||||
|
}
|
||||||
|
if receipt.Verified || receipt.FailureReason != "mifapay order status 0" {
|
||||||
|
t.Fatalf("mifapay unpaid should return failed snapshot, got %+v", receipt)
|
||||||
|
}
|
||||||
|
if len(mifaPay.queryReqs) != 2 {
|
||||||
|
t.Fatalf("mifapay unpaid should stop once an order is found, got %+v", mifaPay.queryReqs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyCoinSellerRechargeReceiptV5PayRejectsCurrencyMismatchWithoutWriting(t *testing.T) {
|
||||||
|
v5Pay := &fakeV5PayClient{
|
||||||
|
queryResp: walletservice.V5PayQueryOrderResponse{
|
||||||
|
OrderID: "receipt-v5-1",
|
||||||
|
ProviderOrderID: "tx_receipt_v5_1",
|
||||||
|
Status: "2",
|
||||||
|
Amount: "48.00",
|
||||||
|
Currency: "TRY",
|
||||||
|
ProductType: "BANK_TRANSFER",
|
||||||
|
RawJSON: `{"status":"2"}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := walletservice.New(nil)
|
||||||
|
svc.SetV5PayClient(v5Pay)
|
||||||
|
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
|
||||||
|
AppCode: "lalu",
|
||||||
|
ProviderCode: ledger.PaymentProviderV5Pay,
|
||||||
|
ExternalOrderNo: "receipt-v5-1",
|
||||||
|
ProviderCountryCode: "TR",
|
||||||
|
ProviderCurrencyCode: "PHP",
|
||||||
|
ProviderPayType: "BANK_TRANSFER",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyCoinSellerRechargeReceipt v5pay failed: %v", err)
|
||||||
|
}
|
||||||
|
if receipt.Verified || receipt.FailureReason != "v5pay currency mismatch" || receipt.ProviderAmountMinor != 4800 {
|
||||||
|
t.Fatalf("v5pay mismatch should return a failed snapshot, got %+v", receipt)
|
||||||
|
}
|
||||||
|
if v5Pay.queryReq.CountryCode != "TR" || v5Pay.queryReq.CurrencyCode != "PHP" {
|
||||||
|
t.Fatalf("v5pay query request should use caller supplied country/currency: %+v", v5Pay.queryReq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyCoinSellerRechargeReceiptV5PayWithoutCountryCurrencyUsesEnabledCandidates(t *testing.T) {
|
||||||
|
repository := &fakeReceiptRepository{channels: []ledger.ThirdPartyPaymentChannel{{
|
||||||
|
AppCode: "lalu",
|
||||||
|
ProviderCode: ledger.PaymentProviderV5Pay,
|
||||||
|
Status: ledger.ThirdPartyPaymentStatusActive,
|
||||||
|
Methods: []ledger.ThirdPartyPaymentMethod{
|
||||||
|
{ProviderCode: ledger.PaymentProviderV5Pay, CountryCode: "TR", CurrencyCode: "TRY", Status: ledger.ThirdPartyPaymentStatusActive},
|
||||||
|
{ProviderCode: ledger.PaymentProviderV5Pay, CountryCode: "PH", CurrencyCode: "PHP", Status: ledger.ThirdPartyPaymentStatusActive},
|
||||||
|
},
|
||||||
|
}}}
|
||||||
|
v5Pay := &fakeV5PayClient{
|
||||||
|
querySeq: []fakeV5PayQueryResult{
|
||||||
|
{err: xerr.New(xerr.NotFound, "v5pay order not found")},
|
||||||
|
{resp: walletservice.V5PayQueryOrderResponse{
|
||||||
|
OrderID: "receipt-v5-no-hints",
|
||||||
|
ProviderOrderID: "tx_receipt_v5_no_hints",
|
||||||
|
Status: "2",
|
||||||
|
Amount: "48.00",
|
||||||
|
Currency: "PHP",
|
||||||
|
ProductType: "GCASH",
|
||||||
|
RawJSON: `{"status":"2"}`,
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := walletservice.New(repository)
|
||||||
|
svc.SetV5PayClient(v5Pay)
|
||||||
|
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
|
||||||
|
AppCode: "lalu",
|
||||||
|
ProviderCode: ledger.PaymentProviderV5Pay,
|
||||||
|
ExternalOrderNo: "receipt-v5-no-hints",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyCoinSellerRechargeReceipt v5pay no hints failed: %v", err)
|
||||||
|
}
|
||||||
|
if !receipt.Verified || receipt.ProviderOrderID != "tx_receipt_v5_no_hints" {
|
||||||
|
t.Fatalf("v5pay no-hints receipt snapshot mismatch: %+v", receipt)
|
||||||
|
}
|
||||||
|
if !repository.querySeen || repository.query.ProviderCode != ledger.PaymentProviderV5Pay || repository.query.IncludeDisabledMethods {
|
||||||
|
t.Fatalf("v5pay no-hints lookup should enumerate active V5Pay methods: %+v", repository.query)
|
||||||
|
}
|
||||||
|
if len(v5Pay.queryReqs) != 2 || v5Pay.queryReqs[0].CountryCode != "TR" || v5Pay.queryReqs[0].CurrencyCode != "TRY" || v5Pay.queryReqs[1].CountryCode != "PH" || v5Pay.queryReqs[1].CurrencyCode != "PHP" {
|
||||||
|
t.Fatalf("v5pay no-hints lookup should use enabled country/currency candidates: %+v", v5Pay.queryReqs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyCoinSellerRechargeReceiptV5PayWithoutCountryCurrencyNotFound(t *testing.T) {
|
||||||
|
repository := &fakeReceiptRepository{channels: []ledger.ThirdPartyPaymentChannel{{
|
||||||
|
AppCode: "lalu",
|
||||||
|
ProviderCode: ledger.PaymentProviderV5Pay,
|
||||||
|
Status: ledger.ThirdPartyPaymentStatusActive,
|
||||||
|
Methods: []ledger.ThirdPartyPaymentMethod{
|
||||||
|
{ProviderCode: ledger.PaymentProviderV5Pay, CountryCode: "TR", CurrencyCode: "TRY", Status: ledger.ThirdPartyPaymentStatusActive},
|
||||||
|
{ProviderCode: ledger.PaymentProviderV5Pay, CountryCode: "PH", CurrencyCode: "PHP", Status: ledger.ThirdPartyPaymentStatusActive},
|
||||||
|
},
|
||||||
|
}}}
|
||||||
|
v5Pay := &fakeV5PayClient{querySeq: []fakeV5PayQueryResult{
|
||||||
|
{err: xerr.New(xerr.NotFound, "v5pay order not found")},
|
||||||
|
{err: xerr.New(xerr.NotFound, "v5pay order not found")},
|
||||||
|
}}
|
||||||
|
svc := walletservice.New(repository)
|
||||||
|
svc.SetV5PayClient(v5Pay)
|
||||||
|
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
|
||||||
|
AppCode: "lalu",
|
||||||
|
ProviderCode: ledger.PaymentProviderV5Pay,
|
||||||
|
ExternalOrderNo: "receipt-v5-missing",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyCoinSellerRechargeReceipt v5pay not found failed: %v", err)
|
||||||
|
}
|
||||||
|
if receipt.Verified || receipt.FailureReason != "v5pay order not found for enabled country/currency candidates" {
|
||||||
|
t.Fatalf("v5pay missing order should return failed snapshot, got %+v", receipt)
|
||||||
|
}
|
||||||
|
if len(v5Pay.queryReqs) != 2 {
|
||||||
|
t.Fatalf("v5pay missing order should exhaust enabled candidates, got %+v", v5Pay.queryReqs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyCoinSellerRechargeReceiptUSDTBEP20UsesReadOnlyClient(t *testing.T) {
|
||||||
|
bsc := &fakeTronUSDTClient{rawJSON: `{"receipt":true}`}
|
||||||
|
svc := walletservice.New(nil)
|
||||||
|
svc.SetBSCUSDTClient(bsc)
|
||||||
|
svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{
|
||||||
|
USDTBEP20Enabled: true,
|
||||||
|
USDTBEP20Address: "0xPlatformBEP20ReceiveAddress",
|
||||||
|
})
|
||||||
|
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
|
||||||
|
AppCode: "lalu",
|
||||||
|
ProviderCode: ledger.PaymentProviderUSDTBEP20,
|
||||||
|
ExternalOrderNo: "0xreceiptbep20",
|
||||||
|
USDMinorAmount: 150,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyCoinSellerRechargeReceipt usdt_bep20 failed: %v", err)
|
||||||
|
}
|
||||||
|
if !receipt.Verified || receipt.Status != "confirmed" || receipt.Chain != "bep20" || receipt.ProviderAmountMinor != 150 || receipt.ReceiveAddress != "0xPlatformBEP20ReceiveAddress" {
|
||||||
|
t.Fatalf("bep20 receipt snapshot mismatch: %+v", receipt)
|
||||||
|
}
|
||||||
|
if bsc.lastTxHash != "0xreceiptbep20" || bsc.lastToAddress != "0xPlatformBEP20ReceiveAddress" || bsc.lastAmountMinor != 150 {
|
||||||
|
t.Fatalf("bep20 client request mismatch: %+v", bsc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyCoinSellerRechargeReceiptUSDTBEP20DisabledFailsClosed(t *testing.T) {
|
||||||
|
svc := walletservice.New(nil)
|
||||||
|
_, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
|
||||||
|
AppCode: "lalu",
|
||||||
|
ProviderCode: ledger.PaymentProviderUSDTBEP20,
|
||||||
|
ExternalOrderNo: "0xreceiptbep20",
|
||||||
|
USDMinorAmount: 150,
|
||||||
|
})
|
||||||
|
if !xerr.IsCode(err, xerr.Unavailable) || !strings.Contains(err.Error(), "not configured") {
|
||||||
|
t.Fatalf("disabled bep20 verification should fail closed, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyCoinSellerRechargeReceiptUSDTTRC20HashUsesTronGrid(t *testing.T) {
|
||||||
|
tron := &fakeTronUSDTClient{rawJSON: `{"chain":true}`}
|
||||||
|
binance := &fakeBinanceClient{err: xerr.New(xerr.Internal, "binance should not be called")}
|
||||||
|
svc := walletservice.New(nil)
|
||||||
|
svc.SetTronUSDTClient(tron)
|
||||||
|
svc.SetBinanceClient(binance)
|
||||||
|
svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{
|
||||||
|
USDTTRC20Enabled: true,
|
||||||
|
USDTTRC20Address: "TPlatformReceiveAddress",
|
||||||
|
})
|
||||||
|
txHash := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
|
||||||
|
AppCode: "lalu",
|
||||||
|
ProviderCode: ledger.PaymentProviderUSDTTRC20,
|
||||||
|
ExternalOrderNo: txHash,
|
||||||
|
USDMinorAmount: 50000,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyCoinSellerRechargeReceipt usdt_trc20 hash failed: %v", err)
|
||||||
|
}
|
||||||
|
if !receipt.Verified || receipt.Status != "confirmed" || receipt.RawJSON != `{"chain":true}` {
|
||||||
|
t.Fatalf("trc20 hash receipt snapshot mismatch: %+v", receipt)
|
||||||
|
}
|
||||||
|
if tron.lastTxHash != txHash || tron.lastToAddress != "TPlatformReceiveAddress" || tron.lastAmountMinor != 50000 {
|
||||||
|
t.Fatalf("trc20 hash should use TronGrid client: %+v", tron)
|
||||||
|
}
|
||||||
|
if binance.calls != 0 {
|
||||||
|
t.Fatalf("chain tx hash must not call Binance, calls=%d", binance.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyCoinSellerRechargeReceiptUSDTTRC20OffchainUsesBinance(t *testing.T) {
|
||||||
|
tron := &fakeTronUSDTClient{err: xerr.New(xerr.Internal, "tron should not be called")}
|
||||||
|
binance := &fakeBinanceClient{record: walletservice.BinanceDepositRecord{
|
||||||
|
TxID: "Off-chain transfer 388611987194",
|
||||||
|
Coin: ledger.PaidCurrencyUSDT,
|
||||||
|
Network: "TRX",
|
||||||
|
Status: 1,
|
||||||
|
Address: "TNwqCkF8cnM4XiwjnbbKTfdZvFDqVoa1TN",
|
||||||
|
Amount: "500.00000000",
|
||||||
|
AmountMinor: 50000,
|
||||||
|
RawJSON: `{"txId":"Off-chain transfer 388611987194","status":1}`,
|
||||||
|
}}
|
||||||
|
svc := walletservice.New(nil)
|
||||||
|
svc.SetTronUSDTClient(tron)
|
||||||
|
svc.SetBinanceClient(binance)
|
||||||
|
svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{
|
||||||
|
USDTTRC20Enabled: true,
|
||||||
|
USDTTRC20Address: "TGlobalFallbackReceiveAddress",
|
||||||
|
USDTTRC20Addresses: map[string]string{
|
||||||
|
"aslan": "TNwqCkF8cnM4XiwjnbbKTfdZvFDqVoa1TN",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
|
||||||
|
AppCode: "aslan",
|
||||||
|
ProviderCode: ledger.PaymentProviderUSDTTRC20,
|
||||||
|
ExternalOrderNo: "388611987194",
|
||||||
|
USDMinorAmount: 50000,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyCoinSellerRechargeReceipt binance offchain failed: %v", err)
|
||||||
|
}
|
||||||
|
if !receipt.Verified || receipt.Status != "confirmed" || receipt.ProviderOrderID != "Off-chain transfer 388611987194" || receipt.ProviderAmountMinor != 50000 || receipt.ReceiveAddress != "TNwqCkF8cnM4XiwjnbbKTfdZvFDqVoa1TN" {
|
||||||
|
t.Fatalf("binance offchain receipt snapshot mismatch: %+v", receipt)
|
||||||
|
}
|
||||||
|
if binance.calls != 1 || binance.req.AppCode != "aslan" || binance.req.ExternalID != "388611987194" || binance.req.Network != "TRX" || binance.req.ToAddress != "TNwqCkF8cnM4XiwjnbbKTfdZvFDqVoa1TN" || binance.req.AmountMinor != 50000 {
|
||||||
|
t.Fatalf("binance offchain request mismatch: calls=%d req=%+v", binance.calls, binance.req)
|
||||||
|
}
|
||||||
|
if tron.lastTxHash != "" {
|
||||||
|
t.Fatalf("off-chain Binance id must not call TronGrid: %+v", tron)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyCoinSellerRechargeReceiptUSDTTRC20OffchainMissingBinanceAccount(t *testing.T) {
|
||||||
|
binance := &fakeBinanceClient{err: xerr.New(xerr.Unavailable, "binance account is not configured")}
|
||||||
|
svc := walletservice.New(nil)
|
||||||
|
svc.SetBinanceClient(binance)
|
||||||
|
svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{
|
||||||
|
USDTTRC20Enabled: true,
|
||||||
|
USDTTRC20Address: "TNwqCkF8cnM4XiwjnbbKTfdZvFDqVoa1TN",
|
||||||
|
})
|
||||||
|
receipt, err := svc.VerifyCoinSellerRechargeReceipt(context.Background(), ledger.VerifyCoinSellerRechargeReceiptCommand{
|
||||||
|
AppCode: "yumi",
|
||||||
|
ProviderCode: ledger.PaymentProviderUSDTTRC20,
|
||||||
|
ExternalOrderNo: "388611987194",
|
||||||
|
USDMinorAmount: 50000,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("missing binance account should return failed snapshot, got error %v", err)
|
||||||
|
}
|
||||||
|
if receipt.Verified || !strings.Contains(receipt.FailureReason, "binance account is not configured") {
|
||||||
|
t.Fatalf("missing binance account should be explicit in snapshot, got %+v", receipt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeReceiptRepository struct {
|
||||||
|
walletservice.Repository
|
||||||
|
channels []ledger.ThirdPartyPaymentChannel
|
||||||
|
query ledger.ListThirdPartyPaymentChannelsQuery
|
||||||
|
queryErr error
|
||||||
|
querySeen bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeReceiptRepository) ListThirdPartyPaymentChannels(_ context.Context, query ledger.ListThirdPartyPaymentChannelsQuery) ([]ledger.ThirdPartyPaymentChannel, error) {
|
||||||
|
r.query = query
|
||||||
|
r.querySeen = true
|
||||||
|
if r.queryErr != nil {
|
||||||
|
return nil, r.queryErr
|
||||||
|
}
|
||||||
|
return r.channels, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeReceiptRepository) GetExternalRechargeOrder(context.Context, string, string) (ledger.ExternalRechargeOrder, error) {
|
||||||
|
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.NotFound, "external recharge order not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameUTCDate(left time.Time, right time.Time) bool {
|
||||||
|
left = left.UTC()
|
||||||
|
right = right.UTC()
|
||||||
|
return left.Year() == right.Year() && left.YearDay() == right.YearDay()
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeBinanceClient struct {
|
||||||
|
req walletservice.BinanceDepositLookupRequest
|
||||||
|
record walletservice.BinanceDepositRecord
|
||||||
|
err error
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeBinanceClient) FindUSDTDeposit(_ context.Context, req walletservice.BinanceDepositLookupRequest) (walletservice.BinanceDepositRecord, error) {
|
||||||
|
f.calls++
|
||||||
|
f.req = req
|
||||||
|
if f.err != nil {
|
||||||
|
return walletservice.BinanceDepositRecord{}, f.err
|
||||||
|
}
|
||||||
|
return f.record, nil
|
||||||
|
}
|
||||||
@ -147,6 +147,35 @@ type V5PayProductTypesResponse struct {
|
|||||||
RawJSON string
|
RawJSON string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BinanceDepositLookupRequest 是后台 USDT 币商充值使用的 Binance 入金查单条件。
|
||||||
|
type BinanceDepositLookupRequest struct {
|
||||||
|
AppCode string
|
||||||
|
ExternalID string
|
||||||
|
Coin string
|
||||||
|
Network string
|
||||||
|
ToAddress string
|
||||||
|
AmountMinor int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// BinanceDepositRecord 是 Binance deposit history 中命中的只读快照;金额按 USD/USDT cent 收敛给上层校验。
|
||||||
|
type BinanceDepositRecord struct {
|
||||||
|
ID string
|
||||||
|
TxID string
|
||||||
|
Coin string
|
||||||
|
Network string
|
||||||
|
Status int64
|
||||||
|
Address string
|
||||||
|
Amount string
|
||||||
|
AmountMinor int64
|
||||||
|
TransferType int64
|
||||||
|
InsertTimeMS int64
|
||||||
|
CompleteTimeMS int64
|
||||||
|
ConfirmTimes string
|
||||||
|
UnlockConfirm int64
|
||||||
|
WalletType int64
|
||||||
|
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)
|
||||||
@ -166,3 +195,13 @@ type V5PayClient interface {
|
|||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BSCUSDTClient 校验用户提交的 BEP20 tx_hash 是否真实打到平台共享地址。
|
||||||
|
type BSCUSDTClient interface {
|
||||||
|
VerifyUSDTTransfer(ctx context.Context, txHash string, toAddress string, amountMinor int64) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BinanceClient 隔离 Binance signed API;service 只关心入金记录是否匹配后台订单。
|
||||||
|
type BinanceClient interface {
|
||||||
|
FindUSDTDeposit(ctx context.Context, req BinanceDepositLookupRequest) (BinanceDepositRecord, error)
|
||||||
|
}
|
||||||
|
|||||||
@ -18,6 +18,8 @@ type Service struct {
|
|||||||
mifaPay ports.MifaPayClient
|
mifaPay ports.MifaPayClient
|
||||||
v5Pay ports.V5PayClient
|
v5Pay ports.V5PayClient
|
||||||
tronUSDT ports.TronUSDTClient
|
tronUSDT ports.TronUSDTClient
|
||||||
|
bscUSDT ports.BSCUSDTClient
|
||||||
|
binance ports.BinanceClient
|
||||||
externalRecharge ExternalRechargeConfig
|
externalRecharge ExternalRechargeConfig
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
}
|
}
|
||||||
@ -56,6 +58,16 @@ func (s *Service) SetTronUSDTClient(client ports.TronUSDTClient) {
|
|||||||
s.tronUSDT = client
|
s.tronUSDT = client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetBSCUSDTClient 注入 BSC BEP20 交易校验客户端;默认不配置,避免误把公共 RPC 当成生产依赖。
|
||||||
|
func (s *Service) SetBSCUSDTClient(client ports.BSCUSDTClient) {
|
||||||
|
s.bscUSDT = client
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetBinanceClient 注入 Binance 入金只读查询客户端;后台币商充值可用 off-chain 单号自动查账。
|
||||||
|
func (s *Service) SetBinanceClient(client ports.BinanceClient) {
|
||||||
|
s.binance = client
|
||||||
|
}
|
||||||
|
|
||||||
// SetExternalRechargeConfig 保存 H5 外部充值的站内公共配置,不把密钥或地址散落到 handler。
|
// SetExternalRechargeConfig 保存 H5 外部充值的站内公共配置,不把密钥或地址散落到 handler。
|
||||||
func (s *Service) SetExternalRechargeConfig(config ExternalRechargeConfig) {
|
func (s *Service) SetExternalRechargeConfig(config ExternalRechargeConfig) {
|
||||||
s.externalRecharge = normalizeExternalRechargeConfig(config)
|
s.externalRecharge = normalizeExternalRechargeConfig(config)
|
||||||
|
|||||||
@ -6716,12 +6716,19 @@ type fakeMifaPayClient struct {
|
|||||||
createReq walletservice.MifaPayCreateOrderRequest
|
createReq walletservice.MifaPayCreateOrderRequest
|
||||||
createResp walletservice.MifaPayCreateOrderResponse
|
createResp walletservice.MifaPayCreateOrderResponse
|
||||||
queryReq walletservice.MifaPayQueryOrderRequest
|
queryReq walletservice.MifaPayQueryOrderRequest
|
||||||
|
queryReqs []walletservice.MifaPayQueryOrderRequest
|
||||||
queryResp walletservice.MifaPayQueryOrderResponse
|
queryResp walletservice.MifaPayQueryOrderResponse
|
||||||
|
querySeq []fakeMifaPayQueryResult
|
||||||
queryErr error
|
queryErr error
|
||||||
notifyData walletservice.MifaPayNotifyData
|
notifyData walletservice.MifaPayNotifyData
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fakeMifaPayQueryResult struct {
|
||||||
|
resp walletservice.MifaPayQueryOrderResponse
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
type fakeMifaPayProviderPayloadError struct {
|
type fakeMifaPayProviderPayloadError struct {
|
||||||
cause error
|
cause error
|
||||||
payload string
|
payload string
|
||||||
@ -6752,6 +6759,11 @@ func (f *fakeMifaPayClient) CreateOrder(_ context.Context, req walletservice.Mif
|
|||||||
|
|
||||||
func (f *fakeMifaPayClient) QueryOrder(_ context.Context, req walletservice.MifaPayQueryOrderRequest) (walletservice.MifaPayQueryOrderResponse, error) {
|
func (f *fakeMifaPayClient) QueryOrder(_ context.Context, req walletservice.MifaPayQueryOrderRequest) (walletservice.MifaPayQueryOrderResponse, error) {
|
||||||
f.queryReq = req
|
f.queryReq = req
|
||||||
|
f.queryReqs = append(f.queryReqs, req)
|
||||||
|
if index := len(f.queryReqs) - 1; index < len(f.querySeq) {
|
||||||
|
result := f.querySeq[index]
|
||||||
|
return result.resp, result.err
|
||||||
|
}
|
||||||
if f.queryErr != nil {
|
if f.queryErr != nil {
|
||||||
return walletservice.MifaPayQueryOrderResponse{}, f.queryErr
|
return walletservice.MifaPayQueryOrderResponse{}, f.queryErr
|
||||||
}
|
}
|
||||||
@ -6769,7 +6781,9 @@ type fakeV5PayClient struct {
|
|||||||
createReq walletservice.V5PayCreateOrderRequest
|
createReq walletservice.V5PayCreateOrderRequest
|
||||||
createResp walletservice.V5PayCreateOrderResponse
|
createResp walletservice.V5PayCreateOrderResponse
|
||||||
queryReq walletservice.V5PayQueryOrderRequest
|
queryReq walletservice.V5PayQueryOrderRequest
|
||||||
|
queryReqs []walletservice.V5PayQueryOrderRequest
|
||||||
queryResp walletservice.V5PayQueryOrderResponse
|
queryResp walletservice.V5PayQueryOrderResponse
|
||||||
|
querySeq []fakeV5PayQueryResult
|
||||||
notifyData walletservice.V5PayNotifyData
|
notifyData walletservice.V5PayNotifyData
|
||||||
productReqs []walletservice.V5PayProductTypesRequest
|
productReqs []walletservice.V5PayProductTypesRequest
|
||||||
productTypes map[string]walletservice.V5PayProductTypesResponse
|
productTypes map[string]walletservice.V5PayProductTypesResponse
|
||||||
@ -6777,6 +6791,11 @@ type fakeV5PayClient struct {
|
|||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fakeV5PayQueryResult struct {
|
||||||
|
resp walletservice.V5PayQueryOrderResponse
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
type fakeProviderPayloadError struct {
|
type fakeProviderPayloadError struct {
|
||||||
message string
|
message string
|
||||||
rawJSON string
|
rawJSON string
|
||||||
@ -6803,6 +6822,11 @@ func (f *fakeV5PayClient) CreateOrder(_ context.Context, req walletservice.V5Pay
|
|||||||
|
|
||||||
func (f *fakeV5PayClient) QueryOrder(_ context.Context, req walletservice.V5PayQueryOrderRequest) (walletservice.V5PayQueryOrderResponse, error) {
|
func (f *fakeV5PayClient) QueryOrder(_ context.Context, req walletservice.V5PayQueryOrderRequest) (walletservice.V5PayQueryOrderResponse, error) {
|
||||||
f.queryReq = req
|
f.queryReq = req
|
||||||
|
f.queryReqs = append(f.queryReqs, req)
|
||||||
|
if index := len(f.queryReqs) - 1; index < len(f.querySeq) {
|
||||||
|
result := f.querySeq[index]
|
||||||
|
return result.resp, result.err
|
||||||
|
}
|
||||||
if f.err != nil {
|
if f.err != nil {
|
||||||
return walletservice.V5PayQueryOrderResponse{}, f.err
|
return walletservice.V5PayQueryOrderResponse{}, f.err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -220,6 +220,27 @@ func (s *Server) GetH5RechargeOrder(ctx context.Context, req *walletv1.GetH5Rech
|
|||||||
return &walletv1.H5RechargeOrderResponse{Order: externalRechargeOrderToProto(order)}, nil
|
return &walletv1.H5RechargeOrderResponse{Order: externalRechargeOrderToProto(order)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VerifyCoinSellerRechargeReceipt 只读校验币商充值凭证,不创建外部订单,也不触发钱包入账。
|
||||||
|
func (s *Server) VerifyCoinSellerRechargeReceipt(ctx context.Context, req *walletv1.VerifyCoinSellerRechargeReceiptRequest) (*walletv1.VerifyCoinSellerRechargeReceiptResponse, error) {
|
||||||
|
ctx = appcode.WithContext(ctx, req.GetAppCode())
|
||||||
|
receipt, err := s.svc.VerifyCoinSellerRechargeReceipt(ctx, ledger.VerifyCoinSellerRechargeReceiptCommand{
|
||||||
|
RequestID: req.GetRequestId(),
|
||||||
|
AppCode: req.GetAppCode(),
|
||||||
|
ProviderCode: req.GetProviderCode(),
|
||||||
|
ExternalOrderNo: req.GetExternalOrderNo(),
|
||||||
|
Chain: req.GetChain(),
|
||||||
|
USDMinorAmount: req.GetUsdMinorAmount(),
|
||||||
|
OrderDateMS: req.GetOrderDateMs(),
|
||||||
|
ProviderCountryCode: req.GetProviderCountryCode(),
|
||||||
|
ProviderCurrencyCode: req.GetProviderCurrencyCode(),
|
||||||
|
ProviderPayType: req.GetProviderPayType(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerr.ToGRPCError(err)
|
||||||
|
}
|
||||||
|
return coinSellerRechargeReceiptVerificationToProto(receipt), nil
|
||||||
|
}
|
||||||
|
|
||||||
// HandleMifapayNotify 验签并处理 MiFaPay 回调;成功或重复成功由 HTTP 层按 response_text 返回大写 SUCCESS。
|
// HandleMifapayNotify 验签并处理 MiFaPay 回调;成功或重复成功由 HTTP 层按 response_text 返回大写 SUCCESS。
|
||||||
func (s *Server) HandleMifapayNotify(ctx context.Context, req *walletv1.HandleMifapayNotifyRequest) (*walletv1.HandleMifapayNotifyResponse, error) {
|
func (s *Server) HandleMifapayNotify(ctx context.Context, req *walletv1.HandleMifapayNotifyRequest) (*walletv1.HandleMifapayNotifyResponse, error) {
|
||||||
ctx = appcode.WithContext(ctx, req.GetAppCode())
|
ctx = appcode.WithContext(ctx, req.GetAppCode())
|
||||||
@ -255,6 +276,23 @@ func (s *Server) HandleV5PayNotify(ctx context.Context, req *walletv1.HandleV5Pa
|
|||||||
return &walletv1.HandleV5PayNotifyResponse{Accepted: accepted, ResponseText: responseText, OrderId: order.OrderID}, nil
|
return &walletv1.HandleV5PayNotifyResponse{Accepted: accepted, ResponseText: responseText, OrderId: order.OrderID}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func coinSellerRechargeReceiptVerificationToProto(receipt ledger.CoinSellerRechargeReceiptVerification) *walletv1.VerifyCoinSellerRechargeReceiptResponse {
|
||||||
|
return &walletv1.VerifyCoinSellerRechargeReceiptResponse{
|
||||||
|
Verified: receipt.Verified,
|
||||||
|
ProviderCode: receipt.ProviderCode,
|
||||||
|
ExternalOrderNo: receipt.ExternalOrderNo,
|
||||||
|
ProviderOrderId: receipt.ProviderOrderID,
|
||||||
|
Status: receipt.Status,
|
||||||
|
CurrencyCode: receipt.CurrencyCode,
|
||||||
|
ProviderAmountMinor: receipt.ProviderAmountMinor,
|
||||||
|
RawJson: receipt.RawJSON,
|
||||||
|
Chain: receipt.Chain,
|
||||||
|
ReceiveAddress: receipt.ReceiveAddress,
|
||||||
|
VerifiedAtMs: receipt.VerifiedAtMS,
|
||||||
|
FailureReason: receipt.FailureReason,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func thirdPartyPaymentChannelToProto(channel ledger.ThirdPartyPaymentChannel) *walletv1.ThirdPartyPaymentChannel {
|
func thirdPartyPaymentChannelToProto(channel ledger.ThirdPartyPaymentChannel) *walletv1.ThirdPartyPaymentChannel {
|
||||||
resp := &walletv1.ThirdPartyPaymentChannel{
|
resp := &walletv1.ThirdPartyPaymentChannel{
|
||||||
AppCode: channel.AppCode,
|
AppCode: channel.AppCode,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user