Compare commits

..

No commits in common. "b8cbb93183c163ae1489378e1283faeecccd1ad1" and "7515233227ea9d657694d5cfeb27cef2e3dd44ec" have entirely different histories.

57 changed files with 2250 additions and 7569 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1625,34 +1625,6 @@ 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;
@ -2372,7 +2344,6 @@ 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,7 +275,6 @@ 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"
@ -385,7 +384,6 @@ 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)
@ -1163,16 +1161,6 @@ 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)
@ -1523,7 +1511,6 @@ 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)
@ -1783,9 +1770,6 @@ 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")
}
@ -3220,24 +3204,6 @@ 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 {
@ -4027,10 +3993,6 @@ 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,8 +95,6 @@ services:
container_name: wallet-service
environment:
TZ: UTC
volumes:
- ./.env:/app/.env:ro
ports:
- "13004:13004"
- "13104:13104"

View File

@ -1,10 +1,7 @@
package configx
import (
"bufio"
"errors"
"os"
"strings"
"gopkg.in/yaml.v3"
)
@ -19,82 +16,3 @@ 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

@ -1,48 +0,0 @@
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,7 +44,6 @@ 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"
@ -199,11 +198,6 @@ 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 {
@ -335,7 +329,6 @@ 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(
@ -505,57 +498,6 @@ 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,21 +123,6 @@ 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,21 +120,6 @@ 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,40 +13,39 @@ 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"`
LegacyCoinSellerRechargeSources []LegacyCoinSellerRechargeSourceConfig `yaml:"legacy_coin_seller_recharge_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"`
FinanceNotifications FinanceNotificationsConfig `yaml:"finance_notifications"`
}
type MigrationConfig struct {
@ -107,18 +106,6 @@ 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"`
}
@ -329,22 +316,6 @@ 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,
@ -523,18 +494,6 @@ 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)
@ -657,20 +616,6 @@ 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")
}
@ -981,33 +926,6 @@ 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,7 +51,6 @@ 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)
@ -241,10 +240,6 @@ 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,22 +18,6 @@ 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"
@ -485,55 +469,6 @@ 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

