package financewithdrawal import ( "context" "encoding/json" "errors" "fmt" "strconv" "strings" "time" "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/integration/activityclient" "hyapp-admin-server/internal/integration/walletclient" "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 now func() time.Time } 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 (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.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) { 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) 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 } return repository.WithdrawalApplicationAuditEffect{CommandID: commandID, TransactionID: transactionID}, nil }) if err != nil { return nil, err } dto := withdrawalApplicationDTOFromModel(*updated) return &dto, nil } // 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 { _, err := withdrawalWalletUserID(item) return err }) if err != nil { return nil, err } if claimed.Status == model.WithdrawalApplicationStatusRejected && claimed.OperationsStatus == model.WithdrawalOperationsStatusRejected { // 已完成的重复请求直接返回终态,避免再次调用 wallet/inbox;前端重试是安全的。 dto := withdrawalApplicationDTOFromModel(*claimed) 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) if err != nil { return nil, err } sentAtMS := nowMS 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 } updated, err := s.store.FinalizeWithdrawalOperationsRejectionForApp(appCode, id, commandID, 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 (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 request hash 校验处冲突,不能先后 settle/release 同一冻结金额。 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 }