自动充值

This commit is contained in:
zhx 2026-07-07 18:05:02 +08:00
parent 8e5f213d9b
commit b8cbb93183
49 changed files with 7306 additions and 2203 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1625,6 +1625,34 @@ message H5RechargeOrderResponse {
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 {
string request_id = 1;
string app_code = 2;
@ -2344,6 +2372,7 @@ service WalletService {
rpc ListTemporaryRechargeOrders(ListTemporaryRechargeOrdersRequest) returns (ListTemporaryRechargeOrdersResponse);
rpc SubmitH5RechargeTx(SubmitH5RechargeTxRequest) returns (H5RechargeOrderResponse);
rpc GetH5RechargeOrder(GetH5RechargeOrderRequest) returns (H5RechargeOrderResponse);
rpc VerifyCoinSellerRechargeReceipt(VerifyCoinSellerRechargeReceiptRequest) returns (VerifyCoinSellerRechargeReceiptResponse);
rpc HandleMifapayNotify(HandleMifapayNotifyRequest) returns (HandleMifapayNotifyResponse);
rpc HandleV5PayNotify(HandleV5PayNotifyRequest) returns (HandleV5PayNotifyResponse);
rpc GetDiamondExchangeConfig(GetDiamondExchangeConfigRequest) returns (GetDiamondExchangeConfigResponse);

View File

@ -275,6 +275,7 @@ const (
WalletService_ListTemporaryRechargeOrders_FullMethodName = "/hyapp.wallet.v1.WalletService/ListTemporaryRechargeOrders"
WalletService_SubmitH5RechargeTx_FullMethodName = "/hyapp.wallet.v1.WalletService/SubmitH5RechargeTx"
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_HandleV5PayNotify_FullMethodName = "/hyapp.wallet.v1.WalletService/HandleV5PayNotify"
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)
SubmitH5RechargeTx(ctx context.Context, in *SubmitH5RechargeTxRequest, 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)
HandleV5PayNotify(ctx context.Context, in *HandleV5PayNotifyRequest, opts ...grpc.CallOption) (*HandleV5PayNotifyResponse, 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
}
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) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(HandleMifapayNotifyResponse)
@ -1511,6 +1523,7 @@ type WalletServiceServer interface {
ListTemporaryRechargeOrders(context.Context, *ListTemporaryRechargeOrdersRequest) (*ListTemporaryRechargeOrdersResponse, error)
SubmitH5RechargeTx(context.Context, *SubmitH5RechargeTxRequest) (*H5RechargeOrderResponse, error)
GetH5RechargeOrder(context.Context, *GetH5RechargeOrderRequest) (*H5RechargeOrderResponse, error)
VerifyCoinSellerRechargeReceipt(context.Context, *VerifyCoinSellerRechargeReceiptRequest) (*VerifyCoinSellerRechargeReceiptResponse, error)
HandleMifapayNotify(context.Context, *HandleMifapayNotifyRequest) (*HandleMifapayNotifyResponse, error)
HandleV5PayNotify(context.Context, *HandleV5PayNotifyRequest) (*HandleV5PayNotifyResponse, 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) {
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) {
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)
}
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) {
in := new(HandleMifapayNotifyRequest)
if err := dec(in); err != nil {
@ -3993,6 +4027,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "GetH5RechargeOrder",
Handler: _WalletService_GetH5RechargeOrder_Handler,
},
{
MethodName: "VerifyCoinSellerRechargeReceipt",
Handler: _WalletService_VerifyCoinSellerRechargeReceipt_Handler,
},
{
MethodName: "HandleMifapayNotify",
Handler: _WalletService_HandleMifapayNotify_Handler,

View File

@ -95,6 +95,8 @@ services:
container_name: wallet-service
environment:
TZ: UTC
volumes:
- ./.env:/app/.env:ro
ports:
- "13004:13004"
- "13104:13104"

View File

@ -1,7 +1,10 @@
package configx
import (
"bufio"
"errors"
"os"
"strings"
"gopkg.in/yaml.v3"
)
@ -16,3 +19,82 @@ func LoadYAML(path string, out any) error {
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()
}

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

View File

@ -44,6 +44,7 @@ import (
dashboardmodule "hyapp-admin-server/internal/modules/dashboard"
databimodule "hyapp-admin-server/internal/modules/databi"
financeapplicationmodule "hyapp-admin-server/internal/modules/financeapplication"
financeordermodule "hyapp-admin-server/internal/modules/financeorder"
financewithdrawalmodule "hyapp-admin-server/internal/modules/financewithdrawal"
firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward"
fullservernoticemodule "hyapp-admin-server/internal/modules/fullservernotice"
@ -198,6 +199,11 @@ func main() {
fatalRuntime("connect_finance_bill_sources_failed", err)
}
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)
if cfg.MySQLAutoMigrate {
@ -329,6 +335,7 @@ func main() {
Databi: databimodule.New(databiService, store, auditHandler),
FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
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),
FullServerNotice: fullservernoticemodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Game: gamemanagementmodule.New(
@ -498,6 +505,57 @@ func connectFinanceBillSources(ctx context.Context, configs []config.FinanceBill
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 {
index := map[string]dashboardmodule.ExternalDashboardSource{}
for _, source := range sources {

View File

@ -123,6 +123,21 @@ dashboard_external_sources:
# aslan_mongo 源按整段区间做聚合(注册/日活/充值/礼物 + 留存 cohort30 天区间比逐日查询重,
# 5s 会在长区间超时;线上建议 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:
dingtalk:
enabled: true

View File

@ -120,6 +120,21 @@ dashboard_external_sources:
legacy_wallet_database: "atyou_wallet"
stat_timezone: "Asia/Shanghai"
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:
dingtalk:
enabled: true

View File

@ -13,39 +13,40 @@ import (
)
type Config struct {
ServiceName string `yaml:"service_name"`
NodeID string `yaml:"node_id"`
Environment string `yaml:"environment"`
Log logging.Config `yaml:"log"`
HTTPAddr string `yaml:"http_addr"`
MySQLDSN string `yaml:"mysql_dsn"`
UserMySQLDSN string `yaml:"user_mysql_dsn"`
WalletMySQLDSN string `yaml:"wallet_mysql_dsn"`
RobotProfileSource RobotProfileSourceConfig `yaml:"robot_profile_source"`
MoneyRegionSources []MoneyRegionSourceConfig `yaml:"money_region_sources"`
FinanceBillSources []FinanceBillSourceConfig `yaml:"finance_bill_sources"`
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
Migrations MigrationConfig `yaml:"migrations"`
JWTSecret string `yaml:"jwt_secret"`
AccessTokenTTL time.Duration `yaml:"access_token_ttl"`
RefreshTokenTTL time.Duration `yaml:"refresh_token_ttl"`
CORSAllowedOrigins []string `yaml:"cors_allowed_origins"`
RefreshCookieSecure bool `yaml:"refresh_cookie_secure"`
RefreshCookieSameSite string `yaml:"refresh_cookie_same_site"`
Bootstrap BootstrapConfig `yaml:"bootstrap"`
BootstrapPassword string `yaml:"bootstrap_password"`
UserService UserServiceConfig `yaml:"user_service"`
TencentCOS TencentCOSConfig `yaml:"tencent-cos"`
Redis RedisConfig `yaml:"redis"`
Jobs JobsConfig `yaml:"jobs"`
WalletService WalletServiceConfig `yaml:"wallet_service"`
RoomService RoomServiceConfig `yaml:"room_service"`
RobotService RobotServiceConfig `yaml:"robot_service"`
ActivityService ActivityServiceConfig `yaml:"activity_service"`
GameService GameServiceConfig `yaml:"game_service"`
StatisticsService StatisticsServiceConfig `yaml:"statistics_service"`
DashboardExternalSources []DashboardExternalSourceConfig `yaml:"dashboard_external_sources"`
FinanceNotifications FinanceNotificationsConfig `yaml:"finance_notifications"`
ServiceName string `yaml:"service_name"`
NodeID string `yaml:"node_id"`
Environment string `yaml:"environment"`
Log logging.Config `yaml:"log"`
HTTPAddr string `yaml:"http_addr"`
MySQLDSN string `yaml:"mysql_dsn"`
UserMySQLDSN string `yaml:"user_mysql_dsn"`
WalletMySQLDSN string `yaml:"wallet_mysql_dsn"`
RobotProfileSource RobotProfileSourceConfig `yaml:"robot_profile_source"`
MoneyRegionSources []MoneyRegionSourceConfig `yaml:"money_region_sources"`
FinanceBillSources []FinanceBillSourceConfig `yaml:"finance_bill_sources"`
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
Migrations MigrationConfig `yaml:"migrations"`
JWTSecret string `yaml:"jwt_secret"`
AccessTokenTTL time.Duration `yaml:"access_token_ttl"`
RefreshTokenTTL time.Duration `yaml:"refresh_token_ttl"`
CORSAllowedOrigins []string `yaml:"cors_allowed_origins"`
RefreshCookieSecure bool `yaml:"refresh_cookie_secure"`
RefreshCookieSameSite string `yaml:"refresh_cookie_same_site"`
Bootstrap BootstrapConfig `yaml:"bootstrap"`
BootstrapPassword string `yaml:"bootstrap_password"`
UserService UserServiceConfig `yaml:"user_service"`
TencentCOS TencentCOSConfig `yaml:"tencent-cos"`
Redis RedisConfig `yaml:"redis"`
Jobs JobsConfig `yaml:"jobs"`
WalletService WalletServiceConfig `yaml:"wallet_service"`
RoomService RoomServiceConfig `yaml:"room_service"`
RobotService RobotServiceConfig `yaml:"robot_service"`
ActivityService ActivityServiceConfig `yaml:"activity_service"`
GameService GameServiceConfig `yaml:"game_service"`
StatisticsService StatisticsServiceConfig `yaml:"statistics_service"`
DashboardExternalSources []DashboardExternalSourceConfig `yaml:"dashboard_external_sources"`
LegacyCoinSellerRechargeSources []LegacyCoinSellerRechargeSourceConfig `yaml:"legacy_coin_seller_recharge_sources"`
FinanceNotifications FinanceNotificationsConfig `yaml:"finance_notifications"`
}
type MigrationConfig struct {
@ -106,6 +107,18 @@ type DashboardExternalSourceConfig struct {
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 {
DingTalk DingTalkRobotConfig `yaml:"dingtalk"`
}
@ -316,6 +329,22 @@ func Default() Config {
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{
DingTalk: DingTalkRobotConfig{
Enabled: true,
@ -494,6 +523,18 @@ func (cfg *Config) Normalize() {
}
}
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.FinanceNotifications.DingTalk.WebhookURL = strings.TrimSpace(cfg.FinanceNotifications.DingTalk.WebhookURL)
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")
}
}
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 {
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 {
appCode = strings.ToUpper(strings.TrimSpace(appCode))
if appCode == "" {

View File

@ -51,6 +51,7 @@ type Client interface {
CreateTemporaryRechargeOrder(ctx context.Context, req *walletv1.CreateTemporaryRechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error)
GetTemporaryRechargeOrder(ctx context.Context, req *walletv1.GetTemporaryRechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, 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)
UpdateThirdPartyPaymentRate(ctx context.Context, req *walletv1.UpdateThirdPartyPaymentRateRequest) (*walletv1.ThirdPartyPaymentMethodResponse, 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)
}
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) {
return c.client.SetThirdPartyPaymentMethodStatus(ctx, req)
}

View File

@ -18,6 +18,22 @@ const (
FinanceApplicationStatusApproved = "approved"
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"
WithdrawalApplicationStatusApproved = "approved"
WithdrawalApplicationStatusRejected = "rejected"
@ -469,6 +485,55 @@ func (FinanceApplication) TableName() string {
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 {
ID uint `gorm:"primaryKey" json:"id"`
AppCode string `gorm:"size:32;index:idx_admin_withdrawal_app_status_time;not null" json:"appCode"`

View File

@ -625,24 +625,23 @@ func (s *AslanMongoDashboardSource) loadRechargeStats(ctx context.Context, start
"status": "SUCCESS",
"trialPeriod": bson.M{"$ne": true},
"createTime": bson.M{"$gte": startDate, "$lt": endDate},
"countryCode": bson.M{"$exists": true, "$ne": ""},
}
aslanMongoExcludeFreightGoldRecharge(match)
aslanMongoApplyCountryFilter(match, "countryCode", countryFilter)
// Aslan 原 Top 充值统计按 acceptUserId 聚合createUser 只在 acceptUserId 缺失的旧单据上兜底。
userExpression := bson.D{{Key: "$ifNull", Value: bson.A{"$acceptUserId", "$createUser"}}}
pipeline := mongo.Pipeline{
{{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{}{}
err := s.aggregateEach(ctx, aslanMongoInAppPurchaseCollection, pipeline, func(row bson.M) error {
key := aslanMongoDocument(row["_id"])
@ -684,14 +683,15 @@ func (s *AslanMongoDashboardSource) loadRechargeStats(ctx context.Context, start
// 新用户充值:按 天×用户 聚合当日充值,再与当日注册 cohort 求交集cohort 自带国家归属。
perUserPipeline := mongo.Pipeline{
{{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{}
err = s.aggregateEach(ctx, aslanMongoInAppPurchaseCollection, perUserPipeline, func(row bson.M) error {
key := aslanMongoDocument(row["_id"])
@ -728,6 +728,41 @@ func (s *AslanMongoDashboardSource) loadRechargeStats(ctx context.Context, start
return nil
}
func aslanMongoRechargeCountryStages(userExpression any, countryFilter externalDashboardCountryFilter) mongo.Pipeline {
// Google 账单普遍没有 countryCodefinance 口径已按付款用户资料兜底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) {
for key, userSet := range distinctUsers {
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{
{"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"
rechargeAmountSource := "likei Mongo in_app_purchase_details amountUsd排除 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,国家码按账单 countryCode 优先、user_run_profile.countryCode 兜底"
mifaPaySource := "likei Mongo in_app_purchase_details factory.factoryCode!=GOOGLE排除 FREIGHT_GOLD全部三方渠道合计"
newUserRechargeSource := "likei Mongo 当日注册 cohort × in_app_purchase_details排除 FREIGHT_GOLD"
if !legacyRecharge {

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

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

View 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

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

View 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-serviceAslan/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")
}

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

View File

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

View File

@ -83,6 +83,7 @@ func (s *Store) AutoMigrate() error {
&model.TemporaryPaymentLinkOwner{},
&model.LegacyGooglePaidDetail{},
&model.FinanceApplication{},
&model.CoinSellerRechargeOrder{},
&model.UserWithdrawalApplication{},
&model.AdminJob{},
)

View File

@ -122,6 +122,10 @@ var defaultPermissions = []model.Permission{
{Name: "财务范围查看", Code: "finance:view", Kind: "menu"},
{Name: "财务申请发起", Code: "finance-application:create", 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-operation:user-coin-credit", 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-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: "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: "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},
@ -645,7 +650,7 @@ func defaultRolePermissionCodes(code string) []string {
"agency:view", "agency:create", "agency:status", "agency:delete",
"bd:view", "bd:create", "bd:update",
"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",
"game:view", "game:create", "game:update", "game:status", "game:delete",
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
@ -662,7 +667,7 @@ func defaultRolePermissionCodes(code string) []string {
"upload:create",
}
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":
return []string{
"overview:view",
@ -707,6 +712,7 @@ func defaultRolePermissionCodes(code string) []string {
"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",
@ -754,7 +760,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
"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",
"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",
"game:view", "game:create", "game:update", "game:status", "game:delete",
"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",
}
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:
return nil
}

View File

@ -20,6 +20,7 @@ import (
"hyapp-admin-server/internal/modules/dashboard"
"hyapp-admin-server/internal/modules/databi"
"hyapp-admin-server/internal/modules/financeapplication"
"hyapp-admin-server/internal/modules/financeorder"
"hyapp-admin-server/internal/modules/financewithdrawal"
"hyapp-admin-server/internal/modules/firstrechargereward"
"hyapp-admin-server/internal/modules/fullservernotice"
@ -82,6 +83,7 @@ type Handlers struct {
Databi *databi.Handler
FirstRechargeReward *firstrechargereward.Handler
FinanceApplication *financeapplication.Handler
FinanceOrder *financeorder.Handler
FinanceWithdrawal *financewithdrawal.Handler
FullServerNotice *fullservernotice.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)
firstrechargereward.RegisterRoutes(protected, h.FirstRechargeReward)
financeapplication.RegisterRoutes(protected, h.FinanceApplication)
financeorder.RegisterRoutes(protected, h.FinanceOrder)
financewithdrawal.RegisterRoutes(protected, h.FinanceWithdrawal)
fullservernotice.RegisterRoutes(protected, h.FullServerNotice)
registrationreward.RegisterRoutes(protected, h.RegistrationReward)

View File

@ -0,0 +1,119 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 币商充值订单是 admin 自己的跨 APP 绑定台账Lalu 入账走 wallet-serviceAslan/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';

View File

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

View File

@ -123,8 +123,8 @@ tencent_rtc:
tencent-cos:
# Docker 本地联调 App 文件/头像上传secret 只保存在 gateway 服务端配置。
enabled: true
secret-id: "IKIDMchhZEfrsiNo472DAtTpzzmLjttkOnyu"
secret-key: "nMkbLsGRO6ZqulSyJQJ0UjjU0KSKxOgl"
secret-id: "IKID8OgrsumTzIRO9j8LemiT3eAUceWGslW4"
secret-key: "ZQ5XyJSTJ9sPxLXMD2aFoBOQIzXv9C8s"
bucket-name: "yumi-assets-1420526837"
region: "me-saudi-arabia"
access-url: "https://media.haiyihy.com"

View File

@ -129,8 +129,8 @@ tencent_rtc:
tencent-cos:
# App 文件/头像上传使用腾讯云 COSsecret 只保存在 gateway 服务端配置。
enabled: true
secret-id: "IKIDMchhZEfrsiNo472DAtTpzzmLjttkOnyu"
secret-key: "nMkbLsGRO6ZqulSyJQJ0UjjU0KSKxOgl"
secret-id: "IKID8OgrsumTzIRO9j8LemiT3eAUceWGslW4"
secret-key: "ZQ5XyJSTJ9sPxLXMD2aFoBOQIzXv9C8s"
bucket-name: "yumi-assets-1420526837"
region: "me-saudi-arabia"
access-url: "https://media.haiyihy.com"

View File

@ -105,9 +105,39 @@ tron_grid:
usdt_contract_address: "TCSTWxGagPpCS8RaZeBXMW9d69gUsK9QGb"
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:
usdt_trc20_enabled: true
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_return_url: ""
v5pay_notify_url: "https://api-test.global-interaction.com/api/v1/payment/v5pay/notify"

View File

@ -104,9 +104,39 @@ tron_grid:
usdt_contract_address: "TCSTWxGagPpCS8RaZeBXMW9d69gUsK9QGb"
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:
usdt_trc20_enabled: true
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_return_url: "https://h5.global-interaction.com/recharge/index.html"
v5pay_notify_url: "https://api.global-interaction.com/api/v1/payment/v5pay/notify"

View File

@ -101,14 +101,40 @@ v5pay:
tron_grid:
enabled: true
api_base_url: "https://api.trongrid.io"
usdt_contract_address: "TCSTWxGagPpCS8RaZeBXMW9d69gUsK9QGb"
usdt_contract_address: "TNwqCkF8cnM4ХiwjnbbKTfdZv FDaVoa1TN"
http_timeout: "10s"
bian_api_secret_key: "hIOXHJ3dgIQQu0L5RVxxiPOltUP4gP3QAkhU2SzsCo9qhJvrkD2cF4BKMCqgFRHh"
bian_secret_key: "4TJPRJ0m6vVcwtpf4PjVTA9xRf2YZzYXIpaFsVLgTmE6JHo0ITpU7GD4QJolGWMB"
external_recharge:
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:
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_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"

View File

@ -17,6 +17,8 @@ import (
servicegrpc "hyapp/pkg/servicekit/grpcserver"
servicehealth "hyapp/pkg/servicekit/health"
"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/mifapay"
"hyapp/services/wallet-service/internal/client/trongrid"
@ -98,12 +100,16 @@ func New(cfg config.Config) (*App, error) {
server := servicegrpc.New("wallet-service")
svc := walletservice.New(repository, client.NewActivityAchievementClient(activityConn))
svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{
USDTTRC20Enabled: cfg.ExternalRecharge.USDTTRC20Enabled,
USDTTRC20Address: cfg.ExternalRecharge.USDTTRC20Address,
MifaPayNotifyURL: cfg.ExternalRecharge.MifaPayNotifyURL,
MifaPayReturnURL: cfg.ExternalRecharge.MifaPayReturnURL,
V5PayNotifyURL: cfg.ExternalRecharge.V5PayNotifyURL,
V5PayReturnURL: cfg.ExternalRecharge.V5PayReturnURL,
USDTTRC20Enabled: cfg.ExternalRecharge.USDTTRC20Enabled,
USDTTRC20Address: cfg.ExternalRecharge.USDTTRC20Address,
USDTTRC20Addresses: cfg.ExternalRecharge.USDTTRC20Addresses,
USDTBEP20Enabled: cfg.ExternalRecharge.USDTBEP20Enabled,
USDTBEP20Address: cfg.ExternalRecharge.USDTBEP20Address,
USDTBEP20Addresses: cfg.ExternalRecharge.USDTBEP20Addresses,
MifaPayNotifyURL: cfg.ExternalRecharge.MifaPayNotifyURL,
MifaPayReturnURL: cfg.ExternalRecharge.MifaPayReturnURL,
V5PayNotifyURL: cfg.ExternalRecharge.V5PayNotifyURL,
V5PayReturnURL: cfg.ExternalRecharge.V5PayReturnURL,
})
if cfg.GooglePlay.Enabled {
googleClient, err := googleplay.New(cfg.GooglePlay)
@ -143,6 +149,16 @@ func New(cfg config.Config) (*App, error) {
if cfg.TronGrid.Enabled {
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.RegisterWalletCronServiceServer(server, grpcserver.NewCronServer(svc))
health, healthHTTP, err := servicehealth.New(server, servicehealth.Config{

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

View 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&timestamp=1783345000000", "secret-lalu") {
t.Fatalf("signature must be based on encoded query string")
}
}

View 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"`
}

View File

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

View File

@ -2,6 +2,8 @@ package config
import (
"errors"
"os"
"path/filepath"
"strings"
"time"
@ -33,6 +35,10 @@ type Config struct {
V5Pay V5PayConfig `yaml:"v5pay"`
// TronGrid 控制 USDT-TRC20 tx_hash 链上校验。
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 ExternalRechargeConfig `yaml:"external_recharge"`
// ExternalRechargeReconcileWorker 控制三方支付回调丢失后的服务端查单补偿。
@ -173,14 +179,66 @@ type TronGridConfig struct {
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 保存外部充值页面和支付回调所需的公开配置。
type ExternalRechargeConfig struct {
USDTTRC20Enabled bool `yaml:"usdt_trc20_enabled"`
USDTTRC20Address string `yaml:"usdt_trc20_address"`
MifaPayNotifyURL string `yaml:"mifapay_notify_url"`
MifaPayReturnURL string `yaml:"mifapay_return_url"`
V5PayNotifyURL string `yaml:"v5pay_notify_url"`
V5PayReturnURL string `yaml:"v5pay_return_url"`
USDTTRC20Enabled bool `yaml:"usdt_trc20_enabled"`
USDTTRC20Address string `yaml:"usdt_trc20_address"`
USDTTRC20Addresses map[string]string `yaml:"usdt_trc20_addresses"`
USDTBEP20Enabled bool `yaml:"usdt_bep20_enabled"`
USDTBEP20Address string `yaml:"usdt_bep20_address"`
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 外部充值查单补偿策略。
@ -231,8 +289,21 @@ func Default() Config {
USDTContractAddress: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
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{
USDTTRC20Enabled: true,
USDTBEP20Enabled: false,
},
ExternalRechargeReconcileWorker: ExternalRechargeReconcileWorkerConfig{
Enabled: true,
@ -303,7 +374,10 @@ func Load(path string) (Config, error) {
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
}
@ -380,7 +454,22 @@ func Load(path string) (Config, error) {
if cfg.TronGrid.HTTPTimeout <= 0 {
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.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.MifaPayReturnURL = strings.TrimSpace(cfg.ExternalRecharge.MifaPayReturnURL)
cfg.ExternalRecharge.V5PayNotifyURL = strings.TrimSpace(cfg.ExternalRecharge.V5PayNotifyURL)
@ -423,6 +512,52 @@ func Load(path string) (Config, error) {
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 {
if cfg.AppCode = strings.TrimSpace(cfg.AppCode); cfg.AppCode == "" {
cfg.AppCode = defaults.AppCode
@ -492,6 +627,49 @@ func normalizeV5PayConfig(cfg V5PayConfig) V5PayConfig {
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 {
if value := firstNonEmpty(strings.TrimSpace(endpoint.MerchantNo), strings.TrimSpace(endpoint.MerNo)); value != "" {
cfg.MerchantNo = value

View File

@ -26,6 +26,9 @@ func TestLoadLocalEnablesOutboxMQ(t *testing.T) {
if cfg.OutboxWorker.Concurrency != 1 || cfg.RealtimeOutboxWorker.Concurrency != 2 {
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) {
@ -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) {
cases := []struct {
path string

View File

@ -62,6 +62,8 @@ const (
PaymentProviderV5Pay = "v5pay"
// PaymentProviderUSDTTRC20 是用户提交 TRC20 链上交易哈希的 USDT 充值渠道。
PaymentProviderUSDTTRC20 = "usdt_trc20"
// PaymentProviderUSDTBEP20 是用户提交 BSC BEP20 链上交易哈希的 USDT 充值渠道。
PaymentProviderUSDTBEP20 = "usdt_bep20"
// ThirdPartyPaymentStatusActive 表示渠道或支付方式可用于 H5 下单。
ThirdPartyPaymentStatusActive = "active"
// ThirdPartyPaymentStatusDisabled 表示渠道或支付方式已被后台关闭。

View File

@ -221,6 +221,36 @@ type H5RechargeOptions struct {
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 是后台临时支付链接列表的筛选条件。
type ListTemporaryExternalRechargeOrdersQuery struct {
AppCode string
@ -379,6 +409,8 @@ func NormalizePaymentProvider(provider string) string {
return PaymentProviderV5Pay
case PaymentProviderUSDTTRC20:
return PaymentProviderUSDTTRC20
case PaymentProviderUSDTBEP20:
return PaymentProviderUSDTBEP20
case PaymentProviderGooglePlay:
return PaymentProviderGooglePlay
default:

View File

@ -20,6 +20,12 @@ type V5PayClient = ports.V5PayClient
// TronUSDTClient 是旧门面包暴露的 TRON USDT client 端口别名。
type TronUSDTClient = ports.TronUSDTClient
// BSCUSDTClient 是旧门面包暴露的 BSC USDT client 端口别名。
type BSCUSDTClient = ports.BSCUSDTClient
// BinanceClient 是旧门面包暴露的 Binance 只读查单 client 端口别名。
type BinanceClient = ports.BinanceClient
type MifaPayCreateOrderRequest = ports.MifaPayCreateOrderRequest
type MifaPayCreateOrderResponse = ports.MifaPayCreateOrderResponse
type MifaPayQueryOrderRequest = ports.MifaPayQueryOrderRequest
@ -34,3 +40,6 @@ type V5PayNotifyData = ports.V5PayNotifyData
type V5PayProductTypesRequest = ports.V5PayProductTypesRequest
type V5PayProductType = ports.V5PayProductType
type V5PayProductTypesResponse = ports.V5PayProductTypesResponse
type BinanceDepositLookupRequest = ports.BinanceDepositLookupRequest
type BinanceDepositRecord = ports.BinanceDepositRecord

View File

@ -2,23 +2,59 @@ package wallet
import (
"strings"
"hyapp/pkg/appcode"
)
// ExternalRechargeConfig 保存 H5 外部充值需要公开给业务链路的运行配置。
type ExternalRechargeConfig struct {
USDTTRC20Enabled bool
USDTTRC20Address string
MifaPayNotifyURL string
MifaPayReturnURL string
V5PayNotifyURL string
V5PayReturnURL string
USDTTRC20Enabled bool
USDTTRC20Address string
USDTTRC20Addresses map[string]string
USDTBEP20Enabled bool
USDTBEP20Address string
USDTBEP20Addresses map[string]string
MifaPayNotifyURL string
MifaPayReturnURL string
V5PayNotifyURL string
V5PayReturnURL string
}
func normalizeExternalRechargeConfig(config ExternalRechargeConfig) ExternalRechargeConfig {
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.MifaPayReturnURL = strings.TrimSpace(config.MifaPayReturnURL)
config.V5PayNotifyURL = strings.TrimSpace(config.V5PayNotifyURL)
config.V5PayReturnURL = strings.TrimSpace(config.V5PayReturnURL)
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
}

View File

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

View File

@ -80,8 +80,9 @@ func (s *Service) ListH5RechargeOptions(ctx context.Context, query ledger.H5Rech
}
options.PaymentMethods = s.filterV5PayRuntimeProductTypes(ctx, filtered)
}
options.USDTTRC20Enabled = s.externalRecharge.USDTTRC20Enabled && s.externalRecharge.USDTTRC20Address != ""
options.USDTTRC20Address = s.externalRecharge.USDTTRC20Address
usdtTRC20Address := s.externalRecharge.usdtTRC20AddressForApp(query.AppCode)
options.USDTTRC20Enabled = s.externalRecharge.USDTTRC20Enabled && usdtTRC20Address != ""
options.USDTTRC20Address = usdtTRC20Address
return options, nil
}

View File

@ -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, "订单不存在")
}

View File

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

View File

@ -147,6 +147,35 @@ type V5PayProductTypesResponse struct {
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 只处理订单状态。
type MifaPayClient interface {
CreateOrder(ctx context.Context, req MifaPayCreateOrderRequest) (MifaPayCreateOrderResponse, error)
@ -166,3 +195,13 @@ type V5PayClient interface {
type TronUSDTClient interface {
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 APIservice 只关心入金记录是否匹配后台订单。
type BinanceClient interface {
FindUSDTDeposit(ctx context.Context, req BinanceDepositLookupRequest) (BinanceDepositRecord, error)
}

View File

@ -18,6 +18,8 @@ type Service struct {
mifaPay ports.MifaPayClient
v5Pay ports.V5PayClient
tronUSDT ports.TronUSDTClient
bscUSDT ports.BSCUSDTClient
binance ports.BinanceClient
externalRecharge ExternalRechargeConfig
now func() time.Time
}
@ -56,6 +58,16 @@ func (s *Service) SetTronUSDTClient(client ports.TronUSDTClient) {
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。
func (s *Service) SetExternalRechargeConfig(config ExternalRechargeConfig) {
s.externalRecharge = normalizeExternalRechargeConfig(config)

View File

@ -6716,12 +6716,19 @@ type fakeMifaPayClient struct {
createReq walletservice.MifaPayCreateOrderRequest
createResp walletservice.MifaPayCreateOrderResponse
queryReq walletservice.MifaPayQueryOrderRequest
queryReqs []walletservice.MifaPayQueryOrderRequest
queryResp walletservice.MifaPayQueryOrderResponse
querySeq []fakeMifaPayQueryResult
queryErr error
notifyData walletservice.MifaPayNotifyData
err error
}
type fakeMifaPayQueryResult struct {
resp walletservice.MifaPayQueryOrderResponse
err error
}
type fakeMifaPayProviderPayloadError struct {
cause error
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) {
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 {
return walletservice.MifaPayQueryOrderResponse{}, f.queryErr
}
@ -6769,7 +6781,9 @@ type fakeV5PayClient struct {
createReq walletservice.V5PayCreateOrderRequest
createResp walletservice.V5PayCreateOrderResponse
queryReq walletservice.V5PayQueryOrderRequest
queryReqs []walletservice.V5PayQueryOrderRequest
queryResp walletservice.V5PayQueryOrderResponse
querySeq []fakeV5PayQueryResult
notifyData walletservice.V5PayNotifyData
productReqs []walletservice.V5PayProductTypesRequest
productTypes map[string]walletservice.V5PayProductTypesResponse
@ -6777,6 +6791,11 @@ type fakeV5PayClient struct {
err error
}
type fakeV5PayQueryResult struct {
resp walletservice.V5PayQueryOrderResponse
err error
}
type fakeProviderPayloadError struct {
message 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) {
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 {
return walletservice.V5PayQueryOrderResponse{}, f.err
}

View File

@ -220,6 +220,27 @@ func (s *Server) GetH5RechargeOrder(ctx context.Context, req *walletv1.GetH5Rech
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。
func (s *Server) HandleMifapayNotify(ctx context.Context, req *walletv1.HandleMifapayNotifyRequest) (*walletv1.HandleMifapayNotifyResponse, error) {
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
}
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 {
resp := &walletv1.ThirdPartyPaymentChannel{
AppCode: channel.AppCode,