From 764caa21939d997026bf116ec026e1a154793387 Mon Sep 17 00:00:00 2001 From: zhx Date: Fri, 17 Jul 2026 18:38:10 +0800 Subject: [PATCH] feat(finance): support cross-app withdrawal review --- server/admin/cmd/server/main.go | 3 +- .../admin/configs/config.tencent.example.yaml | 9 + server/admin/configs/config.yaml | 10 + server/admin/internal/config/config.go | 80 +++++++ .../integration/withdrawalsource/client.go | 114 +++++++++ server/admin/internal/model/models.go | 2 + .../modules/financewithdrawal/handler.go | 50 +++- .../modules/financewithdrawal/response.go | 4 + .../modules/financewithdrawal/routes.go | 7 + .../modules/financewithdrawal/service.go | 216 ++++++++++++++---- .../withdrawal_application_repository.go | 58 +++++ server/admin/internal/router/router.go | 1 + .../104_external_withdrawal_sources.sql | 63 +++++ .../internal/financewithdrawal/writer.go | 6 +- .../internal/financewithdrawal/writer_test.go | 2 + 15 files changed, 578 insertions(+), 47 deletions(-) create mode 100644 server/admin/internal/integration/withdrawalsource/client.go create mode 100644 server/admin/migrations/104_external_withdrawal_sources.sql diff --git a/server/admin/cmd/server/main.go b/server/admin/cmd/server/main.go index dec1773d..87d3995f 100644 --- a/server/admin/cmd/server/main.go +++ b/server/admin/cmd/server/main.go @@ -26,6 +26,7 @@ import ( "hyapp-admin-server/internal/integration/statisticsclient" "hyapp-admin-server/internal/integration/userclient" "hyapp-admin-server/internal/integration/walletclient" + "hyapp-admin-server/internal/integration/withdrawalsource" jobrunner "hyapp-admin-server/internal/job" "hyapp-admin-server/internal/migration" achievementconfigmodule "hyapp-admin-server/internal/modules/achievementconfig" @@ -384,7 +385,7 @@ func main() { financeordermodule.WithLegacyCoinSellerRegionResolver(func(ctx context.Context, appCode string, countryCode string) (int64, bool, error) { return paymentmodule.RegionIDForCountryCode(ctx, moneyRegionSources, appCode, countryCode) })), - FinanceWithdrawal: financewithdrawalmodule.New(store, walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn), auditHandler), + FinanceWithdrawal: financewithdrawalmodule.NewWithSources(store, walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn), auditHandler, withdrawalsource.New(cfg.WithdrawalSources)), ExternalAdmin: externaladminmodule.New(db, userDB, externaladminmodule.Config{ SessionTTL: cfg.ExternalAdmin.SessionTTL, MaxLoginFailures: cfg.ExternalAdmin.MaxLoginFailures, diff --git a/server/admin/configs/config.tencent.example.yaml b/server/admin/configs/config.tencent.example.yaml index d3158544..81350cf2 100644 --- a/server/admin/configs/config.tencent.example.yaml +++ b/server/admin/configs/config.tencent.example.yaml @@ -69,6 +69,15 @@ finance_google_paid_sync: poll_interval: "15s" batch_size: 100 request_timeout: "2m" +# 生产通过 HYAPP_ADMIN_ASLAN_WITHDRAWAL_* 注入密钥、回调地址并开启。 +withdrawal_sources: + - enabled: false + app_code: "aslan" + source_system: "likei" + submit_token: "" + callback_url: "" + callback_token: "" + request_timeout: "5s" mysql_auto_migrate: false migrations: enabled: true diff --git a/server/admin/configs/config.yaml b/server/admin/configs/config.yaml index 766612ec..309a417d 100644 --- a/server/admin/configs/config.yaml +++ b/server/admin/configs/config.yaml @@ -67,6 +67,16 @@ finance_google_paid_sync: poll_interval: "15s" batch_size: 100 request_timeout: "2m" +# 外部钱包统一提现双审来源。生产密钥和回调地址只从 +# HYAPP_ADMIN_ASLAN_WITHDRAWAL_* 环境变量注入,仓库不保存资金接口凭证。 +withdrawal_sources: + - enabled: false + app_code: "aslan" + source_system: "likei" + submit_token: "" + callback_url: "" + callback_token: "" + request_timeout: "5s" mysql_auto_migrate: true migrations: enabled: true diff --git a/server/admin/internal/config/config.go b/server/admin/internal/config/config.go index c3eb684c..5bc49416 100644 --- a/server/admin/internal/config/config.go +++ b/server/admin/internal/config/config.go @@ -52,6 +52,7 @@ type Config struct { StatisticsService StatisticsServiceConfig `yaml:"statistics_service"` DashboardExternalSources []DashboardExternalSourceConfig `yaml:"dashboard_external_sources"` LegacyCoinSellerRechargeSources []LegacyCoinSellerRechargeSourceConfig `yaml:"legacy_coin_seller_recharge_sources"` + WithdrawalSources []WithdrawalSourceConfig `yaml:"withdrawal_sources"` } type MigrationConfig struct { @@ -115,6 +116,18 @@ type StatisticsServiceConfig struct { RequestTimeout time.Duration `yaml:"request_timeout"` } +// WithdrawalSourceConfig 描述接入统一双审工作台的外部钱包系统。 +// submit_token 只允许来源 App 创建自己的申请;callback_token 用于 admin 终审时回调来源钱包,二者分离避免任一方向泄漏后扩大权限。 +type WithdrawalSourceConfig struct { + Enabled bool `yaml:"enabled"` + AppCode string `yaml:"app_code"` + SourceSystem string `yaml:"source_system"` + SubmitToken string `yaml:"submit_token"` + CallbackURL string `yaml:"callback_url"` + CallbackToken string `yaml:"callback_token"` + RequestTimeout time.Duration `yaml:"request_timeout"` +} + type DashboardExternalSourceConfig struct { Enabled bool `yaml:"enabled"` SourceType string `yaml:"type"` @@ -385,6 +398,14 @@ func Default() Config { RequestTimeout: 5 * time.Second, }, }, + WithdrawalSources: []WithdrawalSourceConfig{ + { + Enabled: false, + AppCode: "aslan", + SourceSystem: "likei", + RequestTimeout: 5 * time.Second, + }, + }, FinanceGooglePaidSync: FinanceGooglePaidSyncConfig{ Enabled: true, PollInterval: 15 * time.Second, @@ -623,6 +644,18 @@ func (cfg *Config) Normalize() { } } cfg.applyLegacyCoinSellerRechargeSourceEnvOverrides() + for index := range cfg.WithdrawalSources { + source := &cfg.WithdrawalSources[index] + source.AppCode = strings.ToLower(strings.TrimSpace(source.AppCode)) + source.SourceSystem = strings.ToLower(strings.TrimSpace(source.SourceSystem)) + source.SubmitToken = strings.TrimSpace(source.SubmitToken) + source.CallbackURL = strings.TrimRight(strings.TrimSpace(source.CallbackURL), "/") + source.CallbackToken = strings.TrimSpace(source.CallbackToken) + if source.RequestTimeout <= 0 { + source.RequestTimeout = 5 * time.Second + } + } + cfg.applyWithdrawalSourceEnvOverrides() cfg.RobotProfileSource.MySQLDSN = strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN) if cfg.RobotProfileSource.RequestTimeout <= 0 { cfg.RobotProfileSource.RequestTimeout = 5 * time.Second @@ -904,6 +937,26 @@ func (cfg Config) Validate() error { return fmt.Errorf("dashboard_external_sources[%d].request_timeout must be greater than 0", index) } } + for index, source := range cfg.WithdrawalSources { + if !source.Enabled { + continue + } + if strings.TrimSpace(source.AppCode) == "" || strings.TrimSpace(source.SourceSystem) == "" { + return fmt.Errorf("withdrawal_sources[%d] app_code and source_system are required when enabled", index) + } + if strings.TrimSpace(source.SubmitToken) == "" || containsConfigPlaceholder(source.SubmitToken) { + return fmt.Errorf("withdrawal_sources[%d].submit_token must be injected when enabled", index) + } + if strings.TrimSpace(source.CallbackToken) == "" || containsConfigPlaceholder(source.CallbackToken) { + return fmt.Errorf("withdrawal_sources[%d].callback_token must be injected when enabled", index) + } + if !strings.HasPrefix(source.CallbackURL, "http://") && !strings.HasPrefix(source.CallbackURL, "https://") { + return fmt.Errorf("withdrawal_sources[%d].callback_url must be an absolute url", index) + } + if source.RequestTimeout <= 0 { + return fmt.Errorf("withdrawal_sources[%d].request_timeout must be greater than 0", index) + } + } if isLockedEnvironment(cfg.Environment) && strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN) == "" { return errors.New("robot_profile_source.mysql_dsn is required outside local/dev") } @@ -1114,6 +1167,33 @@ func (cfg *Config) applyLegacyCoinSellerRechargeSourceEnvOverrides() { } } +func (cfg *Config) applyWithdrawalSourceEnvOverrides() { + for index := range cfg.WithdrawalSources { + source := &cfg.WithdrawalSources[index] + appKey := envAppKey(source.AppCode) + if appKey == "" { + continue + } + prefix := "HYAPP_ADMIN_" + appKey + "_WITHDRAWAL_" + if value := strings.TrimSpace(os.Getenv(prefix + "ENABLED")); value != "" { + source.Enabled = parseBoolEnv(value) + } + if value := strings.TrimSpace(os.Getenv(prefix + "SOURCE_SYSTEM")); value != "" { + source.SourceSystem = value + } + if value := strings.TrimSpace(os.Getenv(prefix + "SUBMIT_TOKEN")); value != "" { + // 双向令牌都属于资金系统密钥,只允许通过运行环境注入,不能写进仓库配置。 + source.SubmitToken = value + } + if value := strings.TrimSpace(os.Getenv(prefix + "CALLBACK_URL")); value != "" { + source.CallbackURL = value + } + if value := strings.TrimSpace(os.Getenv(prefix + "CALLBACK_TOKEN")); value != "" { + source.CallbackToken = value + } + } +} + func envAppKey(appCode string) string { appCode = strings.ToUpper(strings.TrimSpace(appCode)) if appCode == "" { diff --git a/server/admin/internal/integration/withdrawalsource/client.go b/server/admin/internal/integration/withdrawalsource/client.go new file mode 100644 index 00000000..76008ef2 --- /dev/null +++ b/server/admin/internal/integration/withdrawalsource/client.go @@ -0,0 +1,114 @@ +package withdrawalsource + +import ( + "bytes" + "context" + "crypto/subtle" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "hyapp-admin-server/internal/config" +) + +const maxErrorBodyBytes = 2048 + +type DecisionRequest struct { + SourceApplicationID string `json:"sourceApplicationId"` + Decision string `json:"decision"` + CredentialURLs []string `json:"credentialUrls"` + Remark string `json:"remark"` + ReviewerUserID uint `json:"reviewerUserId"` + ReviewerName string `json:"reviewerName"` + CommandID string `json:"commandId"` +} + +type Registry struct { + sources map[string]source +} + +type source struct { + config config.WithdrawalSourceConfig + client *http.Client +} + +func New(configs []config.WithdrawalSourceConfig) *Registry { + registry := &Registry{sources: make(map[string]source)} + for _, item := range configs { + if !item.Enabled { + continue + } + key := sourceKey(item.AppCode, item.SourceSystem) + registry.sources[key] = source{ + config: item, + client: &http.Client{Timeout: item.RequestTimeout}, + } + } + return registry +} + +// AuthenticateSubmit 对 app_code + source_system 组合做独立令牌校验。 +// 常量时间比较避免从鉴权耗时侧信道逐字符猜测资金接口令牌。 +func (r *Registry) AuthenticateSubmit(appCode string, sourceSystem string, authorization string) bool { + item, ok := r.lookup(appCode, sourceSystem) + if !ok { + return false + } + authorization = strings.TrimSpace(authorization) + if !strings.HasPrefix(authorization, "Bearer ") { + return false + } + provided := strings.TrimSpace(strings.TrimPrefix(authorization, "Bearer ")) + expected := strings.TrimSpace(item.config.SubmitToken) + return provided != "" && len(provided) == len(expected) && subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) == 1 +} + +func (r *Registry) HasSource(appCode string, sourceSystem string) bool { + _, ok := r.lookup(appCode, sourceSystem) + return ok +} + +// ApplyDecision 只负责把终态回调来源钱包;来源端必须按 commandId 幂等。 +// admin 本地状态只有在 2xx 后才会提交,因此任何网络错误都会保留可重试的审核状态。 +func (r *Registry) ApplyDecision(ctx context.Context, appCode string, sourceSystem string, request DecisionRequest) (string, error) { + item, ok := r.lookup(appCode, sourceSystem) + if !ok { + return "", errors.New("withdrawal source is not configured") + } + body, err := json.Marshal(request) + if err != nil { + return "", fmt.Errorf("encode withdrawal source decision: %w", err) + } + httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, item.config.CallbackURL, bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("build withdrawal source decision: %w", err) + } + httpRequest.Header.Set("Authorization", "Bearer "+item.config.CallbackToken) + httpRequest.Header.Set("Content-Type", "application/json") + response, err := item.client.Do(httpRequest) + if err != nil { + return "", fmt.Errorf("call withdrawal source decision: %w", err) + } + defer response.Body.Close() + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + message, _ := io.ReadAll(io.LimitReader(response.Body, maxErrorBodyBytes)) + return "", fmt.Errorf("withdrawal source decision returned %d: %s", response.StatusCode, strings.TrimSpace(string(message))) + } + // 来源申请号已单独持久化;阶段 commandId 作为短事务号即可关联 admin 审核与来源幂等执行,且不会突破字段长度上限。 + return "external:" + strings.TrimSpace(request.CommandID), nil +} + +func (r *Registry) lookup(appCode string, sourceSystem string) (source, bool) { + if r == nil { + return source{}, false + } + item, ok := r.sources[sourceKey(appCode, sourceSystem)] + return item, ok +} + +func sourceKey(appCode string, sourceSystem string) string { + return strings.ToLower(strings.TrimSpace(appCode)) + "\x00" + strings.ToLower(strings.TrimSpace(sourceSystem)) +} diff --git a/server/admin/internal/model/models.go b/server/admin/internal/model/models.go index a8ac7a21..3b000c32 100644 --- a/server/admin/internal/model/models.go +++ b/server/admin/internal/model/models.go @@ -728,6 +728,8 @@ func (order CoinSellerRechargeOrder) BusinessTimeMS() int64 { type UserWithdrawalApplication struct { ID uint `gorm:"primaryKey;index:idx_admin_withdrawal_app_operations_status_time,priority:4" json:"id"` AppCode string `gorm:"size:32;index:idx_admin_withdrawal_app_status_time;index:idx_admin_withdrawal_app_operations_status_time,priority:1;not null" json:"appCode"` + SourceSystem string `gorm:"column:source_system;size:32;not null;default:hyapp_wallet" json:"sourceSystem"` + SourceApplicationID string `gorm:"column:source_application_id;size:128;not null;default:''" json:"sourceApplicationId"` UserID string `gorm:"size:64;index:idx_admin_withdrawal_user;not null" json:"userId"` SalaryAssetType string `gorm:"size:64;not null;default:''" json:"salaryAssetType"` WithdrawAmount string `gorm:"type:decimal(18,2);not null;default:0.00" json:"withdrawAmount"` diff --git a/server/admin/internal/modules/financewithdrawal/handler.go b/server/admin/internal/modules/financewithdrawal/handler.go index 02277c73..d4742da1 100644 --- a/server/admin/internal/modules/financewithdrawal/handler.go +++ b/server/admin/internal/modules/financewithdrawal/handler.go @@ -8,6 +8,7 @@ import ( "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/integration/activityclient" "hyapp-admin-server/internal/integration/walletclient" + "hyapp-admin-server/internal/integration/withdrawalsource" "hyapp-admin-server/internal/middleware" "hyapp-admin-server/internal/modules/shared" "hyapp-admin-server/internal/repository" @@ -20,10 +21,57 @@ import ( type Handler struct { service *Service audit shared.OperationLogger + sources *withdrawalsource.Registry } func New(store *repository.Store, wallet walletclient.Client, activity activityclient.Client, audit shared.OperationLogger) *Handler { - return &Handler{service: NewService(store, wallet, activity), audit: audit} + return NewWithSources(store, wallet, activity, audit, nil) +} + +func NewWithSources(store *repository.Store, wallet walletclient.Client, activity activityclient.Client, audit shared.OperationLogger, sources *withdrawalsource.Registry) *Handler { + return &Handler{service: NewServiceWithSources(store, wallet, activity, sources), audit: audit, sources: sources} +} + +// CreateExternalApplication 是来源钱包的服务间入口,不使用后台 JWT。 +// 每个 App 使用独立 submit token,且业务唯一键保证来源端持久重试不会生成重复审核单。 +func (h *Handler) CreateExternalApplication(c *gin.Context) { + var req struct { + AppCode string `json:"appCode" binding:"required"` + SourceSystem string `json:"sourceSystem" binding:"required"` + SourceApplicationID string `json:"sourceApplicationId" binding:"required"` + UserID string `json:"userId" binding:"required"` + SalaryAssetType string `json:"salaryAssetType" binding:"required"` + WithdrawAmount string `json:"withdrawAmount" binding:"required"` + WithdrawAmountMinor int64 `json:"withdrawAmountMinor" binding:"required"` + WithdrawMethod string `json:"withdrawMethod" binding:"required"` + WithdrawAddress string `json:"withdrawAddress" binding:"required"` + CreatedAtMS int64 `json:"createdAtMs"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "提现申请参数不正确") + return + } + if h.sources == nil || !h.sources.AuthenticateSubmit(req.AppCode, req.SourceSystem, c.GetHeader("Authorization")) { + response.Unauthorized(c, "提现来源鉴权失败") + return + } + item, err := h.service.CreateExternalApplication(externalWithdrawalApplicationInput{ + AppCode: req.AppCode, + SourceSystem: req.SourceSystem, + SourceApplicationID: req.SourceApplicationID, + UserID: req.UserID, + SalaryAssetType: req.SalaryAssetType, + WithdrawAmount: req.WithdrawAmount, + WithdrawAmountMinor: req.WithdrawAmountMinor, + WithdrawMethod: req.WithdrawMethod, + WithdrawAddress: req.WithdrawAddress, + CreatedAtMS: req.CreatedAtMS, + }) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + response.Created(c, item) } func (h *Handler) ListApplications(c *gin.Context) { diff --git a/server/admin/internal/modules/financewithdrawal/response.go b/server/admin/internal/modules/financewithdrawal/response.go index 1a9c9616..c4aca4eb 100644 --- a/server/admin/internal/modules/financewithdrawal/response.go +++ b/server/admin/internal/modules/financewithdrawal/response.go @@ -6,6 +6,8 @@ type withdrawalApplicationDTO struct { ID uint `json:"id"` AppCode string `json:"appCode"` AppName string `json:"appName"` + SourceSystem string `json:"sourceSystem"` + SourceApplicationID string `json:"sourceApplicationId"` UserID string `json:"userId"` SalaryAssetType string `json:"salaryAssetType"` WithdrawAmount string `json:"withdrawAmount"` @@ -41,6 +43,8 @@ func withdrawalApplicationDTOFromModel(item model.UserWithdrawalApplication) wit ID: item.ID, AppCode: item.AppCode, AppName: item.AppCode, + SourceSystem: item.SourceSystem, + SourceApplicationID: item.SourceApplicationID, UserID: item.UserID, SalaryAssetType: item.SalaryAssetType, WithdrawAmount: item.WithdrawAmount, diff --git a/server/admin/internal/modules/financewithdrawal/routes.go b/server/admin/internal/modules/financewithdrawal/routes.go index 6b634743..5d5bf04c 100644 --- a/server/admin/internal/modules/financewithdrawal/routes.go +++ b/server/admin/internal/modules/financewithdrawal/routes.go @@ -17,3 +17,10 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) { protected.POST("/admin/operations/withdrawal-applications/:application_id/approve", middleware.RequirePermission(permissionAuditOperationsWithdrawals), h.ApproveOperationsApplication) protected.POST("/admin/operations/withdrawal-applications/:application_id/reject", middleware.RequirePermission(permissionAuditOperationsWithdrawals), h.RejectOperationsApplication) } + +func RegisterIntegrationRoutes(api *gin.RouterGroup, h *Handler) { + if h == nil { + return + } + api.POST("/integrations/withdrawal-applications", h.CreateExternalApplication) +} diff --git a/server/admin/internal/modules/financewithdrawal/service.go b/server/admin/internal/modules/financewithdrawal/service.go index 79b4ca48..1052ab02 100644 --- a/server/admin/internal/modules/financewithdrawal/service.go +++ b/server/admin/internal/modules/financewithdrawal/service.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "math/big" "strconv" "strings" "time" @@ -12,6 +13,7 @@ import ( "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/integration/activityclient" "hyapp-admin-server/internal/integration/walletclient" + "hyapp-admin-server/internal/integration/withdrawalsource" "hyapp-admin-server/internal/model" "hyapp-admin-server/internal/modules/shared" "hyapp-admin-server/internal/repository" @@ -35,9 +37,23 @@ type Service struct { store *repository.Store wallet walletclient.Client activity activityclient.Client + sources *withdrawalsource.Registry now func() time.Time } +type externalWithdrawalApplicationInput struct { + AppCode string + SourceSystem string + SourceApplicationID string + UserID string + SalaryAssetType string + WithdrawAmount string + WithdrawAmountMinor int64 + WithdrawMethod string + WithdrawAddress string + CreatedAtMS int64 +} + type pointWithdrawalWallet interface { SettlePointWithdrawal(ctx context.Context, req *walletv1.SettlePointWithdrawalRequest) (*walletv1.SettlePointWithdrawalResponse, error) ReleasePointWithdrawal(ctx context.Context, req *walletv1.ReleasePointWithdrawalRequest) (*walletv1.ReleasePointWithdrawalResponse, error) @@ -47,6 +63,77 @@ func NewService(store *repository.Store, wallet walletclient.Client, activity ac return &Service{store: store, wallet: wallet, activity: activity, now: func() time.Time { return time.Now().UTC() }} } +func NewServiceWithSources(store *repository.Store, wallet walletclient.Client, activity activityclient.Client, sources *withdrawalsource.Registry) *Service { + service := NewService(store, wallet, activity) + service.sources = sources + return service +} + +func (s *Service) CreateExternalApplication(input externalWithdrawalApplicationInput) (*withdrawalApplicationDTO, error) { + if s == nil || s.store == nil || s.sources == nil { + return nil, errors.New("external withdrawal intake is not configured") + } + input.AppCode = appctx.Normalize(input.AppCode) + input.SourceSystem = strings.ToLower(strings.TrimSpace(input.SourceSystem)) + input.SourceApplicationID = strings.TrimSpace(input.SourceApplicationID) + input.UserID = strings.TrimSpace(input.UserID) + input.SalaryAssetType = strings.TrimSpace(input.SalaryAssetType) + input.WithdrawAmount = strings.TrimSpace(input.WithdrawAmount) + input.WithdrawMethod = strings.TrimSpace(input.WithdrawMethod) + input.WithdrawAddress = strings.TrimSpace(input.WithdrawAddress) + if !s.sources.HasSource(input.AppCode, input.SourceSystem) { + return nil, errors.New("withdrawal source is not configured") + } + if input.SourceApplicationID == "" || len(input.SourceApplicationID) > 128 || input.UserID == "" || len(input.UserID) > 64 { + return nil, errors.New("来源申请号或用户 ID 不正确") + } + if input.SalaryAssetType == "" || len(input.SalaryAssetType) > 64 || input.WithdrawMethod == "" || len(input.WithdrawMethod) > 64 || input.WithdrawAddress == "" || len(input.WithdrawAddress) > 255 { + return nil, errors.New("提现资产或收款信息不完整") + } + if err := validateExternalWithdrawalAmount(input.WithdrawAmount, input.WithdrawAmountMinor); err != nil { + return nil, err + } + createdAtMS := input.CreatedAtMS + if createdAtMS <= 0 { + createdAtMS = s.now().UnixMilli() + } + application, err := s.store.CreateExternalWithdrawalApplication(model.UserWithdrawalApplication{ + AppCode: input.AppCode, + SourceSystem: input.SourceSystem, + SourceApplicationID: input.SourceApplicationID, + UserID: input.UserID, + SalaryAssetType: input.SalaryAssetType, + WithdrawAmount: input.WithdrawAmount, + WithdrawAmountMinor: input.WithdrawAmountMinor, + WithdrawMethod: input.WithdrawMethod, + WithdrawAddress: input.WithdrawAddress, + // 外部来源在提交前已经完成扣款;这两个字段保留来源事实,真正终态由 callback adapter 执行,绝不误调用 HY wallet。 + FreezeCommandID: "external:" + input.SourceSystem + ":" + input.SourceApplicationID, + FreezeTransactionID: "external:" + input.SourceApplicationID, + Status: model.WithdrawalApplicationStatusPending, + OperationsStatus: model.WithdrawalOperationsStatusPending, + CreatedAtMS: createdAtMS, + UpdatedAtMS: createdAtMS, + }) + if err != nil { + return nil, err + } + dto := withdrawalApplicationDTOFromModel(*application) + return &dto, nil +} + +func validateExternalWithdrawalAmount(amount string, amountMinor int64) error { + parsed, ok := new(big.Rat).SetString(strings.TrimSpace(amount)) + if !ok || parsed.Sign() <= 0 || amountMinor <= 0 { + return errors.New("提现金额不正确") + } + minor := new(big.Rat).Mul(parsed, big.NewRat(100, 1)) + if !minor.IsInt() || !minor.Num().IsInt64() || minor.Num().Int64() != amountMinor { + return errors.New("提现金额与最小单位金额不一致") + } + return nil +} + func (s *Service) ListApplications(actor shared.Actor, options repository.WithdrawalApplicationListOptions) ([]withdrawalApplicationDTO, int64, error) { if s == nil || s.store == nil { return nil, 0, errors.New("admin store is not configured") @@ -156,34 +243,33 @@ func (s *Service) auditApplication(ctx context.Context, actor shared.Actor, id u AuditImageURL: auditImageURL, ApprovedAtMS: nowMS, }, func(item model.UserWithdrawalApplication) (repository.WithdrawalApplicationAuditEffect, error) { - userID, parseErr := withdrawalWalletUserID(item) - if parseErr != nil { - return repository.WithdrawalApplicationAuditEffect{}, parseErr - } if stage == repository.WithdrawalApplicationReviewStageOperations && decision == model.WithdrawalApplicationStatusApproved { // 运营通过只确认业务资料并转交财务;申请金额继续保留在 frozen,且不能提前向用户发送最终通过通知。 return repository.WithdrawalApplicationAuditEffect{}, nil } - transactionID, walletErr := s.applyWalletDecision(ctx, decision, commandID, appCode, userID, item.SalaryAssetType, item.WithdrawAmountMinor, item.PointFeeAmount, item.PointNetAmount, item.PointsPerUSD, item.PointFeeBPS, actor, strings.TrimSpace(remark), id) + transactionID, userID, walletErr := s.applyWithdrawalDecision(ctx, item, decision, commandID, appCode, actor, remark, auditImageURL, id) if walletErr != nil { return repository.WithdrawalApplicationAuditEffect{}, walletErr } - if noticeErr := s.sendWithdrawalAuditNotice(ctx, auditNoticeInput{ - ApplicationID: id, - AppCode: appCode, - UserID: userID, - Stage: stage, - Decision: decision, - Remark: remark, - AuditImageURL: auditImageURL, - AuditTransactionID: transactionID, - WithdrawAmount: item.WithdrawAmount, - WithdrawMethod: item.WithdrawMethod, - RequestID: requestID, - SentAtMS: nowMS, - }); noticeErr != nil { - // 钱包扣冻/释放使用阶段固定 command id,通知使用阶段+结果事件 id;回调失败会回滚 admin 状态,重试不会重复资金动作或消息。 - return repository.WithdrawalApplicationAuditEffect{}, noticeErr + if !isExternalWithdrawal(item) { + noticeErr := s.sendWithdrawalAuditNotice(ctx, auditNoticeInput{ + ApplicationID: id, + AppCode: appCode, + UserID: userID, + Stage: stage, + Decision: decision, + Remark: remark, + AuditImageURL: auditImageURL, + AuditTransactionID: transactionID, + WithdrawAmount: item.WithdrawAmount, + WithdrawMethod: item.WithdrawMethod, + RequestID: requestID, + SentAtMS: nowMS, + }) + if noticeErr != nil { + // 钱包扣冻/释放使用阶段固定 command id,通知使用阶段+结果事件 id;回调失败会回滚 admin 状态,重试不会重复资金动作或消息。 + return repository.WithdrawalApplicationAuditEffect{}, noticeErr + } } return repository.WithdrawalApplicationAuditEffect{CommandID: commandID, TransactionID: transactionID}, nil }) @@ -206,8 +292,7 @@ func (s *Service) executeOperationsRejection(ctx context.Context, actor shared.A AuditCommandID: commandID, ApprovedAtMS: nowMS, }, func(item model.UserWithdrawalApplication) error { - _, err := withdrawalWalletUserID(item) - return err + return validateWithdrawalDecisionTarget(item) }) if err != nil { return nil, err @@ -218,17 +303,13 @@ func (s *Service) executeOperationsRejection(ctx context.Context, actor shared.A return &dto, nil } - userID, err := withdrawalWalletUserID(*claimed) - if err != nil { - return nil, err - } if claimed.OperationsReviewerUserID == nil || *claimed.OperationsReviewerUserID == 0 || strings.TrimSpace(claimed.OperationsAuditRemark) == "" { return nil, errors.New("提现单运营拒绝 claim 不完整") } // 重试沿用第一次 claim 的审核人和拒绝原因,不能让资金 metadata、用户通知和后台审计快照随重试人改变。 claimedActor := shared.Actor{UserID: *claimed.OperationsReviewerUserID, Username: claimed.OperationsReviewerName} claimedRemark := strings.TrimSpace(claimed.OperationsAuditRemark) - transactionID, err := s.applyWalletDecision(ctx, model.WithdrawalApplicationStatusRejected, commandID, appCode, userID, claimed.SalaryAssetType, claimed.WithdrawAmountMinor, claimed.PointFeeAmount, claimed.PointNetAmount, claimed.PointsPerUSD, claimed.PointFeeBPS, claimedActor, claimedRemark, id) + transactionID, userID, err := s.applyWithdrawalDecision(ctx, *claimed, model.WithdrawalApplicationStatusRejected, commandID, appCode, claimedActor, claimedRemark, "", id) if err != nil { return nil, err } @@ -236,21 +317,24 @@ func (s *Service) executeOperationsRejection(ctx context.Context, actor shared.A if claimed.OperationsReviewedAtMS != nil && *claimed.OperationsReviewedAtMS > 0 { sentAtMS = *claimed.OperationsReviewedAtMS } - if err := s.sendWithdrawalAuditNotice(ctx, auditNoticeInput{ - ApplicationID: id, - AppCode: appCode, - UserID: userID, - Stage: repository.WithdrawalApplicationReviewStageOperations, - Decision: model.WithdrawalApplicationStatusRejected, - Remark: claimedRemark, - AuditTransactionID: transactionID, - WithdrawAmount: claimed.WithdrawAmount, - WithdrawMethod: claimed.WithdrawMethod, - RequestID: requestID, - SentAtMS: sentAtMS, - }); err != nil { - // rejecting 已在前一事务提交;返回错误让运营页展示“重试拒绝”,绝不能回退成 pending。 - return nil, err + if !isExternalWithdrawal(*claimed) { + err = s.sendWithdrawalAuditNotice(ctx, auditNoticeInput{ + ApplicationID: id, + AppCode: appCode, + UserID: userID, + Stage: repository.WithdrawalApplicationReviewStageOperations, + Decision: model.WithdrawalApplicationStatusRejected, + Remark: claimedRemark, + AuditTransactionID: transactionID, + WithdrawAmount: claimed.WithdrawAmount, + WithdrawMethod: claimed.WithdrawMethod, + RequestID: requestID, + SentAtMS: sentAtMS, + }) + if err != nil { + // rejecting 已在前一事务提交;返回错误让运营页展示“重试拒绝”,绝不能回退成 pending。 + return nil, err + } } updated, err := s.store.FinalizeWithdrawalOperationsRejectionForApp(appCode, id, commandID, transactionID, s.now().UnixMilli()) if err != nil { @@ -268,6 +352,52 @@ func withdrawalWalletUserID(item model.UserWithdrawalApplication) (int64, error) return userID, nil } +func validateWithdrawalDecisionTarget(item model.UserWithdrawalApplication) error { + if isExternalWithdrawal(item) { + if strings.TrimSpace(item.SourceApplicationID) == "" || strings.TrimSpace(item.UserID) == "" || item.WithdrawAmountMinor <= 0 { + return errors.New("外部提现单来源信息不完整") + } + return nil + } + _, err := withdrawalWalletUserID(item) + return err +} + +func isExternalWithdrawal(item model.UserWithdrawalApplication) bool { + sourceSystem := strings.ToLower(strings.TrimSpace(item.SourceSystem)) + return sourceSystem != "" && sourceSystem != "hyapp_wallet" +} + +func (s *Service) applyWithdrawalDecision(ctx context.Context, item model.UserWithdrawalApplication, decision string, commandID string, appCode string, actor shared.Actor, remark string, auditImageURL string, applicationID uint) (string, int64, error) { + if isExternalWithdrawal(item) { + if s.sources == nil { + return "", 0, errors.New("withdrawal source callback is not configured") + } + sourceDecision := "NOT_PASS" + credentials := []string(nil) + if decision == model.WithdrawalApplicationStatusApproved { + sourceDecision = "PASS" + credentials = []string{strings.TrimSpace(auditImageURL)} + } + transactionID, err := s.sources.ApplyDecision(ctx, appCode, item.SourceSystem, withdrawalsource.DecisionRequest{ + SourceApplicationID: item.SourceApplicationID, + Decision: sourceDecision, + CredentialURLs: credentials, + Remark: strings.TrimSpace(remark), + ReviewerUserID: actor.UserID, + ReviewerName: actor.Username, + CommandID: commandID, + }) + return transactionID, 0, err + } + userID, err := withdrawalWalletUserID(item) + if err != nil { + return "", 0, err + } + transactionID, err := s.applyWalletDecision(ctx, decision, commandID, appCode, userID, item.SalaryAssetType, item.WithdrawAmountMinor, item.PointFeeAmount, item.PointNetAmount, item.PointsPerUSD, item.PointFeeBPS, actor, strings.TrimSpace(remark), applicationID) + return transactionID, userID, err +} + func (s *Service) applyWalletDecision(ctx context.Context, decision string, commandID string, appCode string, userID int64, assetType string, amountMinor int64, storedFeePoints int64, storedNetPoints int64, storedPointsPerUSD int64, storedFeeBPS int32, actor shared.Actor, remark string, applicationID uint) (string, error) { reason := strings.TrimSpace(remark) if reason == "" { diff --git a/server/admin/internal/repository/withdrawal_application_repository.go b/server/admin/internal/repository/withdrawal_application_repository.go index 0b49fc31..c5adf8ee 100644 --- a/server/admin/internal/repository/withdrawal_application_repository.go +++ b/server/admin/internal/repository/withdrawal_application_repository.go @@ -57,6 +57,64 @@ type WithdrawalApplicationAuditAction func(application model.UserWithdrawalAppli // 校验必须留在持行锁的短事务内,避免坏数据先进入 rejecting 后再永久卡住人工流程。 type WithdrawalApplicationClaimValidator func(application model.UserWithdrawalApplication) error +// CreateExternalWithdrawalApplication 以 App、来源系统、来源申请号作为业务幂等键。 +// 来源钱包采用持久重试投递;重复提交必须返回同一 admin 申请,不能制造两张可独立审核的资金单。 +func (s *Store) CreateExternalWithdrawalApplication(application model.UserWithdrawalApplication) (*model.UserWithdrawalApplication, error) { + application.AppCode = strings.TrimSpace(application.AppCode) + application.SourceSystem = strings.TrimSpace(application.SourceSystem) + application.SourceApplicationID = strings.TrimSpace(application.SourceApplicationID) + if application.AppCode == "" || application.SourceSystem == "" || application.SourceApplicationID == "" { + return nil, errors.New("external withdrawal source identity is incomplete") + } + + var existing model.UserWithdrawalApplication + lookup := s.db.Where( + "app_code = ? AND source_system = ? AND source_application_id = ?", + application.AppCode, + application.SourceSystem, + application.SourceApplicationID, + ).First(&existing) + if lookup.Error == nil { + if err := validateExternalWithdrawalReplay(existing, application); err != nil { + return nil, err + } + return &existing, nil + } + if !errors.Is(lookup.Error, gorm.ErrRecordNotFound) { + return nil, lookup.Error + } + + result := s.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&application) + if result.Error != nil { + return nil, result.Error + } + // MySQL 对 ON DUPLICATE KEY 的 RowsAffected 会受连接参数影响;无论本次创建还是并发重放,都按业务键回读唯一事实。 + if err := s.db.Where( + "app_code = ? AND source_system = ? AND source_application_id = ?", + application.AppCode, + application.SourceSystem, + application.SourceApplicationID, + ).First(&existing).Error; err != nil { + return nil, err + } + if err := validateExternalWithdrawalReplay(existing, application); err != nil { + return nil, err + } + return &existing, nil +} + +func validateExternalWithdrawalReplay(existing model.UserWithdrawalApplication, replay model.UserWithdrawalApplication) error { + if existing.UserID != replay.UserID || + existing.SalaryAssetType != replay.SalaryAssetType || + existing.WithdrawAmountMinor != replay.WithdrawAmountMinor || + existing.WithdrawMethod != replay.WithdrawMethod || + existing.WithdrawAddress != replay.WithdrawAddress { + // 相同来源单号只能重放完全一致的资金事实,不能用幂等接口覆盖已进入人工审核的金额或收款地址。 + return errors.New("external withdrawal replay payload conflicts with existing application") + } + return nil +} + func (s *Store) GetWithdrawalApplication(id uint) (*model.UserWithdrawalApplication, error) { var application model.UserWithdrawalApplication if err := s.db.First(&application, id).Error; err != nil { diff --git a/server/admin/internal/router/router.go b/server/admin/internal/router/router.go index 29216dea..d5945a42 100644 --- a/server/admin/internal/router/router.go +++ b/server/admin/internal/router/router.go @@ -154,6 +154,7 @@ func New(cfg config.Config, auth *service.AuthService, store *repository.Store, appProtected.Use(middleware.RequireAppScope(store)) authroutes.RegisterRoutes(api, protected, h.Auth) + financewithdrawal.RegisterIntegrationRoutes(api, h.FinanceWithdrawal) externaladmin.RegisterExternalRoutes(api, h.ExternalAdmin, externaladmin.BusinessHandlers{ AppUser: h.AppUser, HostOrg: h.HostOrg, RoomAdmin: h.RoomAdmin, Resource: h.Resource, PrettyID: h.PrettyID, AppConfig: h.AppConfig, VIPConfig: h.VIPConfig, Upload: h.Upload, diff --git a/server/admin/migrations/104_external_withdrawal_sources.sql b/server/admin/migrations/104_external_withdrawal_sources.sql new file mode 100644 index 00000000..6bdb15e0 --- /dev/null +++ b/server/admin/migrations/104_external_withdrawal_sources.sql @@ -0,0 +1,63 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- 两列都是短 VARCHAR;线上使用 INPLACE/LOCK=NONE,避免跨 App 接入时阻塞现有 Lalu 提现写入。 +SET @source_system_exists = ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'admin_user_withdrawal_applications' + AND COLUMN_NAME = 'source_system' +); + +SET @source_system_ddl = IF( + @source_system_exists = 0, + 'ALTER TABLE admin_user_withdrawal_applications ADD COLUMN source_system VARCHAR(32) NOT NULL DEFAULT ''hyapp_wallet'' AFTER app_code, ALGORITHM=INPLACE, LOCK=NONE', + 'SELECT 1' +); + +PREPARE stmt FROM @source_system_ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @source_application_id_exists = ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'admin_user_withdrawal_applications' + AND COLUMN_NAME = 'source_application_id' +); + +SET @source_application_id_ddl = IF( + @source_application_id_exists = 0, + 'ALTER TABLE admin_user_withdrawal_applications ADD COLUMN source_application_id VARCHAR(128) NOT NULL DEFAULT '''' AFTER source_system, ALGORITHM=INPLACE, LOCK=NONE', + 'SELECT 1' +); + +PREPARE stmt FROM @source_application_id_ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 存量 Lalu 单使用已经唯一的 freeze_command_id 作为来源单号,确保新增唯一索引不会发生空值碰撞。 +UPDATE admin_user_withdrawal_applications +SET source_system = 'hyapp_wallet', + source_application_id = freeze_command_id +WHERE source_application_id = ''; + +SET @source_index_exists = ( + SELECT COUNT(*) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'admin_user_withdrawal_applications' + AND INDEX_NAME = 'uk_admin_withdrawal_source_application' +); + +-- 唯一索引同时承担来源重试的幂等查找;列选择性高,不增加列表查询扫描成本。 +SET @source_index_ddl = IF( + @source_index_exists = 0, + 'ALTER TABLE admin_user_withdrawal_applications ADD UNIQUE KEY uk_admin_withdrawal_source_application (app_code, source_system, source_application_id), ALGORITHM=INPLACE, LOCK=NONE', + 'SELECT 1' +); + +PREPARE stmt FROM @source_index_ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/services/gateway-service/internal/financewithdrawal/writer.go b/services/gateway-service/internal/financewithdrawal/writer.go index 117bb5b8..75fac609 100644 --- a/services/gateway-service/internal/financewithdrawal/writer.go +++ b/services/gateway-service/internal/financewithdrawal/writer.go @@ -119,12 +119,14 @@ func (w *MySQLWriter) CreateApplication(ctx context.Context, command CreateAppli result, err := w.db.ExecContext(ctx, ` INSERT INTO admin_user_withdrawal_applications - (app_code, user_id, salary_asset_type, withdraw_amount, withdraw_amount_minor, + (app_code, source_system, source_application_id, user_id, salary_asset_type, withdraw_amount, withdraw_amount_minor, point_fee_amount, point_net_amount, points_per_usd, point_fee_bps, point_policy_instance_code, withdraw_method, withdraw_address, freeze_command_id, freeze_transaction_id, status, operations_status, created_at_ms, updated_at_ms) VALUES - (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, command.AppCode, + "hyapp_wallet", + command.FreezeCommandID, formatUserID(command.UserID), command.SalaryAssetType, command.WithdrawAmount, diff --git a/services/gateway-service/internal/financewithdrawal/writer_test.go b/services/gateway-service/internal/financewithdrawal/writer_test.go index a5af0839..36a0184a 100644 --- a/services/gateway-service/internal/financewithdrawal/writer_test.go +++ b/services/gateway-service/internal/financewithdrawal/writer_test.go @@ -93,6 +93,8 @@ func TestCreateApplicationInsertsAfterAppScopedMiss(t *testing.T) { mock.ExpectExec(`(?s)INSERT INTO admin_user_withdrawal_applications`). WithArgs( "huwaa", + "hyapp_wallet", + "cmd-point-freeze", "42001", "POINT", "9.50",