feat(finance): support cross-app withdrawal review

This commit is contained in:
zhx 2026-07-17 18:38:10 +08:00
parent a10213b4d8
commit 764caa2193
15 changed files with 578 additions and 47 deletions

View File

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

View File

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

View File

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

View File

@ -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 == "" {

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

View File

@ -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"`

View File

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

View File

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

View File

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

View File

@ -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 == "" {

View File

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

View File

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

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

View File

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

View File

@ -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",