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/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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
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 {
|
||||
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"`
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
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, `
|
||||
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,
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user