@ -108,7 +108,8 @@ func (s *AslanMongoDashboardSource) coinSellerRechargeBillUnionSQL(query CoinSel
goldArgs := []any{s.sysOrigin}
freightWhere := []string{
"fbrw.sys_origin = ?",
"((fbrw.origin = 'PURCHASE' AND fbrw.type = 0) OR (fbrw.origin = 'DEDUCTION' AND fbrw.type = 1))",
"fbrw.origin = 'PURCHASE'",
"fbrw.type = 0",
"(COALESCE(fbrw.amount, 0) <> 0 OR COALESCE(fbrw.usd_quantity, 0) <> 0)",
"ubi.country_code IS NOT NULL",
"ubi.country_code != ''",
@ -131,7 +132,7 @@ func (s *AslanMongoDashboardSource) coinSellerRechargeBillUnionSQL(query CoinSel
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
goldWhere = append(goldWhere, "(CAST(ugcr.id AS CHAR) = ? OR CONCAT('aslan-gold-coin-', ugcr.id) = ? OR CAST(ugcr.user_id AS CHAR) = ?)")
goldArgs = append(goldArgs, keyword, keyword, keyword)
freightWhere = append(freightWhere, "(CAST(fbrw.id AS CHAR) = ? OR CONCAT('aslan-freight-', LOWER(fbrw.origin), '-', fbrw.id) = ? OR CAST(fbrw.user_id AS CHAR) = ?)")
freightWhere = append(freightWhere, "(CAST(fbrw.id AS CHAR) = ? OR CONCAT('aslan-freight-purchase-', fbrw.id) = ? OR CAST(fbrw.user_id AS CHAR) = ?)")
freightArgs = append(freightArgs, keyword, keyword, keyword)
}
if countryFilter.Applied {
@ -169,18 +170,11 @@ FROM user_gold_coin_recharge ugcr
INNER JOIN user_base_info ubi ON ubi.id = ugcr.user_id
WHERE ` + strings.Join(goldWhere, " AND ") + `
UNION ALL
SELECT CONCAT('aslan-freight-', LOWER(fbrw.origin), '-', fbrw.id) AS transaction_id,
CONCAT('freight_', LOWER(fbrw.origin)) AS source,
SELECT CONCAT('aslan-freight-purchase-', fbrw.id) AS transaction_id,
'freight_purchase' AS source,
fbrw.user_id,
UPPER(ubi.country_code) AS country_code,
CAST(CASE
WHEN fbrw.origin = 'DEDUCTION' OR fbrw.type = 1 THEN -ABS(CASE
WHEN fbrw.amount IS NOT NULL AND fbrw.amount <> 0 THEN fbrw.amount
ELSE COALESCE(fbrw.usd_quantity, 0)
END)
WHEN fbrw.amount IS NOT NULL AND fbrw.amount <> 0 THEN fbrw.amount
ELSE COALESCE(fbrw.usd_quantity, 0)
END AS CHAR) AS amount,
CAST(CASE WHEN fbrw.amount IS NOT NULL AND fbrw.amount <> 0 THEN fbrw.amount ELSE COALESCE(fbrw.usd_quantity, 0) END AS CHAR) AS amount,
fbrw.create_time AS created_at
FROM ` + quoteMySQLIdentifier(walletDatabase) + `.user_freight_balance_running_water fbrw
INNER JOIN user_base_info ubi ON ubi.id = fbrw.user_id

View File

@ -625,23 +625,24 @@ 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"])
@ -683,15 +684,14 @@ 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,41 +728,6 @@ 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))
@ -871,7 +836,7 @@ func (s *AslanMongoDashboardSource) loadLegacyCoinSellerRechargeStats(ctx contex
if err := s.loadLegacyGoldCoinRechargeStats(ctx, startDate, endDate, countryFilter, grid, distinctUsers, perUser); err != nil {
return err
}
if err := s.loadLegacyFreightStockRechargeStats(ctx, startDate, endDate, countryFilter, grid, distinctUsers, perUser); err != nil {
if err := s.loadLegacyFreightPurchaseRechargeStats(ctx, startDate, endDate, countryFilter, grid, distinctUsers, perUser); err != nil {
return err
}
return nil
@ -947,17 +912,17 @@ GROUP BY DATE_FORMAT(ugcr.create_time, '%Y-%m-%d'), UPPER(ubi.country_code), ugc
return nil
}
func (s *AslanMongoDashboardSource) loadLegacyFreightStockRechargeStats(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter, grid *legacyMongoGrid, distinctUsers map[aslanMongoDayCountryUsers]map[string]struct{}, perUser map[string]map[int64]int64) error {
func (s *AslanMongoDashboardSource) loadLegacyFreightPurchaseRechargeStats(ctx context.Context, startDate time.Time, endDate time.Time, countryFilter externalDashboardCountryFilter, grid *legacyMongoGrid, distinctUsers map[aslanMongoDayCountryUsers]map[string]struct{}, perUser map[string]map[int64]int64) error {
walletDatabase := strings.TrimSpace(s.legacyWalletDatabase)
if walletDatabase == "" {
return nil
}
// 后台“金币代理 > 发货”实际是给代理进货origin=PURCHASE/type=0表单金额落 amount
// “扣减”是库存/USDT 冲减origin=DEDUCTION/type=1amount 在源系统仍是正数,统计时必须转成负数。
// App/经销商路径可能只落 usd_quantity因此金额优先取 amount空值再回退 usd_quantity。
where := []string{
"fbrw.sys_origin = ?",
"((fbrw.origin = 'PURCHASE' AND fbrw.type = 0) OR (fbrw.origin = 'DEDUCTION' AND fbrw.type = 1))",
"fbrw.origin = 'PURCHASE'",
"fbrw.type = 0",
"fbrw.create_time >= ?",
"fbrw.create_time < ?",
"(COALESCE(fbrw.amount, 0) <> 0 OR COALESCE(fbrw.usd_quantity, 0) <> 0)",
@ -991,10 +956,6 @@ SELECT DATE_FORMAT(fbrw.create_time, '%Y-%m-%d') AS stat_day,
UPPER(ubi.country_code) AS country_code,
fbrw.user_id,
CAST(COALESCE(SUM(CASE
WHEN fbrw.origin = 'DEDUCTION' OR fbrw.type = 1 THEN -ABS(CASE
WHEN fbrw.amount IS NOT NULL AND fbrw.amount <> 0 THEN fbrw.amount
ELSE COALESCE(fbrw.usd_quantity, 0)
END)
WHEN fbrw.amount IS NOT NULL AND fbrw.amount <> 0 THEN fbrw.amount
ELSE COALESCE(fbrw.usd_quantity, 0)
END), 0) AS CHAR) AS amount
@ -1003,7 +964,7 @@ FROM `+quoteMySQLIdentifier(walletDatabase)+`.user_freight_balance_running_water
WHERE `+strings.Join(where, " AND ")+`
GROUP BY DATE_FORMAT(fbrw.create_time, '%Y-%m-%d'), UPPER(ubi.country_code), fbrw.user_id`, args...)
if err != nil {
return fmt.Errorf("query legacy freight stock recharge: %w", err)
return fmt.Errorf("query legacy freight purchase recharge: %w", err)
}
defer rows.Close()
@ -1013,7 +974,7 @@ GROUP BY DATE_FORMAT(fbrw.create_time, '%Y-%m-%d'), UPPER(ubi.country_code), fbr
var userID int64
var amountText string
if err := rows.Scan(&day, &countryCode, &userID, &amountText); err != nil {
return fmt.Errorf("scan legacy freight stock recharge: %w", err)
return fmt.Errorf("scan legacy freight purchase recharge: %w", err)
}
day = strings.TrimSpace(day)
countryCode = aslanMongoCountryCode(countryCode)
@ -1022,27 +983,24 @@ GROUP BY DATE_FORMAT(fbrw.create_time, '%Y-%m-%d'), UPPER(ubi.country_code), fbr
}
amount, err := decimalTextToMinor(amountText)
if err != nil {
return fmt.Errorf("parse legacy freight stock recharge amount for %s/%s/%d: %w", day, countryCode, userID, err)
return fmt.Errorf("parse legacy freight purchase recharge amount for %s/%s/%d: %w", day, countryCode, userID, err)
}
addLegacyCoinSellerRecharge(grid, distinctUsers, perUser, day, countryCode, userID, amount)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterate legacy freight stock recharge: %w", err)
return fmt.Errorf("iterate legacy freight purchase recharge: %w", err)
}
return nil
}
func addLegacyCoinSellerRecharge(grid *legacyMongoGrid, distinctUsers map[aslanMongoDayCountryUsers]map[string]struct{}, perUser map[string]map[int64]int64, day string, countryCode string, userID int64, amount int64) {
if amount == 0 {
if amount <= 0 {
return
}
metric := grid.at(day, countryCode)
metric.RechargeUSDMinor += amount
metric.CoinSellerRechargeUSDMinor += amount
// 扣减流水按负数冲减金额,但不是一次新的付费行为,不能增加付费用户或新用户充值。
if amount > 0 {
addLegacyRechargeUser(distinctUsers, perUser, day, countryCode, userID, amount)
}
addLegacyRechargeUser(distinctUsers, perUser, day, countryCode, userID, amount)
}
func addLegacyStandardRecharge(grid *legacyMongoGrid, distinctUsers map[aslanMongoDayCountryUsers]map[string]struct{}, perUser map[string]map[int64]int64, day string, countryCode string, userID int64, amount int64) {
@ -1790,10 +1748,10 @@ 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"},
{"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"},
}
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 兜底"
rechargeUsersSource := "likei Mongo in_app_purchase_details SUCCESS distinct acceptUserId排除 FREIGHT_GOLD"
rechargeAmountSource := "likei Mongo in_app_purchase_details amountUsd排除 FREIGHT_GOLD"
mifaPaySource := "likei Mongo in_app_purchase_details factory.factoryCode!=GOOGLE排除 FREIGHT_GOLD全部三方渠道合计"
newUserRechargeSource := "likei Mongo 当日注册 cohort × in_app_purchase_details排除 FREIGHT_GOLD"
if !legacyRecharge {
@ -1802,7 +1760,7 @@ func aslanMongoDashboardMetricSources(goldWater bool, legacyRecharge bool) []map
}
} else {
rechargeUsersSource += " + legacy MySQL order_other_recharge/user_gold_coin_recharge/user_freight_balance_running_water distinct user_id"
rechargeAmountSource += " + legacy MySQL order_other_recharge amount + user_gold_coin_recharge recharge_amount + user_freight_balance_running_water PURCHASE amount/usd_quantity - DEDUCTION amount/usd_quantity"
rechargeAmountSource += " + legacy MySQL order_other_recharge amount + user_gold_coin_recharge recharge_amount + user_freight_balance_running_water PURCHASE amount/usd_quantity"
mifaPaySource += " + legacy MySQL order_other_recharge amount"
newUserRechargeSource += "/legacy 其他充值/legacy 币商充值"
}

View File

@ -169,7 +169,7 @@ func TestAslanMongoLegacyOtherRechargeFeedsThirdPartyChannel(t *testing.T) {
}
}
func TestAslanMongoLegacyFreightStockAdjustmentsFeedCoinSellerChannel(t *testing.T) {
func TestAslanMongoLegacyFreightPurchaseRechargeFeedsCoinSellerChannel(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
@ -196,12 +196,11 @@ func TestAslanMongoLegacyFreightStockAdjustmentsFeedCoinSellerChannel(t *testing
mock.ExpectQuery("FROM user_gold_coin_recharge[\\s\\S]*ugcr\\.gold_coin_source = 2").
WithArgs("ATYOU", start, end).
WillReturnRows(sqlmock.NewRows([]string{"stat_day", "country_code", "user_id", "amount"}))
mock.ExpectQuery("FROM `atyou_wallet`\\.user_freight_balance_running_water fbrw[\\s\\S]*fbrw\\.origin = 'PURCHASE'[\\s\\S]*fbrw\\.origin = 'DEDUCTION'").
mock.ExpectQuery("FROM `atyou_wallet`\\.user_freight_balance_running_water fbrw[\\s\\S]*fbrw\\.origin = 'PURCHASE'[\\s\\S]*fbrw\\.type = 0").
WithArgs("ATYOU", start, end).
WillReturnRows(sqlmock.NewRows([]string{"stat_day", "country_code", "user_id", "amount"}).
AddRow("2026-07-05", "PH", int64(2069828597070528514), "2000.00").
AddRow("2026-07-05", "PK", int64(2071589123112779777), "25.47").
AddRow("2026-07-05", "SA", int64(2071589123112779778), "-1000.00"))
AddRow("2026-07-05", "PK", int64(2071589123112779777), "25.47"))
if err := source.loadLegacyCoinSellerRechargeStats(context.Background(), start, end, externalDashboardCountryFilter{}, grid, distinctUsers, perUser); err != nil {
t.Fatalf("loadLegacyCoinSellerRechargeStats: %v", err)
@ -219,12 +218,6 @@ func TestAslanMongoLegacyFreightStockAdjustmentsFeedCoinSellerChannel(t *testing
assertInt64(t, pkRow["coin_seller_recharge_usd_minor"], 2547)
assertInt64(t, pkRow["recharge_usd_minor"], 2547)
saRow := grid.metrics["2026-07-05"]["SA"].toExternal(nil).toMap()
assertInt64(t, saRow["coin_seller_recharge_usd_minor"], -100000)
assertInt64(t, saRow["recharge_usd_minor"], -100000)
assertInt64(t, saRow["user_recharge_usd_minor"], 0)
assertInt64(t, saRow["paid_users"], 0)
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
@ -251,11 +244,10 @@ func TestAslanMongoListCoinSellerRechargeBills(t *testing.T) {
queryPattern := "FROM user_gold_coin_recharge ugcr[\\s\\S]*UNION ALL[\\s\\S]*FROM `atyou_wallet`\\.user_freight_balance_running_water fbrw"
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM \\([\\s\\S]*"+queryPattern).
WithArgs("ATYOU", startArg, endArg, "ATYOU", startArg, endArg).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(int64(3)))
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(int64(2)))
mock.ExpectQuery("SELECT transaction_id, source, user_id, country_code, amount, created_at[\\s\\S]*"+queryPattern).
WithArgs("ATYOU", startArg, endArg, "ATYOU", startArg, endArg, 10, 0).
WillReturnRows(sqlmock.NewRows([]string{"transaction_id", "source", "user_id", "country_code", "amount", "created_at"}).
AddRow("aslan-freight-deduction-100", "freight_deduction", int64(2069828597070528516), "ae", "-1000.00", createdAt.Add(time.Minute)).
AddRow("aslan-freight-purchase-99", "freight_purchase", int64(2069828597070528514), "ph", "1327.90", createdAt).
AddRow("aslan-gold-coin-7", "gold_coin_recharge", int64(2069828597070528515), "SA", "42.00", createdAt.Add(-time.Hour)))
@ -268,21 +260,18 @@ func TestAslanMongoListCoinSellerRechargeBills(t *testing.T) {
if err != nil {
t.Fatalf("ListCoinSellerRechargeBills: %v", err)
}
if total != 3 || len(items) != 3 {
if total != 2 || len(items) != 2 {
t.Fatalf("unexpected result total=%d items=%+v", total, items)
}
if items[0].TransactionID != "aslan-freight-deduction-100" || items[0].Source != "freight_deduction" || items[0].CountryCode != "AE" {
if items[0].TransactionID != "aslan-freight-purchase-99" || items[0].Source != "freight_purchase" || items[0].CountryCode != "PH" {
t.Fatalf("unexpected first item identity: %+v", items[0])
}
if items[0].USDMinorAmount != -100000 || items[0].CreatedAtMS != createdAt.Add(time.Minute).UnixMilli() {
if items[0].USDMinorAmount != 132790 || items[0].CreatedAtMS != createdAt.UnixMilli() {
t.Fatalf("unexpected first item amount/time: %+v", items[0])
}
if items[1].TransactionID != "aslan-freight-purchase-99" || items[1].USDMinorAmount != 132790 {
if items[1].TransactionID != "aslan-gold-coin-7" || items[1].USDMinorAmount != 4200 {
t.Fatalf("unexpected second item: %+v", items[1])
}
if items[2].TransactionID != "aslan-gold-coin-7" || items[2].USDMinorAmount != 4200 {
t.Fatalf("unexpected third item: %+v", items[2])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
@ -311,31 +300,24 @@ func TestAslanMongoRechargeMatchExcludesFreightGold(t *testing.T) {
}
}
func TestAslanMongoLegacyRechargeHandlesZeroAndDeductionAmount(t *testing.T) {
func TestAslanMongoLegacyRechargeSkipsNonPositiveAmount(t *testing.T) {
start := time.Date(2026, 7, 5, 0, 0, 0, 0, time.UTC)
grid := newLegacyMongoGrid(start, start.AddDate(0, 0, 1))
distinctUsers := map[aslanMongoDayCountryUsers]map[string]struct{}{}
perUser := map[string]map[int64]int64{}
addLegacyStandardRecharge(grid, distinctUsers, perUser, "2026-07-05", "SA", 101, 0)
addLegacyCoinSellerRecharge(grid, distinctUsers, perUser, "2026-07-05", "SA", 102, 0)
if row := grid.metrics["2026-07-05"]["SA"]; row != nil {
t.Fatalf("zero legacy recharge should not create metric row: %#v", row)
}
addLegacyCoinSellerRecharge(grid, distinctUsers, perUser, "2026-07-05", "SA", 102, -100)
row := grid.metrics["2026-07-05"]["SA"]
if row == nil {
t.Fatalf("negative coin seller deduction should create metric row")
}
if row.RechargeUSDMinor != -100 || row.CoinSellerRechargeUSDMinor != -100 {
t.Fatalf("deduction should reduce recharge metrics: %#v", row)
if row != nil {
t.Fatalf("non-positive legacy recharge should not create metric row: %#v", row)
}
if len(distinctUsers) != 0 {
t.Fatalf("deduction should not create paid users: %#v", distinctUsers)
t.Fatalf("non-positive legacy recharge should not create paid users: %#v", distinctUsers)
}
if len(perUser) != 0 {
t.Fatalf("deduction should not create per-user recharge: %#v", perUser)
t.Fatalf("non-positive legacy recharge should not create per-user recharge: %#v", perUser)
}
}

View File

@ -1,146 +0,0 @@
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

@ -1,205 +0,0 @@
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

@ -1,275 +0,0 @@
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

@ -1,19 +0,0 @@
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

@ -1,910 +0,0 @@
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

@ -1,211 +0,0 @@
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

@ -296,7 +296,7 @@ func (s *MongoRechargeBillSource) listCoinSellerRechargeBills(ctx context.Contex
item := rechargeBillDTO{
AppCode: s.config.AppCode,
TransactionID: row.TransactionID,
RechargeType: legacyCoinSellerBillRechargeType(row.Source),
RechargeType: "coin_seller_stock_purchase",
Status: "succeeded",
ExternalRef: row.Source,
SellerUserID: row.UserID,
@ -318,15 +318,6 @@ func (s *MongoRechargeBillSource) listCoinSellerRechargeBills(ctx context.Contex
return items, total, nil
}
func legacyCoinSellerBillRechargeType(source string) string {
switch strings.ToLower(strings.TrimSpace(source)) {
case "freight_deduction", "coin_seller_stock_deduction":
return "coin_seller_stock_deduction"
default:
return "coin_seller_stock_purchase"
}
}
func (s *MongoRechargeBillSource) listBillsByFilter(ctx context.Context, filter bson.M, page int, pageSize int) ([]rechargeBillDTO, int64, error) {
total, err := s.collection.CountDocuments(ctx, filter)
if err != nil {

View File

@ -237,25 +237,15 @@ func (s *fakeDashboardRechargeSource) ListCoinSellerRechargeBills(_ context.Cont
func TestLegacyCoinSellerListUsesDashboardBillSource(t *testing.T) {
dashboardSource := &fakeDashboardRechargeSource{
appCode: "aslan",
billTotal: 2,
bills: []dashboard.CoinSellerRechargeBill{
{
TransactionID: "aslan-freight-purchase-99",
Source: "freight_purchase",
UserID: 2069828597070528514,
CountryCode: "PH",
USDMinorAmount: 132790,
CreatedAtMS: 1_783_530_000_000,
},
{
TransactionID: "aslan-freight-deduction-100",
Source: "freight_deduction",
UserID: 2069828597070528515,
CountryCode: "AE",
USDMinorAmount: -100000,
CreatedAtMS: 1_783_530_060_000,
},
},
billTotal: 1,
bills: []dashboard.CoinSellerRechargeBill{{
TransactionID: "aslan-freight-purchase-99",
Source: "freight_purchase",
UserID: 2069828597070528514,
CountryCode: "PH",
USDMinorAmount: 132790,
CreatedAtMS: 1_783_530_000_000,
}},
}
source := &MongoRechargeBillSource{
config: aslanBillSourceConfig(),
@ -264,11 +254,6 @@ func TestLegacyCoinSellerListUsesDashboardBillSource(t *testing.T) {
RegionID: 301,
Name: "Philippines",
Countries: []string{"PH"},
}, {
AppCode: "aslan",
RegionID: 302,
Name: "United Arab Emirates",
Countries: []string{"AE"},
}}}},
}
source.SetDashboardCoinSellerSource(dashboardSource)
@ -283,7 +268,7 @@ func TestLegacyCoinSellerListUsesDashboardBillSource(t *testing.T) {
if err != nil {
t.Fatalf("ListRechargeBills: %v", err)
}
if total != 2 || len(items) != 2 {
if total != 1 || len(items) != 1 {
t.Fatalf("unexpected result total=%d items=%+v", total, items)
}
item := items[0]
@ -299,19 +284,6 @@ func TestLegacyCoinSellerListUsesDashboardBillSource(t *testing.T) {
if item.UserPaidCurrencyCode != "USD" || item.UserPaidAmountMicro != 1_327_900_000 {
t.Fatalf("paid facts mismatch: %+v", item)
}
deduction := items[1]
if deduction.RechargeType != "coin_seller_stock_deduction" || deduction.ProviderCode != "coin_seller" {
t.Fatalf("unexpected deduction mapping: %+v", deduction)
}
if deduction.TransactionID != "aslan-freight-deduction-100" || deduction.SellerUserID != 2069828597070528515 || deduction.USDMinorAmount != -100000 {
t.Fatalf("unexpected deduction fields: %+v", deduction)
}
if deduction.SellerRegionID != 302 || deduction.TargetRegionID != 302 {
t.Fatalf("deduction region mapping failed: %+v", deduction)
}
if deduction.UserPaidCurrencyCode != "USD" || deduction.UserPaidAmountMicro != -1_000_000_000 {
t.Fatalf("deduction paid facts mismatch: %+v", deduction)
}
if dashboardSource.billCalls != 1 || dashboardSource.billQuery.PageSize != 20 || dashboardSource.billQuery.StatTZ != "Asia/Shanghai" {
t.Fatalf("dashboard bill source not called as expected: calls=%d query=%+v", dashboardSource.billCalls, dashboardSource.billQuery)
}

View File

@ -1,277 +0,0 @@
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,7 +83,6 @@ func (s *Store) AutoMigrate() error {
&model.TemporaryPaymentLinkOwner{},
&model.LegacyGooglePaidDetail{},
&model.FinanceApplication{},
&model.CoinSellerRechargeOrder{},
&model.UserWithdrawalApplication{},
&model.AdminJob{},
)

View File

@ -122,10 +122,6 @@ 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"},
@ -322,7 +318,6 @@ 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},
@ -650,7 +645,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-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",
"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",
"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",
@ -667,7 +662,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", "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"}
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"}
case "readonly":
return []string{
"overview:view",
@ -712,7 +707,6 @@ 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",
@ -760,7 +754,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-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",
"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",
"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",
@ -774,7 +768,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", "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"}
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"}
default:
return nil
}

View File

@ -20,7 +20,6 @@ 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"
@ -83,7 +82,6 @@ type Handlers struct {
Databi *databi.Handler
FirstRechargeReward *firstrechargereward.Handler
FinanceApplication *financeapplication.Handler
FinanceOrder *financeorder.Handler
FinanceWithdrawal *financewithdrawal.Handler
FullServerNotice *fullservernotice.Handler
Game *gamemanagement.Handler
@ -153,7 +151,6 @@ 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

@ -1,119 +0,0 @@
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

@ -1,11 +0,0 @@
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: "IKID8OgrsumTzIRO9j8LemiT3eAUceWGslW4"
secret-key: "ZQ5XyJSTJ9sPxLXMD2aFoBOQIzXv9C8s"
secret-id: "IKIDMchhZEfrsiNo472DAtTpzzmLjttkOnyu"
secret-key: "nMkbLsGRO6ZqulSyJQJ0UjjU0KSKxOgl"
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: "IKID8OgrsumTzIRO9j8LemiT3eAUceWGslW4"
secret-key: "ZQ5XyJSTJ9sPxLXMD2aFoBOQIzXv9C8s"
secret-id: "IKIDMchhZEfrsiNo472DAtTpzzmLjttkOnyu"
secret-key: "nMkbLsGRO6ZqulSyJQJ0UjjU0KSKxOgl"
bucket-name: "yumi-assets-1420526837"
region: "me-saudi-arabia"
access-url: "https://media.haiyihy.com"

View File

@ -105,39 +105,9 @@ 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,39 +104,9 @@ 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,40 +101,14 @@ v5pay:
tron_grid:
enabled: true
api_base_url: "https://api.trongrid.io"
usdt_contract_address: "TNwqCkF8cnM4ХiwjnbbKTfdZv FDaVoa1TN"
usdt_contract_address: "TCSTWxGagPpCS8RaZeBXMW9d69gUsK9QGb"
http_timeout: "10s"
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:
bian_api_secret_key: "hIOXHJ3dgIQQu0L5RVxxiPOltUP4gP3QAkhU2SzsCo9qhJvrkD2cF4BKMCqgFRHh"
bian_secret_key: "4TJPRJ0m6vVcwtpf4PjVTA9xRf2YZzYXIpaFsVLgTmE6JHo0ITpU7GD4QJolGWMB"
external_recharge:
usdt_trc20_enabled: true
usdt_trc20_address: "TNwqCkF8cnM4XiwjnbbKTfdZvFDqVoa1TN"
usdt_trc20_addresses:
lalu: "TNwqCkF8cnM4XiwjnbbKTfdZvFDqVoa1TN"
aslan: "TLv7wERsM42ZeobMrrKVC5C72d3gFUa44J"
yumi: "TCSTWxGagPpCS8RaZeBXMW9d69gUsK9QGb"
usdt_bep20_enabled: false
usdt_bep20_address: "TNwqCkF8cnM4ХiwjnbbKTfdZv FDaVoa1TN"
usdt_trc20_address: "cccccccccccc"
mifapay_notify_url: "http://127.0.0.1:13000/api/v1/payment/mifapay/notify"
mifapay_return_url: "http://127.0.0.1:7001/recharge/index.html"
v5pay_notify_url: "http://127.0.0.1:13000/api/v1/payment/v5pay/notify"

View File

@ -17,8 +17,6 @@ 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"
@ -100,16 +98,12 @@ 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,
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,
USDTTRC20Enabled: cfg.ExternalRecharge.USDTTRC20Enabled,
USDTTRC20Address: cfg.ExternalRecharge.USDTTRC20Address,
MifaPayNotifyURL: cfg.ExternalRecharge.MifaPayNotifyURL,
MifaPayReturnURL: cfg.ExternalRecharge.MifaPayReturnURL,
V5PayNotifyURL: cfg.ExternalRecharge.V5PayNotifyURL,
V5PayReturnURL: cfg.ExternalRecharge.V5PayReturnURL,
})
if cfg.GooglePlay.Enabled {
googleClient, err := googleplay.New(cfg.GooglePlay)
@ -149,16 +143,6 @@ 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

@ -1,360 +0,0 @@
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

@ -1,171 +0,0 @@
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

@ -1,178 +0,0 @@
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

@ -1,87 +0,0 @@
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

@ -64,7 +64,7 @@ func (c *Client) CreateOrder(ctx context.Context, req walletservice.MifaPayCreat
}
payer := rechargePayer(req)
data := map[string]any{
"amount": mifaPayProviderAmountValue(req.ProviderAmountMinor, req.CurrencyCode),
"amount": strconv.FormatInt(req.ProviderAmountMinor, 10),
"orderId": req.OrderID,
"payWay": req.PayWay,
"language": firstNonEmpty(req.Language, "en"),
@ -364,15 +364,6 @@ func productInfo(req walletservice.MifaPayCreateOrderRequest) string {
return string(body)
}
func mifaPayProviderAmountValue(providerAmountMinor int64, currencyCode string) string {
if strings.EqualFold(strings.TrimSpace(currencyCode), "INR") {
// MiFaPay India treats amount as whole INR units while wallet snapshots keep local minor units;
// converting at the client boundary keeps V5Pay and non-INR MiFaPay contracts unchanged.
return strconv.FormatInt(providerAmountMinor/100, 10)
}
return strconv.FormatInt(providerAmountMinor, 10)
}
type payerIdentity struct {
email string
firstName string

View File

@ -83,63 +83,6 @@ func TestCreateOrderMapsGatewayRejectionToUnavailable(t *testing.T) {
}
}
func TestCreateOrderFormatsINRAmountAsWholeMajorUnits(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.URL.Path != "/paygateway/mbpay/order/en_v2" {
t.Fatalf("unexpected mifapay path: %s", request.URL.Path)
}
raw, err := io.ReadAll(request.Body)
if err != nil {
t.Fatalf("read mifapay request body: %v", err)
}
var outer envelope
if err := json.Unmarshal(raw, &outer); err != nil {
t.Fatalf("decode mifapay request envelope: %v", err)
}
var data struct {
Amount string `json:"amount"`
Currency string `json:"currency"`
}
if err := json.Unmarshal([]byte(outer.Data), &data); err != nil {
t.Fatalf("decode mifapay request data: %v", err)
}
if data.Amount != "100" || data.Currency != "INR" {
t.Fatalf("mifapay INR amount should be whole major units: %+v", data)
}
writer.Header().Set("Content-Type", "application/json")
_, _ = writer.Write([]byte(`{"code":"100002","msg":"stop after request inspection"}`))
}))
defer server.Close()
cfg := testMifaPayConfig(t, server.URL)
client, err := New(cfg)
if err != nil {
t.Fatalf("new mifapay client failed: %v", err)
}
_, err = client.CreateOrder(context.Background(), walletservice.MifaPayCreateOrderRequest{
OrderID: "h5r_inr_test",
TargetUserID: 10001,
ProductName: "coin",
AmountMinor: 100,
CurrencyCode: "INR",
CountryCode: "IN",
PayWay: "Ewallet",
PayType: "Paytm",
NotifyURL: "https://api.example.com/api/v1/payment/mifapay/notify",
ReturnURL: "https://h5.example.com/recharge/index.html",
ClientIP: "127.0.0.1",
Language: "en",
ProviderAmountMinor: 10_000,
PayerName: "normal55",
PayerAccount: "163055",
PayerEmail: "163055@lalu.com",
})
if !xerr.IsCode(err, xerr.Unavailable) {
t.Fatalf("expected inspected MiFaPay rejection to map to UNAVAILABLE, got %v", err)
}
}
func TestQueryOrderUsesProviderOrderIDAndParsesSignedStatus(t *testing.T) {
var signer *Client
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {

View File

@ -2,8 +2,6 @@ package config
import (
"errors"
"os"
"path/filepath"
"strings"
"time"
@ -35,10 +33,6 @@ 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 控制三方支付回调丢失后的服务端查单补偿。
@ -179,66 +173,14 @@ 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"`
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"`
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"`
}
// ExternalRechargeReconcileWorkerConfig 保存 H5 外部充值查单补偿策略。
@ -289,21 +231,8 @@ 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,
@ -374,10 +303,7 @@ func Load(path string) (Config, error) {
return cfg, nil
}
if err := loadRuntimeEnv(path); err != nil {
return Config{}, err
}
if err := configx.LoadYAMLWithEnv(path, &cfg); err != nil {
if err := configx.LoadYAML(path, &cfg); err != nil {
return Config{}, err
}
@ -454,22 +380,7 @@ 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)
@ -512,52 +423,6 @@ 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
@ -627,49 +492,6 @@ 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,9 +26,6 @@ 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) {
@ -53,46 +50,6 @@ 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,8 +62,6 @@ const (
PaymentProviderV5Pay = "v5pay"
// PaymentProviderUSDTTRC20 是用户提交 TRC20 链上交易哈希的 USDT 充值渠道。
PaymentProviderUSDTTRC20 = "usdt_trc20"
// PaymentProviderUSDTBEP20 是用户提交 BSC BEP20 链上交易哈希的 USDT 充值渠道。
PaymentProviderUSDTBEP20 = "usdt_bep20"
// ThirdPartyPaymentStatusActive 表示渠道或支付方式可用于 H5 下单。
ThirdPartyPaymentStatusActive = "active"
// ThirdPartyPaymentStatusDisabled 表示渠道或支付方式已被后台关闭。

View File

@ -221,36 +221,6 @@ 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
@ -409,8 +379,6 @@ func NormalizePaymentProvider(provider string) string {
return PaymentProviderV5Pay
case PaymentProviderUSDTTRC20:
return PaymentProviderUSDTTRC20
case PaymentProviderUSDTBEP20:
return PaymentProviderUSDTBEP20
case PaymentProviderGooglePlay:
return PaymentProviderGooglePlay
default:

View File

@ -20,12 +20,6 @@ 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
@ -40,6 +34,3 @@ 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,59 +2,23 @@ package wallet
import (
"strings"
"hyapp/pkg/appcode"
)
// ExternalRechargeConfig 保存 H5 外部充值需要公开给业务链路的运行配置。
type ExternalRechargeConfig struct {
USDTTRC20Enabled bool
USDTTRC20Address string
USDTTRC20Addresses map[string]string
USDTBEP20Enabled bool
USDTBEP20Address string
USDTBEP20Addresses map[string]string
MifaPayNotifyURL string
MifaPayReturnURL string
V5PayNotifyURL string
V5PayReturnURL string
USDTTRC20Enabled bool
USDTTRC20Address 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,11 +43,10 @@ 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) {
receiveAddress := s.externalRecharge.usdtTRC20AddressForApp(command.AppCode)
if !s.externalRecharge.USDTTRC20Enabled || receiveAddress == "" {
if !s.externalRecharge.USDTTRC20Enabled || s.externalRecharge.USDTTRC20Address == "" {
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "usdt trc20 recharge is not configured")
}
return s.repository.CreateExternalRechargeOrder(ctx, command, product, nil, amountMicroToUSDMinor(product.AmountMicro), receiveAddress)
return s.repository.CreateExternalRechargeOrder(ctx, command, product, nil, amountMicroToUSDMinor(product.AmountMicro), s.externalRecharge.USDTTRC20Address)
}
func (s *Service) createMifaPayRechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand, product ledger.RechargeProduct) (ledger.ExternalRechargeOrder, error) {
@ -61,7 +60,7 @@ func (s *Service) createMifaPayRechargeOrder(ctx context.Context, command ledger
if err := validateMifaPayMethodForOrder(method, command); err != nil {
return ledger.ExternalRechargeOrder{}, err
}
providerAmountMinor, err := calculateMifaPayProviderAmountMinor(amountMicroToUSDMinor(product.AmountMicro), method.USDToCurrencyRate, method.CurrencyCode)
providerAmountMinor, err := calculateProviderAmountMinor(amountMicroToUSDMinor(product.AmountMicro), method.USDToCurrencyRate, method.CurrencyCode)
if err != nil {
return ledger.ExternalRechargeOrder{}, err
}

View File

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

View File

@ -1,413 +0,0 @@
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

@ -1,400 +0,0 @@
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

@ -43,7 +43,7 @@ func (s *Service) createTemporaryMifaPayRechargeOrder(ctx context.Context, comma
return ledger.ExternalRechargeOrder{}, err
}
// 临时链接没有商品档位,三方实付金额只能由运营输入的 USD 金额和支付方式汇率即时换算;订单快照会保存换算结果。
providerAmountMinor, err := calculateMifaPayProviderAmountMinor(command.USDMinorAmount, method.USDToCurrencyRate, method.CurrencyCode)
providerAmountMinor, err := calculateProviderAmountMinor(command.USDMinorAmount, method.USDToCurrencyRate, method.CurrencyCode)
if err != nil {
return ledger.ExternalRechargeOrder{}, err
}
@ -205,7 +205,7 @@ func validateMifaPayMethodForTemporaryOrder(method ledger.ThirdPartyPaymentMetho
if method.ProviderCode != ledger.PaymentProviderMifaPay || method.Status != ledger.ThirdPartyPaymentStatusActive || method.MethodID != command.PaymentMethodID {
return xerr.New(xerr.Conflict, "mifapay method is not active")
}
if _, err := calculateMifaPayProviderAmountMinor(command.USDMinorAmount, method.USDToCurrencyRate, method.CurrencyCode); err != nil {
if _, err := calculateProviderAmountMinor(command.USDMinorAmount, method.USDToCurrencyRate, method.CurrencyCode); err != nil {
return err
}
return nil

View File

@ -116,7 +116,7 @@ func validateMifaPayMethodForOrder(method ledger.ThirdPartyPaymentMethod, comman
if method.CountryCode != "GLOBAL" && !strings.EqualFold(method.CountryCode, command.TargetCountryCode) {
return xerr.New(xerr.Conflict, "mifapay method country does not match")
}
if _, err := calculateMifaPayProviderAmountMinor(100, method.USDToCurrencyRate, method.CurrencyCode); err != nil {
if _, err := calculateProviderAmountMinor(100, method.USDToCurrencyRate, method.CurrencyCode); err != nil {
return err
}
return nil
@ -154,39 +154,6 @@ func calculateProviderAmountMinor(usdMinor int64, rate string, currencyCode stri
return int64(math.Round(float64(usdMinor) * value)), nil
}
const mifaPayINRMinimumMajorAmount = int64(100)
func calculateMifaPayProviderAmountMinor(usdMinor int64, rate string, currencyCode string) (int64, error) {
if !mifaPayUsesWholeMajorAmount(currencyCode) {
return calculateProviderAmountMinor(usdMinor, rate, currencyCode)
}
if usdMinor <= 0 {
return 0, xerr.New(xerr.InvalidArgument, "usd amount is invalid")
}
value, err := strconv.ParseFloat(strings.TrimSpace(rate), 64)
if err != nil || value <= 0 {
return 0, xerr.New(xerr.InvalidArgument, "payment exchange rate is invalid")
}
majorAmount := int64(math.Round(float64(usdMinor) / 100 * value))
if strings.EqualFold(currencyCode, "INR") && majorAmount < mifaPayINRMinimumMajorAmount {
// MiFaPay 印度通道要求 amount 是整数卢比,且当前网关最小金额为 100 INR
// 本地仍按 minor 保存 10000回调和账单都能表达实际向三方提交的收款金额。
majorAmount = mifaPayINRMinimumMajorAmount
}
return majorAmount * 100, nil
}
func mifaPayProviderAmountValue(providerAmountMinor int64, currencyCode string) string {
if mifaPayUsesWholeMajorAmount(currencyCode) {
return strconv.FormatInt(providerAmountMinor/100, 10)
}
return strconv.FormatInt(providerAmountMinor, 10)
}
func mifaPayUsesWholeMajorAmount(currencyCode string) bool {
return strings.EqualFold(strings.TrimSpace(currencyCode), "INR")
}
func providerCurrencyScale(currencyCode string) int {
switch strings.ToUpper(strings.TrimSpace(currencyCode)) {
case "BIF", "CLP", "DJF", "GNF", "IDR", "JPY", "KMF", "KRW", "MGA", "PYG", "RWF", "UGX", "VND", "VUV", "XAF", "XOF", "XPF":
@ -215,7 +182,8 @@ func mifaPayNotifyMatchesOrder(data MifaPayNotifyData, order ledger.ExternalRech
if data.PayType != "" && !strings.EqualFold(data.PayType, order.PayType) {
return false
}
return strings.TrimSpace(data.Amount) == mifaPayProviderAmountValue(order.ProviderAmountMinor, order.CurrencyCode)
amount, err := strconv.ParseInt(strings.TrimSpace(data.Amount), 10, 64)
return err == nil && amount == order.ProviderAmountMinor
}
func mifaPayQueryMatchesOrder(data MifaPayNotifyData, order ledger.ExternalRechargeOrder) bool {
@ -228,7 +196,8 @@ func mifaPayQueryMatchesOrder(data MifaPayNotifyData, order ledger.ExternalRecha
if data.PayType != "" && !strings.EqualFold(data.PayType, order.PayType) {
return false
}
return strings.TrimSpace(data.Amount) == mifaPayProviderAmountValue(order.ProviderAmountMinor, order.CurrencyCode)
amount, err := strconv.ParseInt(strings.TrimSpace(data.Amount), 10, 64)
return err == nil && amount == order.ProviderAmountMinor
}
func v5PayOrderDataMatchesOrder(data V5PayNotifyData, order ledger.ExternalRechargeOrder) bool {

View File

@ -147,35 +147,6 @@ 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)
@ -195,13 +166,3 @@ 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,8 +18,6 @@ type Service struct {
mifaPay ports.MifaPayClient
v5Pay ports.V5PayClient
tronUSDT ports.TronUSDTClient
bscUSDT ports.BSCUSDTClient
binance ports.BinanceClient
externalRecharge ExternalRechargeConfig
now func() time.Time
}
@ -58,16 +56,6 @@ 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

@ -2700,57 +2700,6 @@ func TestH5MifaPayOrderRoundsZeroDecimalCurrency(t *testing.T) {
}
}
// TestH5MifaPayOrderUsesWholeINRProviderAmount 锁定印度 MiFaPay 的金额边界:上游 amount 传整数卢比,
// 且 1 USD 档位按 99 INR 汇率会低于网关 100 INR 下限,本地订单必须保存实际提交的 100.00 INR。
func TestH5MifaPayOrderUsesWholeINRProviderAmount(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
mifaPay := &fakeMifaPayClient{}
svc.SetMifaPayClient(mifaPay)
svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{MifaPayNotifyURL: "https://api.example.com/api/v1/payment/mifapay/notify"})
product := repository.CreateH5RechargeProduct(ledger.RechargeAudienceNormal, 7210, 1_000_000, 80300)
inPaytmMethodID := repository.ThirdPartyPaymentMethodID("IN", "Ewallet", "Paytm")
repository.SetThirdPartyPaymentRate(inPaytmMethodID, "99.00000000")
order, err := svc.CreateH5RechargeOrder(context.Background(), ledger.CreateExternalRechargeOrderCommand{
AppCode: appcode.Default,
CommandID: "cmd-h5-mifapay-inr-minimum",
TargetUserID: 42003,
TargetRegionID: 7210,
TargetCountryCode: "IN",
AudienceType: ledger.RechargeAudienceNormal,
ProductID: product.ProductID,
ProviderCode: ledger.PaymentProviderMifaPay,
PaymentMethodID: inPaytmMethodID,
PayerName: "normal55",
PayerAccount: "163055",
PayerEmail: "normal55@lalu.com",
})
if err != nil {
t.Fatalf("CreateH5RechargeOrder INR failed: %v", err)
}
if order.ProviderAmountMinor != 10_000 || mifaPay.createReq.ProviderAmountMinor != 10_000 {
t.Fatalf("INR amount must respect MiFaPay 100 INR minimum: order=%+v req=%+v", order, mifaPay.createReq)
}
mifaPay.queryResp = walletservice.MifaPayQueryOrderResponse{
OrderID: order.OrderID,
ProviderOrderID: order.ProviderOrderID,
Status: "1",
Amount: "100",
Currency: order.CurrencyCode,
PayType: order.PayType,
RawJSON: `{"status":"1","amount":"100","currency":"INR"}`,
}
credited, err := svc.GetH5RechargeOrder(context.Background(), appcode.Default, order.OrderID, order.TargetUserID)
if err != nil {
t.Fatalf("GetH5RechargeOrder INR query failed: %v", err)
}
if credited.Status != ledger.ExternalRechargeStatusCredited || credited.ProviderAmountMinor != 10_000 {
t.Fatalf("INR query should match whole-major MiFaPay amount and credit: %+v", credited)
}
}
// TestH5MifaPayOrderRejectsUpstreamFailureAsUnavailable 锁定 MiFaPay 网关拒单的边界:本地订单要落失败便于排障,
// 对外错误必须保持上游依赖失败语义,不能伪装成商品、幂等或状态冲突。
func TestH5MifaPayOrderRejectsUpstreamFailureAsUnavailable(t *testing.T) {
@ -6716,19 +6665,12 @@ 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
@ -6759,11 +6701,6 @@ 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
}
@ -6781,9 +6718,7 @@ 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
@ -6791,11 +6726,6 @@ type fakeV5PayClient struct {
err error
}
type fakeV5PayQueryResult struct {
resp walletservice.V5PayQueryOrderResponse
err error
}
type fakeProviderPayloadError struct {
message string
rawJSON string
@ -6822,11 +6752,6 @@ 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,27 +220,6 @@ 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())
@ -276,23 +255,6 @@ 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,