package financewithdrawal import ( "context" "encoding/json" "errors" "fmt" "log/slog" "math/big" "strconv" "strings" "time" "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" activityv1 "hyapp.local/api/proto/activity/v1" walletv1 "hyapp.local/api/proto/wallet/v1" ) const ( permissionViewWithdrawalApplications = "finance-withdrawal:view" permissionAuditWithdrawalApplications = "finance-withdrawal:audit" permissionViewOperationsWithdrawals = "operations-withdrawal:view" permissionAuditOperationsWithdrawals = "operations-withdrawal:audit" pointWithdrawalDefaultPointsPerUSD = int64(100000) pointWithdrawalDefaultFeeBPS = int32(500) ) var errWithdrawalMoneyScopeForbidden = errors.New("没有该 App 的财务范围") type Service struct { store *repository.Store wallet walletclient.Client activity activityclient.Client sources *withdrawalsource.Registry notifier ReviewPendingNotifier now func() time.Time } type externalWithdrawalApplicationInput struct { AppCode string SourceSystem string SourceApplicationID string UserID string SalaryAssetType string WithdrawAmount string WithdrawAmountMinor int64 BalanceBefore string ServiceChargeRatio string ServiceCharge string ActualAmount string ActualAmountMinor *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) } func NewService(store *repository.Store, wallet walletclient.Client, activity activityclient.Client) *Service { 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) BindReviewPendingNotifier(notifier ReviewPendingNotifier) *Service { if s != nil { s.notifier = notifier } return s } func (s *Service) CreateExternalApplication(ctx context.Context, 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.BalanceBefore = strings.TrimSpace(input.BalanceBefore) input.ServiceChargeRatio = strings.TrimSpace(input.ServiceChargeRatio) input.ServiceCharge = strings.TrimSpace(input.ServiceCharge) input.ActualAmount = strings.TrimSpace(input.ActualAmount) 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 } if err := validateExternalWithdrawalBalanceBefore(input.BalanceBefore, input.WithdrawAmount); err != nil { return nil, err } if err := validateExternalWithdrawalPayoutSnapshot(input); err != nil { return nil, err } createdAtMS := input.CreatedAtMS if createdAtMS <= 0 { createdAtMS = s.now().UnixMilli() } application, created, 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, BalanceBefore: optionalDecimalString(input.BalanceBefore), ServiceChargeRatio: optionalDecimalString(input.ServiceChargeRatio), ServiceCharge: optionalDecimalString(input.ServiceCharge), ActualAmount: optionalDecimalString(input.ActualAmount), ActualAmountMinor: input.ActualAmountMinor, 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 } // 来源采用持久重试;只有首次成功落库才提醒运营,HTTP 响应丢失后的幂等重放不能反复轰炸审核群。 if created { s.notifyReviewPending(ctx, *application, repository.WithdrawalApplicationReviewStageOperations) } 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 validateExternalWithdrawalBalanceBefore(balanceBefore string, withdrawAmount string) error { if balanceBefore == "" { // 兼容字段上线前已经进入 Likei 持久重试队列的申请;这些历史单在审核页明确显示“-”。 return nil } balance, ok := new(big.Rat).SetString(balanceBefore) if !ok || balance.Sign() < 0 { return errors.New("提现前余额不正确") } amount, ok := new(big.Rat).SetString(withdrawAmount) if !ok || balance.Cmp(amount) < 0 { return errors.New("提现前余额不能小于提现金额") } return nil } func validateExternalWithdrawalPayoutSnapshot(input externalWithdrawalApplicationInput) error { ratioText := strings.TrimSpace(input.ServiceChargeRatio) chargeText := strings.TrimSpace(input.ServiceCharge) actualText := strings.TrimSpace(input.ActualAmount) presentCount := 0 for _, present := range []bool{ratioText != "", chargeText != "", actualText != "", input.ActualAmountMinor != nil} { if present { presentCount++ } } if presentCount == 0 { // ChatApp3 的历史字段同时包含费率和已向上取整的手续费结果;缺快照时 Admin 无法安全推导实际打款额。 if strings.EqualFold(strings.TrimSpace(input.SourceSystem), "chatapp3") { return errors.New("ChatApp3 提现缺少手续费与实际打款快照") } return nil } if presentCount != 4 { return errors.New("提现手续费与实际打款快照不完整") } ratio, _, ok := parseExternalFixedDecimal(ratioText, 9, 4) if !ok || ratio.Sign() < 0 || ratio.Cmp(big.NewRat(100, 1)) > 0 { return errors.New("提现手续费率不正确") } charge, chargeMinor, ok := parseExternalFixedDecimal(chargeText, 18, 2) if !ok || charge.Sign() < 0 { return errors.New("提现手续费不正确") } actual, actualMinor, ok := parseExternalFixedDecimal(actualText, 18, 2) if !ok || actual.Sign() <= 0 { return errors.New("实际打款金额不正确") } if !actualMinor.IsInt64() || actualMinor.Int64() != *input.ActualAmountMinor { return errors.New("实际打款金额与最小单位金额不一致") } gross, _, ok := parseExternalFixedDecimal(input.WithdrawAmount, 18, 2) if !ok || new(big.Rat).Add(charge, actual).Cmp(gross) != 0 { return errors.New("申请金额、手续费与实际打款金额不一致") } // 同时校验最小单位表示,防止 decimal 展示正确但上游分值被截断或溢出。 payoutMinor := new(big.Int).Add(chargeMinor, big.NewInt(*input.ActualAmountMinor)) if !payoutMinor.IsInt64() || payoutMinor.Int64() != input.WithdrawAmountMinor { return errors.New("手续费、实际打款与申请金额的最小单位不一致") } return nil } func parseExternalFixedDecimal(value string, precision int, scale int) (*big.Rat, *big.Int, bool) { value = strings.TrimSpace(value) if value == "" || len(value) > 64 || precision <= 0 || scale < 0 || scale > precision { return nil, nil, false } negative := false if value[0] == '+' || value[0] == '-' { negative = value[0] == '-' value = value[1:] } parts := strings.Split(value, ".") if len(parts) > 2 || parts[0] == "" || !isExternalDecimalDigits(parts[0]) { return nil, nil, false } fraction := "" if len(parts) == 2 { fraction = parts[1] if fraction == "" || !isExternalDecimalDigits(fraction) { return nil, nil, false } } if len(fraction) > scale { // 文本小数位超出列 scale 时即使多出的是 0 也拒绝,避免契约精度在入库时被隐式改写。 return nil, nil, false } scaleFactor := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(scale)), nil) scaledDigits := parts[0] + fraction + strings.Repeat("0", scale-len(fraction)) scaled, ok := new(big.Int).SetString(scaledDigits, 10) if !ok { return nil, nil, false } if negative { scaled.Neg(scaled) } maxScaled := new(big.Int).Sub(new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(precision)), nil), big.NewInt(1)) if new(big.Int).Abs(new(big.Int).Set(scaled)).Cmp(maxScaled) > 0 { return nil, nil, false } // 先按存储 scale 构造精确有理数,后续所有金额恒等式都不经过 float64 或四舍五入。 return new(big.Rat).SetFrac(new(big.Int).Set(scaled), scaleFactor), scaled, true } func isExternalDecimalDigits(value string) bool { for i := 0; i < len(value); i++ { if value[i] < '0' || value[i] > '9' { return false } } return value != "" } func optionalDecimalString(value string) *string { if value == "" { return nil } return &value } 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") } var err error options, err = s.scopeWithdrawalList(actor, options) if err != nil { return nil, 0, err } options.Stage = repository.WithdrawalApplicationReviewStageFinance items, total, err := s.store.ListWithdrawalApplications(options) if err != nil { return nil, 0, err } return withdrawalApplicationDTOsFromModel(items), total, nil } func (s *Service) ListOperationsApplications(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") } var err error options, err = s.scopeWithdrawalList(actor, options) if err != nil { return nil, 0, err } options.Stage = repository.WithdrawalApplicationReviewStageOperations items, total, err := s.store.ListWithdrawalApplications(options) if err != nil { return nil, 0, err } return withdrawalApplicationDTOsFromModel(items), total, nil } func (s *Service) scopeWithdrawalList(actor shared.Actor, options repository.WithdrawalApplicationListOptions) (repository.WithdrawalApplicationListOptions, error) { access, err := s.store.MoneyAccessForUser(actor.UserID) if err != nil { return options, err } if requestedApp := strings.TrimSpace(options.AppCode); requestedApp != "" { options.AppCode = appctx.Normalize(requestedApp) if !access.AllowsApp(options.AppCode) { return options, errWithdrawalMoneyScopeForbidden } return options, nil } options.AppCode = "" if !access.All { // 空 app 查询不代表全局;非全量财务用户只能在 admin_user_money_scopes 授权的 App 集合中分页。 options.RestrictAppCodes = true options.AllowedAppCodes = access.AppCodes() } return options, nil } func (s *Service) ApproveApplication(ctx context.Context, actor shared.Actor, id uint, remark string, auditImageURL string, requestID string) (*withdrawalApplicationDTO, error) { return s.auditApplication(ctx, actor, id, repository.WithdrawalApplicationReviewStageFinance, model.WithdrawalApplicationStatusApproved, remark, auditImageURL, requestID) } func (s *Service) RejectApplication(ctx context.Context, actor shared.Actor, id uint, remark string, requestID string) (*withdrawalApplicationDTO, error) { return s.auditApplication(ctx, actor, id, repository.WithdrawalApplicationReviewStageFinance, model.WithdrawalApplicationStatusRejected, remark, "", requestID) } func (s *Service) ApproveOperationsApplication(ctx context.Context, actor shared.Actor, id uint, remark string, requestID string) (*withdrawalApplicationDTO, error) { return s.auditApplication(ctx, actor, id, repository.WithdrawalApplicationReviewStageOperations, model.WithdrawalApplicationStatusApproved, remark, "", requestID) } func (s *Service) RejectOperationsApplication(ctx context.Context, actor shared.Actor, id uint, remark string, requestID string) (*withdrawalApplicationDTO, error) { return s.auditApplication(ctx, actor, id, repository.WithdrawalApplicationReviewStageOperations, model.WithdrawalApplicationStatusRejected, remark, "", requestID) } func (s *Service) auditApplication(ctx context.Context, actor shared.Actor, id uint, stage repository.WithdrawalApplicationReviewStage, decision string, remark string, auditImageURL string, requestID string) (*withdrawalApplicationDTO, error) { if s == nil || s.store == nil || s.wallet == nil || s.activity == nil { return nil, errors.New("admin finance withdrawal service is not configured") } if id == 0 || actor.UserID == 0 || !canAuditWithdrawalStage(stage, actor.Permissions) { return nil, errors.New("没有操作权限") } remark = strings.TrimSpace(remark) auditImageURL = strings.TrimSpace(auditImageURL) if stage == repository.WithdrawalApplicationReviewStageFinance && decision == model.WithdrawalApplicationStatusApproved && auditImageURL == "" { return nil, errors.New("提现通过凭证图片不能为空") } if decision == model.WithdrawalApplicationStatusRejected && remark == "" { return nil, errors.New("拒绝原因不能为空") } appCode := appctx.FromContext(ctx) access, err := s.store.MoneyAccessForUser(actor.UserID) if err != nil { return nil, err } if !access.AllowsApp(appCode) { // 审核以目标行的 App header 为数据域;即使 JWT 含按钮权限,也不能跨 admin_user_money_scopes 触发钱包动作。 return nil, errWithdrawalMoneyScopeForbidden } nowMS := s.now().UnixMilli() commandID := withdrawalAuditCommandID(id, stage) if stage == repository.WithdrawalApplicationReviewStageFinance { current, err := s.store.GetWithdrawalApplicationForApp(appCode, id) if err != nil { return nil, err } if isExternalWithdrawal(*current) { // 外部钱包回调和 Admin 终态不可能共享一个数据库事务;先持久首次财务 claim 才能安全调用回调。 return s.executeExternalFinanceDecision(ctx, actor, id, appCode, decision, remark, auditImageURL, commandID, nowMS) } } if stage == repository.WithdrawalApplicationReviewStageOperations && decision == model.WithdrawalApplicationStatusRejected { return s.executeOperationsRejection(ctx, actor, id, appCode, remark, requestID, commandID, nowMS) } updated, err := s.store.ReviewWithdrawalApplicationForApp(appCode, id, repository.WithdrawalApplicationAuditInput{ Stage: stage, Decision: decision, ApproverUserID: actor.UserID, ApproverName: actor.Username, AuditRemark: remark, AuditImageURL: auditImageURL, ApprovedAtMS: nowMS, }, func(item model.UserWithdrawalApplication) (repository.WithdrawalApplicationAuditEffect, error) { if stage == repository.WithdrawalApplicationReviewStageOperations && decision == model.WithdrawalApplicationStatusApproved { // 运营通过只确认业务资料并转交财务;申请金额继续保留在 frozen,且不能提前向用户发送最终通过通知。 return repository.WithdrawalApplicationAuditEffect{}, nil } transactionID, userID, walletErr := s.applyWithdrawalDecision(ctx, item, decision, commandID, appCode, actor, remark, auditImageURL, id) if walletErr != nil { return repository.WithdrawalApplicationAuditEffect{}, walletErr } 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 }) if err != nil { return nil, err } if stage == repository.WithdrawalApplicationReviewStageOperations && decision == model.WithdrawalApplicationStatusApproved { // 运营通过已经提交到数据库,随后独立提醒财务;机器人异常不能把已转交的申请回滚到运营队列。 s.notifyReviewPending(ctx, *updated, repository.WithdrawalApplicationReviewStageFinance) } dto := withdrawalApplicationDTOFromModel(*updated) return &dto, nil } func (s *Service) notifyReviewPending(ctx context.Context, application model.UserWithdrawalApplication, stage repository.WithdrawalApplicationReviewStage) { if s == nil || s.notifier == nil { return } if err := s.notifier.NotifyReviewPending(ctx, application, stage); err != nil { slog.Warn("withdrawal_review_dingtalk_notify_failed", "application_id", application.ID, "app_code", application.AppCode, "review_stage", stage, "error", err, ) } } // executeOperationsRejection 把拒绝拆成 claim -> 外部幂等动作 -> finalize 三段。 // claim 是独立短事务,因此 wallet 已释放后即使 notice 或最终 admin 提交失败,申请仍停在 rejecting,不能被改点通过或进入财务。 func (s *Service) executeOperationsRejection(ctx context.Context, actor shared.Actor, id uint, appCode string, remark string, requestID string, commandID string, nowMS int64) (*withdrawalApplicationDTO, error) { claimed, err := s.store.ClaimWithdrawalOperationsRejectionForApp(appCode, id, repository.WithdrawalApplicationAuditInput{ Stage: repository.WithdrawalApplicationReviewStageOperations, Decision: model.WithdrawalApplicationStatusRejected, ApproverUserID: actor.UserID, ApproverName: actor.Username, AuditRemark: remark, AuditCommandID: commandID, ApprovedAtMS: nowMS, }, func(item model.UserWithdrawalApplication) error { return validateWithdrawalDecisionTarget(item) }) if err != nil { return nil, err } if claimed.Status == model.WithdrawalApplicationStatusRejected && claimed.OperationsStatus == model.WithdrawalOperationsStatusRejected { // 已完成的重复请求直接返回终态,避免再次调用 wallet/inbox;前端重试是安全的。 dto := withdrawalApplicationDTOFromModel(*claimed) return &dto, nil } 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, userID, err := s.applyWithdrawalDecision(ctx, *claimed, model.WithdrawalApplicationStatusRejected, commandID, appCode, claimedActor, claimedRemark, "", id) if err != nil { return nil, err } sentAtMS := nowMS if claimed.OperationsReviewedAtMS != nil && *claimed.OperationsReviewedAtMS > 0 { sentAtMS = *claimed.OperationsReviewedAtMS } 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 { return nil, err } dto := withdrawalApplicationDTOFromModel(*updated) return &dto, nil } // executeExternalFinanceDecision 把外部财务终审拆成 claim -> 来源幂等回调 -> finalize。 // claim 一旦提交,后续任何审核人的同决策重试都只能沿用第一次审计快照,相反决策在回调前被拒绝。 func (s *Service) executeExternalFinanceDecision(ctx context.Context, actor shared.Actor, id uint, appCode string, decision string, remark string, auditImageURL string, commandID string, nowMS int64) (*withdrawalApplicationDTO, error) { claimed, err := s.store.ClaimExternalWithdrawalFinanceDecisionForApp(appCode, id, repository.WithdrawalApplicationAuditInput{ Stage: repository.WithdrawalApplicationReviewStageFinance, Decision: decision, ApproverUserID: actor.UserID, ApproverName: actor.Username, AuditRemark: remark, AuditImageURL: auditImageURL, AuditCommandID: commandID, ApprovedAtMS: nowMS, }, func(item model.UserWithdrawalApplication) error { return validateWithdrawalDecisionTarget(item) }) if err != nil { return nil, err } claimStatus := "" if claimed.ExternalFinanceClaimStatus != nil { claimStatus = strings.TrimSpace(*claimed.ExternalFinanceClaimStatus) } if claimStatus == model.WithdrawalExternalFinanceClaimStatusFinalized { // 上次 finalize 可能已提交但返回链路中断;终态幂等返回不再重复回调。 dto := withdrawalApplicationDTOFromModel(*claimed) return &dto, nil } if claimStatus != model.WithdrawalExternalFinanceClaimStatusClaimed || claimed.ExternalFinanceClaimDecision == nil || claimed.ApproverUserID == nil || *claimed.ApproverUserID == 0 || claimed.ApprovedAtMS == nil || *claimed.ApprovedAtMS <= 0 || strings.TrimSpace(claimed.AuditCommandID) == "" { return nil, errors.New("外部提现财务 claim 不完整") } claimedDecision := strings.TrimSpace(*claimed.ExternalFinanceClaimDecision) claimedRemark := strings.TrimSpace(claimed.AuditRemark) claimedImageURL := strings.TrimSpace(claimed.AuditImageURL) claimedActor := shared.Actor{UserID: *claimed.ApproverUserID, Username: claimed.ApproverName} transactionID, _, err := s.applyWithdrawalDecision(ctx, *claimed, claimedDecision, claimed.AuditCommandID, appCode, claimedActor, claimedRemark, claimedImageURL, id) if err != nil { // claim 已在独立事务持久,回调失败只返错误供财务按相同决策重试,不得回退或改写 claim。 return nil, err } updated, err := s.store.FinalizeExternalWithdrawalFinanceDecisionForApp(appCode, id, claimedDecision, claimed.AuditCommandID, transactionID, s.now().UnixMilli()) if err != nil { return nil, err } dto := withdrawalApplicationDTOFromModel(*updated) return &dto, nil } func withdrawalWalletUserID(item model.UserWithdrawalApplication) (int64, error) { userID, err := strconv.ParseInt(strings.TrimSpace(item.UserID), 10, 64) if err != nil || userID <= 0 || strings.TrimSpace(item.SalaryAssetType) == "" || item.WithdrawAmountMinor <= 0 || strings.TrimSpace(item.FreezeTransactionID) == "" { return 0, errors.New("提现单冻结信息不完整") } 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 == "" { reason = "salary withdrawal " + decision } applicationIDText := strconv.FormatUint(uint64(applicationID), 10) if isPointWithdrawalAssetType(assetType) { pointWallet, ok := s.wallet.(pointWithdrawalWallet) if !ok { return "", errors.New("point withdrawal wallet executor is not configured") } feePoints, netPoints, pointsPerUSD, feeBPS := pointWithdrawalSnapshot(model.UserWithdrawalApplication{ WithdrawAmountMinor: amountMinor, PointFeeAmount: storedFeePoints, PointNetAmount: storedNetPoints, PointsPerUSD: storedPointsPerUSD, PointFeeBPS: storedFeeBPS, }) switch decision { case model.WithdrawalApplicationStatusApproved: resp, err := pointWallet.SettlePointWithdrawal(ctx, &walletv1.SettlePointWithdrawalRequest{ CommandId: commandID, UserId: userID, AssetType: assetType, GrossPointAmount: amountMinor, FeePointAmount: feePoints, NetPointAmount: netPoints, PointsPerUsd: pointsPerUSD, FeeBps: feeBPS, OperatorUserId: int64(actor.UserID), Reason: reason, AppCode: appCode, WithdrawalApplicationId: applicationIDText, }) if err != nil { return "", err } return resp.GetTransactionId(), nil case model.WithdrawalApplicationStatusRejected: resp, err := pointWallet.ReleasePointWithdrawal(ctx, &walletv1.ReleasePointWithdrawalRequest{ CommandId: commandID, UserId: userID, AssetType: assetType, GrossPointAmount: amountMinor, FeePointAmount: feePoints, NetPointAmount: netPoints, PointsPerUsd: pointsPerUSD, FeeBps: feeBPS, OperatorUserId: int64(actor.UserID), Reason: reason, AppCode: appCode, WithdrawalApplicationId: applicationIDText, }) if err != nil { return "", err } return resp.GetTransactionId(), nil default: return "", errors.New("审核结果不正确") } } switch decision { case model.WithdrawalApplicationStatusApproved: resp, err := s.wallet.SettleSalaryWithdrawal(ctx, &walletv1.SettleSalaryWithdrawalRequest{ CommandId: commandID, UserId: userID, SalaryAssetType: assetType, SalaryUsdMinor: amountMinor, OperatorUserId: int64(actor.UserID), Reason: reason, AppCode: appCode, WithdrawalApplicationId: applicationIDText, }) if err != nil { return "", err } return resp.GetTransactionId(), nil case model.WithdrawalApplicationStatusRejected: resp, err := s.wallet.ReleaseSalaryWithdrawal(ctx, &walletv1.ReleaseSalaryWithdrawalRequest{ CommandId: commandID, UserId: userID, SalaryAssetType: assetType, SalaryUsdMinor: amountMinor, OperatorUserId: int64(actor.UserID), Reason: reason, AppCode: appCode, WithdrawalApplicationId: applicationIDText, }) if err != nil { return "", err } return resp.GetTransactionId(), nil default: return "", errors.New("审核结果不正确") } } func withdrawalAuditCommandID(id uint, stage repository.WithdrawalApplicationReviewStage) string { // 同一阶段的通过和拒绝复用 command id;内部 wallet 用请求哈希防相反决策,外部回调则由持久 finance claim 先锁定决策和审计上下文。 return fmt.Sprintf("salary-withdrawal:%d:%s", id, stage) } func isPointWithdrawalAssetType(assetType string) bool { switch strings.ToUpper(strings.TrimSpace(assetType)) { case "POINT", "COIN_SELLER_POINT": return true default: return false } } type auditNoticeInput struct { ApplicationID uint AppCode string UserID int64 Stage repository.WithdrawalApplicationReviewStage Decision string Remark string AuditImageURL string AuditTransactionID string WithdrawAmount string WithdrawMethod string RequestID string SentAtMS int64 } func (s *Service) sendWithdrawalAuditNotice(ctx context.Context, input auditNoticeInput) error { body := "Your withdrawal request has been approved." title := "Withdrawal request approved" imageURL := "" eventType := "finance_withdrawal_approved" if input.Decision == model.WithdrawalApplicationStatusRejected { body = "Your withdrawal request has been rejected. Reason: " + strings.TrimSpace(input.Remark) title = "Withdrawal request rejected" eventType = "finance_withdrawal_rejected" if input.Stage == repository.WithdrawalApplicationReviewStageOperations { eventType = "operations_withdrawal_rejected" } } else { imageURL = strings.TrimSpace(input.AuditImageURL) } metadata, err := json.Marshal(map[string]any{ "application_id": input.ApplicationID, "app_code": strings.TrimSpace(input.AppCode), "audit_transaction_id": strings.TrimSpace(input.AuditTransactionID), "decision": strings.TrimSpace(input.Decision), "review_stage": string(input.Stage), "withdraw_amount": strings.TrimSpace(input.WithdrawAmount), "withdraw_method": strings.TrimSpace(input.WithdrawMethod), }) if err != nil { return err } _, err = s.activity.CreateInboxMessage(ctx, &activityv1.CreateInboxMessageRequest{ Meta: &activityv1.RequestMeta{ RequestId: strings.TrimSpace(input.RequestID), Caller: "admin-server", AppCode: strings.TrimSpace(input.AppCode), SentAtMs: input.SentAtMS, }, TargetUserId: input.UserID, Producer: "admin-server", ProducerEventId: withdrawalAuditNotificationEventID(input.ApplicationID, input.Stage, input.Decision), ProducerEventType: eventType, MessageType: "system", AggregateType: "user_withdrawal_application", AggregateId: strconv.FormatUint(uint64(input.ApplicationID), 10), Title: title, Summary: body, Body: body, ImageUrl: imageURL, SentAtMs: input.SentAtMS, MetadataJson: string(metadata), }) if err != nil { return fmt.Errorf("发送提现审核通知失败: %w", err) } return nil } func withdrawalAuditNotificationEventID(id uint, stage repository.WithdrawalApplicationReviewStage, decision string) string { if stage == repository.WithdrawalApplicationReviewStageFinance { // 旧财务通知可能已成功而 admin 申请仍 pending;继续使用原 event id,部署后重试才不会重复发用户通知。 return fmt.Sprintf("finance-withdrawal:%d:%s", id, strings.TrimSpace(decision)) } return fmt.Sprintf("operations-withdrawal:%d:%s", id, strings.TrimSpace(decision)) } func canAuditWithdrawal(permissions []string) bool { return hasWithdrawalPermission(permissions, permissionAuditWithdrawalApplications) } func canAuditOperationsWithdrawal(permissions []string) bool { return hasWithdrawalPermission(permissions, permissionAuditOperationsWithdrawals) } func canAuditWithdrawalStage(stage repository.WithdrawalApplicationReviewStage, permissions []string) bool { if stage == repository.WithdrawalApplicationReviewStageOperations { return canAuditOperationsWithdrawal(permissions) } return canAuditWithdrawal(permissions) } func hasWithdrawalPermission(permissions []string, expected string) bool { for _, permission := range permissions { if strings.TrimSpace(permission) == expected { return true } } return false }