feat(finance): support cross-app withdrawal review
This commit is contained in:
parent
a10213b4d8
commit
764caa2193
@ -26,6 +26,7 @@ import (
|
|||||||
"hyapp-admin-server/internal/integration/statisticsclient"
|
"hyapp-admin-server/internal/integration/statisticsclient"
|
||||||
"hyapp-admin-server/internal/integration/userclient"
|
"hyapp-admin-server/internal/integration/userclient"
|
||||||
"hyapp-admin-server/internal/integration/walletclient"
|
"hyapp-admin-server/internal/integration/walletclient"
|
||||||
|
"hyapp-admin-server/internal/integration/withdrawalsource"
|
||||||
jobrunner "hyapp-admin-server/internal/job"
|
jobrunner "hyapp-admin-server/internal/job"
|
||||||
"hyapp-admin-server/internal/migration"
|
"hyapp-admin-server/internal/migration"
|
||||||
achievementconfigmodule "hyapp-admin-server/internal/modules/achievementconfig"
|
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) {
|
financeordermodule.WithLegacyCoinSellerRegionResolver(func(ctx context.Context, appCode string, countryCode string) (int64, bool, error) {
|
||||||
return paymentmodule.RegionIDForCountryCode(ctx, moneyRegionSources, appCode, countryCode)
|
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{
|
ExternalAdmin: externaladminmodule.New(db, userDB, externaladminmodule.Config{
|
||||||
SessionTTL: cfg.ExternalAdmin.SessionTTL,
|
SessionTTL: cfg.ExternalAdmin.SessionTTL,
|
||||||
MaxLoginFailures: cfg.ExternalAdmin.MaxLoginFailures,
|
MaxLoginFailures: cfg.ExternalAdmin.MaxLoginFailures,
|
||||||
|
|||||||
@ -69,6 +69,15 @@ finance_google_paid_sync:
|
|||||||
poll_interval: "15s"
|
poll_interval: "15s"
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
request_timeout: "2m"
|
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
|
mysql_auto_migrate: false
|
||||||
migrations:
|
migrations:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
@ -67,6 +67,16 @@ finance_google_paid_sync:
|
|||||||
poll_interval: "15s"
|
poll_interval: "15s"
|
||||||
batch_size: 100
|
batch_size: 100
|
||||||
request_timeout: "2m"
|
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
|
mysql_auto_migrate: true
|
||||||
migrations:
|
migrations:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
@ -52,6 +52,7 @@ type Config struct {
|
|||||||
StatisticsService StatisticsServiceConfig `yaml:"statistics_service"`
|
StatisticsService StatisticsServiceConfig `yaml:"statistics_service"`
|
||||||
DashboardExternalSources []DashboardExternalSourceConfig `yaml:"dashboard_external_sources"`
|
DashboardExternalSources []DashboardExternalSourceConfig `yaml:"dashboard_external_sources"`
|
||||||
LegacyCoinSellerRechargeSources []LegacyCoinSellerRechargeSourceConfig `yaml:"legacy_coin_seller_recharge_sources"`
|
LegacyCoinSellerRechargeSources []LegacyCoinSellerRechargeSourceConfig `yaml:"legacy_coin_seller_recharge_sources"`
|
||||||
|
WithdrawalSources []WithdrawalSourceConfig `yaml:"withdrawal_sources"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MigrationConfig struct {
|
type MigrationConfig struct {
|
||||||
@ -115,6 +116,18 @@ type StatisticsServiceConfig struct {
|
|||||||
RequestTimeout time.Duration `yaml:"request_timeout"`
|
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 {
|
type DashboardExternalSourceConfig struct {
|
||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled"`
|
||||||
SourceType string `yaml:"type"`
|
SourceType string `yaml:"type"`
|
||||||
@ -385,6 +398,14 @@ func Default() Config {
|
|||||||
RequestTimeout: 5 * time.Second,
|
RequestTimeout: 5 * time.Second,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
WithdrawalSources: []WithdrawalSourceConfig{
|
||||||
|
{
|
||||||
|
Enabled: false,
|
||||||
|
AppCode: "aslan",
|
||||||
|
SourceSystem: "likei",
|
||||||
|
RequestTimeout: 5 * time.Second,
|
||||||
|
},
|
||||||
|
},
|
||||||
FinanceGooglePaidSync: FinanceGooglePaidSyncConfig{
|
FinanceGooglePaidSync: FinanceGooglePaidSyncConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
PollInterval: 15 * time.Second,
|
PollInterval: 15 * time.Second,
|
||||||
@ -623,6 +644,18 @@ func (cfg *Config) Normalize() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
cfg.applyLegacyCoinSellerRechargeSourceEnvOverrides()
|
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)
|
cfg.RobotProfileSource.MySQLDSN = strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN)
|
||||||
if cfg.RobotProfileSource.RequestTimeout <= 0 {
|
if cfg.RobotProfileSource.RequestTimeout <= 0 {
|
||||||
cfg.RobotProfileSource.RequestTimeout = 5 * time.Second
|
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)
|
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) == "" {
|
if isLockedEnvironment(cfg.Environment) && strings.TrimSpace(cfg.RobotProfileSource.MySQLDSN) == "" {
|
||||||
return errors.New("robot_profile_source.mysql_dsn is required outside local/dev")
|
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 {
|
func envAppKey(appCode string) string {
|
||||||
appCode = strings.ToUpper(strings.TrimSpace(appCode))
|
appCode = strings.ToUpper(strings.TrimSpace(appCode))
|
||||||
if appCode == "" {
|
if appCode == "" {
|
||||||
|
|||||||
114
server/admin/internal/integration/withdrawalsource/client.go
Normal file
114
server/admin/internal/integration/withdrawalsource/client.go
Normal file
@ -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))
|
||||||
|
}
|
||||||
@ -728,6 +728,8 @@ func (order CoinSellerRechargeOrder) BusinessTimeMS() int64 {
|
|||||||
type UserWithdrawalApplication struct {
|
type UserWithdrawalApplication struct {
|
||||||
ID uint `gorm:"primaryKey;index:idx_admin_withdrawal_app_operations_status_time,priority:4" json:"id"`
|
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"`
|
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"`
|
UserID string `gorm:"size:64;index:idx_admin_withdrawal_user;not null" json:"userId"`
|
||||||
SalaryAssetType string `gorm:"size:64;not null;default:''" json:"salaryAssetType"`
|
SalaryAssetType string `gorm:"size:64;not null;default:''" json:"salaryAssetType"`
|
||||||
WithdrawAmount string `gorm:"type:decimal(18,2);not null;default:0.00" json:"withdrawAmount"`
|
WithdrawAmount string `gorm:"type:decimal(18,2);not null;default:0.00" json:"withdrawAmount"`
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import (
|
|||||||
"hyapp-admin-server/internal/appctx"
|
"hyapp-admin-server/internal/appctx"
|
||||||
"hyapp-admin-server/internal/integration/activityclient"
|
"hyapp-admin-server/internal/integration/activityclient"
|
||||||
"hyapp-admin-server/internal/integration/walletclient"
|
"hyapp-admin-server/internal/integration/walletclient"
|
||||||
|
"hyapp-admin-server/internal/integration/withdrawalsource"
|
||||||
"hyapp-admin-server/internal/middleware"
|
"hyapp-admin-server/internal/middleware"
|
||||||
"hyapp-admin-server/internal/modules/shared"
|
"hyapp-admin-server/internal/modules/shared"
|
||||||
"hyapp-admin-server/internal/repository"
|
"hyapp-admin-server/internal/repository"
|
||||||
@ -20,10 +21,57 @@ import (
|
|||||||
type Handler struct {
|
type Handler struct {
|
||||||
service *Service
|
service *Service
|
||||||
audit shared.OperationLogger
|
audit shared.OperationLogger
|
||||||
|
sources *withdrawalsource.Registry
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(store *repository.Store, wallet walletclient.Client, activity activityclient.Client, audit shared.OperationLogger) *Handler {
|
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) {
|
func (h *Handler) ListApplications(c *gin.Context) {
|
||||||
|
|||||||
@ -6,6 +6,8 @@ type withdrawalApplicationDTO struct {
|
|||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
AppCode string `json:"appCode"`
|
AppCode string `json:"appCode"`
|
||||||
AppName string `json:"appName"`
|
AppName string `json:"appName"`
|
||||||
|
SourceSystem string `json:"sourceSystem"`
|
||||||
|
SourceApplicationID string `json:"sourceApplicationId"`
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
SalaryAssetType string `json:"salaryAssetType"`
|
SalaryAssetType string `json:"salaryAssetType"`
|
||||||
WithdrawAmount string `json:"withdrawAmount"`
|
WithdrawAmount string `json:"withdrawAmount"`
|
||||||
@ -41,6 +43,8 @@ func withdrawalApplicationDTOFromModel(item model.UserWithdrawalApplication) wit
|
|||||||
ID: item.ID,
|
ID: item.ID,
|
||||||
AppCode: item.AppCode,
|
AppCode: item.AppCode,
|
||||||
AppName: item.AppCode,
|
AppName: item.AppCode,
|
||||||
|
SourceSystem: item.SourceSystem,
|
||||||
|
SourceApplicationID: item.SourceApplicationID,
|
||||||
UserID: item.UserID,
|
UserID: item.UserID,
|
||||||
SalaryAssetType: item.SalaryAssetType,
|
SalaryAssetType: item.SalaryAssetType,
|
||||||
WithdrawAmount: item.WithdrawAmount,
|
WithdrawAmount: item.WithdrawAmount,
|
||||||
|
|||||||
@ -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/approve", middleware.RequirePermission(permissionAuditOperationsWithdrawals), h.ApproveOperationsApplication)
|
||||||
protected.POST("/admin/operations/withdrawal-applications/:application_id/reject", middleware.RequirePermission(permissionAuditOperationsWithdrawals), h.RejectOperationsApplication)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/big"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -12,6 +13,7 @@ import (
|
|||||||
"hyapp-admin-server/internal/appctx"
|
"hyapp-admin-server/internal/appctx"
|
||||||
"hyapp-admin-server/internal/integration/activityclient"
|
"hyapp-admin-server/internal/integration/activityclient"
|
||||||
"hyapp-admin-server/internal/integration/walletclient"
|
"hyapp-admin-server/internal/integration/walletclient"
|
||||||
|
"hyapp-admin-server/internal/integration/withdrawalsource"
|
||||||
"hyapp-admin-server/internal/model"
|
"hyapp-admin-server/internal/model"
|
||||||
"hyapp-admin-server/internal/modules/shared"
|
"hyapp-admin-server/internal/modules/shared"
|
||||||
"hyapp-admin-server/internal/repository"
|
"hyapp-admin-server/internal/repository"
|
||||||
@ -35,9 +37,23 @@ type Service struct {
|
|||||||
store *repository.Store
|
store *repository.Store
|
||||||
wallet walletclient.Client
|
wallet walletclient.Client
|
||||||
activity activityclient.Client
|
activity activityclient.Client
|
||||||
|
sources *withdrawalsource.Registry
|
||||||
now func() time.Time
|
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 {
|
type pointWithdrawalWallet interface {
|
||||||
SettlePointWithdrawal(ctx context.Context, req *walletv1.SettlePointWithdrawalRequest) (*walletv1.SettlePointWithdrawalResponse, error)
|
SettlePointWithdrawal(ctx context.Context, req *walletv1.SettlePointWithdrawalRequest) (*walletv1.SettlePointWithdrawalResponse, error)
|
||||||
ReleasePointWithdrawal(ctx context.Context, req *walletv1.ReleasePointWithdrawalRequest) (*walletv1.ReleasePointWithdrawalResponse, 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() }}
|
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) {
|
func (s *Service) ListApplications(actor shared.Actor, options repository.WithdrawalApplicationListOptions) ([]withdrawalApplicationDTO, int64, error) {
|
||||||
if s == nil || s.store == nil {
|
if s == nil || s.store == nil {
|
||||||
return nil, 0, errors.New("admin store is not configured")
|
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,
|
AuditImageURL: auditImageURL,
|
||||||
ApprovedAtMS: nowMS,
|
ApprovedAtMS: nowMS,
|
||||||
}, func(item model.UserWithdrawalApplication) (repository.WithdrawalApplicationAuditEffect, error) {
|
}, 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 {
|
if stage == repository.WithdrawalApplicationReviewStageOperations && decision == model.WithdrawalApplicationStatusApproved {
|
||||||
// 运营通过只确认业务资料并转交财务;申请金额继续保留在 frozen,且不能提前向用户发送最终通过通知。
|
// 运营通过只确认业务资料并转交财务;申请金额继续保留在 frozen,且不能提前向用户发送最终通过通知。
|
||||||
return repository.WithdrawalApplicationAuditEffect{}, nil
|
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 {
|
if walletErr != nil {
|
||||||
return repository.WithdrawalApplicationAuditEffect{}, walletErr
|
return repository.WithdrawalApplicationAuditEffect{}, walletErr
|
||||||
}
|
}
|
||||||
if noticeErr := s.sendWithdrawalAuditNotice(ctx, auditNoticeInput{
|
if !isExternalWithdrawal(item) {
|
||||||
ApplicationID: id,
|
noticeErr := s.sendWithdrawalAuditNotice(ctx, auditNoticeInput{
|
||||||
AppCode: appCode,
|
ApplicationID: id,
|
||||||
UserID: userID,
|
AppCode: appCode,
|
||||||
Stage: stage,
|
UserID: userID,
|
||||||
Decision: decision,
|
Stage: stage,
|
||||||
Remark: remark,
|
Decision: decision,
|
||||||
AuditImageURL: auditImageURL,
|
Remark: remark,
|
||||||
AuditTransactionID: transactionID,
|
AuditImageURL: auditImageURL,
|
||||||
WithdrawAmount: item.WithdrawAmount,
|
AuditTransactionID: transactionID,
|
||||||
WithdrawMethod: item.WithdrawMethod,
|
WithdrawAmount: item.WithdrawAmount,
|
||||||
RequestID: requestID,
|
WithdrawMethod: item.WithdrawMethod,
|
||||||
SentAtMS: nowMS,
|
RequestID: requestID,
|
||||||
}); noticeErr != nil {
|
SentAtMS: nowMS,
|
||||||
// 钱包扣冻/释放使用阶段固定 command id,通知使用阶段+结果事件 id;回调失败会回滚 admin 状态,重试不会重复资金动作或消息。
|
})
|
||||||
return repository.WithdrawalApplicationAuditEffect{}, noticeErr
|
if noticeErr != nil {
|
||||||
|
// 钱包扣冻/释放使用阶段固定 command id,通知使用阶段+结果事件 id;回调失败会回滚 admin 状态,重试不会重复资金动作或消息。
|
||||||
|
return repository.WithdrawalApplicationAuditEffect{}, noticeErr
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return repository.WithdrawalApplicationAuditEffect{CommandID: commandID, TransactionID: transactionID}, nil
|
return repository.WithdrawalApplicationAuditEffect{CommandID: commandID, TransactionID: transactionID}, nil
|
||||||
})
|
})
|
||||||
@ -206,8 +292,7 @@ func (s *Service) executeOperationsRejection(ctx context.Context, actor shared.A
|
|||||||
AuditCommandID: commandID,
|
AuditCommandID: commandID,
|
||||||
ApprovedAtMS: nowMS,
|
ApprovedAtMS: nowMS,
|
||||||
}, func(item model.UserWithdrawalApplication) error {
|
}, func(item model.UserWithdrawalApplication) error {
|
||||||
_, err := withdrawalWalletUserID(item)
|
return validateWithdrawalDecisionTarget(item)
|
||||||
return err
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -218,17 +303,13 @@ func (s *Service) executeOperationsRejection(ctx context.Context, actor shared.A
|
|||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
userID, err := withdrawalWalletUserID(*claimed)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if claimed.OperationsReviewerUserID == nil || *claimed.OperationsReviewerUserID == 0 || strings.TrimSpace(claimed.OperationsAuditRemark) == "" {
|
if claimed.OperationsReviewerUserID == nil || *claimed.OperationsReviewerUserID == 0 || strings.TrimSpace(claimed.OperationsAuditRemark) == "" {
|
||||||
return nil, errors.New("提现单运营拒绝 claim 不完整")
|
return nil, errors.New("提现单运营拒绝 claim 不完整")
|
||||||
}
|
}
|
||||||
// 重试沿用第一次 claim 的审核人和拒绝原因,不能让资金 metadata、用户通知和后台审计快照随重试人改变。
|
// 重试沿用第一次 claim 的审核人和拒绝原因,不能让资金 metadata、用户通知和后台审计快照随重试人改变。
|
||||||
claimedActor := shared.Actor{UserID: *claimed.OperationsReviewerUserID, Username: claimed.OperationsReviewerName}
|
claimedActor := shared.Actor{UserID: *claimed.OperationsReviewerUserID, Username: claimed.OperationsReviewerName}
|
||||||
claimedRemark := strings.TrimSpace(claimed.OperationsAuditRemark)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -236,21 +317,24 @@ func (s *Service) executeOperationsRejection(ctx context.Context, actor shared.A
|
|||||||
if claimed.OperationsReviewedAtMS != nil && *claimed.OperationsReviewedAtMS > 0 {
|
if claimed.OperationsReviewedAtMS != nil && *claimed.OperationsReviewedAtMS > 0 {
|
||||||
sentAtMS = *claimed.OperationsReviewedAtMS
|
sentAtMS = *claimed.OperationsReviewedAtMS
|
||||||
}
|
}
|
||||||
if err := s.sendWithdrawalAuditNotice(ctx, auditNoticeInput{
|
if !isExternalWithdrawal(*claimed) {
|
||||||
ApplicationID: id,
|
err = s.sendWithdrawalAuditNotice(ctx, auditNoticeInput{
|
||||||
AppCode: appCode,
|
ApplicationID: id,
|
||||||
UserID: userID,
|
AppCode: appCode,
|
||||||
Stage: repository.WithdrawalApplicationReviewStageOperations,
|
UserID: userID,
|
||||||
Decision: model.WithdrawalApplicationStatusRejected,
|
Stage: repository.WithdrawalApplicationReviewStageOperations,
|
||||||
Remark: claimedRemark,
|
Decision: model.WithdrawalApplicationStatusRejected,
|
||||||
AuditTransactionID: transactionID,
|
Remark: claimedRemark,
|
||||||
WithdrawAmount: claimed.WithdrawAmount,
|
AuditTransactionID: transactionID,
|
||||||
WithdrawMethod: claimed.WithdrawMethod,
|
WithdrawAmount: claimed.WithdrawAmount,
|
||||||
RequestID: requestID,
|
WithdrawMethod: claimed.WithdrawMethod,
|
||||||
SentAtMS: sentAtMS,
|
RequestID: requestID,
|
||||||
}); err != nil {
|
SentAtMS: sentAtMS,
|
||||||
// rejecting 已在前一事务提交;返回错误让运营页展示“重试拒绝”,绝不能回退成 pending。
|
})
|
||||||
return nil, err
|
if err != nil {
|
||||||
|
// rejecting 已在前一事务提交;返回错误让运营页展示“重试拒绝”,绝不能回退成 pending。
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
updated, err := s.store.FinalizeWithdrawalOperationsRejectionForApp(appCode, id, commandID, transactionID, s.now().UnixMilli())
|
updated, err := s.store.FinalizeWithdrawalOperationsRejectionForApp(appCode, id, commandID, transactionID, s.now().UnixMilli())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -268,6 +352,52 @@ func withdrawalWalletUserID(item model.UserWithdrawalApplication) (int64, error)
|
|||||||
return userID, nil
|
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) {
|
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)
|
reason := strings.TrimSpace(remark)
|
||||||
if reason == "" {
|
if reason == "" {
|
||||||
|
|||||||
@ -57,6 +57,64 @@ type WithdrawalApplicationAuditAction func(application model.UserWithdrawalAppli
|
|||||||
// 校验必须留在持行锁的短事务内,避免坏数据先进入 rejecting 后再永久卡住人工流程。
|
// 校验必须留在持行锁的短事务内,避免坏数据先进入 rejecting 后再永久卡住人工流程。
|
||||||
type WithdrawalApplicationClaimValidator func(application model.UserWithdrawalApplication) error
|
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) {
|
func (s *Store) GetWithdrawalApplication(id uint) (*model.UserWithdrawalApplication, error) {
|
||||||
var application model.UserWithdrawalApplication
|
var application model.UserWithdrawalApplication
|
||||||
if err := s.db.First(&application, id).Error; err != nil {
|
if err := s.db.First(&application, id).Error; err != nil {
|
||||||
|
|||||||
@ -154,6 +154,7 @@ func New(cfg config.Config, auth *service.AuthService, store *repository.Store,
|
|||||||
appProtected.Use(middleware.RequireAppScope(store))
|
appProtected.Use(middleware.RequireAppScope(store))
|
||||||
|
|
||||||
authroutes.RegisterRoutes(api, protected, h.Auth)
|
authroutes.RegisterRoutes(api, protected, h.Auth)
|
||||||
|
financewithdrawal.RegisterIntegrationRoutes(api, h.FinanceWithdrawal)
|
||||||
externaladmin.RegisterExternalRoutes(api, h.ExternalAdmin, externaladmin.BusinessHandlers{
|
externaladmin.RegisterExternalRoutes(api, h.ExternalAdmin, externaladmin.BusinessHandlers{
|
||||||
AppUser: h.AppUser, HostOrg: h.HostOrg, RoomAdmin: h.RoomAdmin, Resource: h.Resource,
|
AppUser: h.AppUser, HostOrg: h.HostOrg, RoomAdmin: h.RoomAdmin, Resource: h.Resource,
|
||||||
PrettyID: h.PrettyID, AppConfig: h.AppConfig, VIPConfig: h.VIPConfig, Upload: h.Upload,
|
PrettyID: h.PrettyID, AppConfig: h.AppConfig, VIPConfig: h.VIPConfig, Upload: h.Upload,
|
||||||
|
|||||||
63
server/admin/migrations/104_external_withdrawal_sources.sql
Normal file
63
server/admin/migrations/104_external_withdrawal_sources.sql
Normal file
@ -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;
|
||||||
@ -119,12 +119,14 @@ func (w *MySQLWriter) CreateApplication(ctx context.Context, command CreateAppli
|
|||||||
|
|
||||||
result, err := w.db.ExecContext(ctx, `
|
result, err := w.db.ExecContext(ctx, `
|
||||||
INSERT INTO admin_user_withdrawal_applications
|
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,
|
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)
|
withdraw_method, withdraw_address, freeze_command_id, freeze_transaction_id, status, operations_status, created_at_ms, updated_at_ms)
|
||||||
VALUES
|
VALUES
|
||||||
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
command.AppCode,
|
command.AppCode,
|
||||||
|
"hyapp_wallet",
|
||||||
|
command.FreezeCommandID,
|
||||||
formatUserID(command.UserID),
|
formatUserID(command.UserID),
|
||||||
command.SalaryAssetType,
|
command.SalaryAssetType,
|
||||||
command.WithdrawAmount,
|
command.WithdrawAmount,
|
||||||
|
|||||||
@ -93,6 +93,8 @@ func TestCreateApplicationInsertsAfterAppScopedMiss(t *testing.T) {
|
|||||||
mock.ExpectExec(`(?s)INSERT INTO admin_user_withdrawal_applications`).
|
mock.ExpectExec(`(?s)INSERT INTO admin_user_withdrawal_applications`).
|
||||||
WithArgs(
|
WithArgs(
|
||||||
"huwaa",
|
"huwaa",
|
||||||
|
"hyapp_wallet",
|
||||||
|
"cmd-point-freeze",
|
||||||
"42001",
|
"42001",
|
||||||
"POINT",
|
"POINT",
|
||||||
"9.50",
|
"9.50",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user