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" permissionAuditFinanceApplications = "finance-application:audit" pointWithdrawalDefaultPointsPerUSD = int64(100000) pointWithdrawalDefaultFeeBPS = int32(500) ) 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(options repository.WithdrawalApplicationListOptions) ([]withdrawalApplicationDTO, int64, error) { if s == nil || s.store == nil { return nil, 0, errors.New("admin store is not configured") } items, total, err := s.store.ListWithdrawalApplications(options) if err != nil { return nil, 0, err } return withdrawalApplicationDTOsFromModel(items), total, 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, 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, model.WithdrawalApplicationStatusRejected, remark, "", requestID) } func (s *Service) auditApplication(ctx context.Context, actor shared.Actor, id uint, 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 || !canAuditWithdrawal(actor.Permissions) { return nil, errors.New("没有操作权限") } remark = strings.TrimSpace(remark) auditImageURL = strings.TrimSpace(auditImageURL) if decision == model.WithdrawalApplicationStatusApproved && auditImageURL == "" { return nil, errors.New("提现通过凭证图片不能为空") } if decision == model.WithdrawalApplicationStatusRejected && remark == "" { return nil, errors.New("拒绝原因不能为空") } appCode := appctx.FromContext(ctx) item, err := s.store.GetWithdrawalApplicationForApp(appCode, id) if err != nil { return nil, err } if item.Status != model.WithdrawalApplicationStatusPending { return nil, repository.ErrWithdrawalApplicationAlreadyAudited } userID, err := strconv.ParseInt(strings.TrimSpace(item.UserID), 10, 64) if err != nil || userID <= 0 || item.SalaryAssetType == "" || item.WithdrawAmountMinor <= 0 || item.FreezeTransactionID == "" { return nil, errors.New("提现单冻结信息不完整") } commandID := withdrawalAuditCommandID(id, decision) amountMinor := item.WithdrawAmountMinor transactionID, err := s.applyWalletDecision(ctx, decision, commandID, appCode, userID, item.SalaryAssetType, amountMinor, actor, strings.TrimSpace(remark), id) if err != nil { return nil, err } nowMS := s.now().UnixMilli() if err := s.sendWithdrawalAuditNotice(ctx, auditNoticeInput{ ApplicationID: id, AppCode: appCode, UserID: userID, Decision: decision, Remark: remark, AuditImageURL: auditImageURL, AuditTransactionID: transactionID, WithdrawAmount: item.WithdrawAmount, WithdrawMethod: item.WithdrawMethod, RequestID: requestID, SentAtMS: nowMS, }); err != nil { // 钱包同意/释放已经用 command id 做幂等;通知失败时不写后台终态,财务重试会复用同一钱包命令和消息事件,避免重复扣款、返还或重复通知。 return nil, err } updated, err := s.store.AuditWithdrawalApplicationForApp(appCode, id, repository.WithdrawalApplicationAuditInput{ Decision: decision, ApproverUserID: actor.UserID, ApproverName: actor.Username, AuditRemark: remark, AuditImageURL: auditImageURL, AuditCommandID: commandID, AuditTransactionID: transactionID, ApprovedAtMS: nowMS, }) if err != nil { return nil, err } dto := withdrawalApplicationDTOFromModel(*updated) return &dto, nil } func (s *Service) applyWalletDecision(ctx context.Context, decision string, commandID string, appCode string, userID int64, assetType string, amountMinor int64, 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 := amountMinor * int64(pointWithdrawalDefaultFeeBPS) / 10000 netPoints := amountMinor - feePoints 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: pointWithdrawalDefaultPointsPerUSD, FeeBps: pointWithdrawalDefaultFeeBPS, 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: pointWithdrawalDefaultPointsPerUSD, FeeBps: pointWithdrawalDefaultFeeBPS, 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, decision string) string { return fmt.Sprintf("salary-withdrawal:%d:%s", id, decision) } 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 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" } 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), "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.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, decision string) string { return fmt.Sprintf("finance-withdrawal:%d:%s", id, strings.TrimSpace(decision)) } func canAuditWithdrawal(permissions []string) bool { for _, permission := range permissions { switch strings.TrimSpace(permission) { case permissionAuditWithdrawalApplications, permissionAuditFinanceApplications: return true } } return false }