package repository import ( "errors" "strings" "hyapp-admin-server/internal/model" "gorm.io/gorm" "gorm.io/gorm/clause" ) var ErrWithdrawalApplicationAlreadyAudited = errors.New("withdrawal application already audited") type WithdrawalApplicationListOptions struct { Page int PageSize int AppCode string Keyword string } type WithdrawalApplicationAuditInput struct { Decision string ApproverUserID uint ApproverName string AuditRemark string AuditImageURL string AuditCommandID string AuditTransactionID string ApprovedAtMS int64 } func (s *Store) GetWithdrawalApplication(id uint) (*model.UserWithdrawalApplication, error) { var application model.UserWithdrawalApplication if err := s.db.First(&application, id).Error; err != nil { return nil, err } return &application, nil } func (s *Store) GetWithdrawalApplicationForApp(appCode string, id uint) (*model.UserWithdrawalApplication, error) { var application model.UserWithdrawalApplication if err := s.db.Where("app_code = ? AND id = ?", strings.TrimSpace(appCode), id).First(&application).Error; err != nil { return nil, err } return &application, nil } func (s *Store) ListWithdrawalApplications(options WithdrawalApplicationListOptions) ([]model.UserWithdrawalApplication, int64, error) { page, pageSize := normalizePage(options.Page, options.PageSize) query := s.db.Model(&model.UserWithdrawalApplication{}) query = applyWithdrawalApplicationFilters(query, options) var total int64 if err := query.Count(&total).Error; err != nil { return nil, 0, err } var applications []model.UserWithdrawalApplication // 提现申请是财务审核场景,默认按最新申请优先展示;id 作为同毫秒写入时的稳定次序,避免分页翻页时记录抖动。 err := query. Order("created_at_ms DESC, id DESC"). Limit(pageSize). Offset((page - 1) * pageSize). Find(&applications).Error return applications, total, err } func (s *Store) AuditWithdrawalApplication(id uint, input WithdrawalApplicationAuditInput) (*model.UserWithdrawalApplication, error) { return s.AuditWithdrawalApplicationForApp("", id, input) } func (s *Store) AuditWithdrawalApplicationForApp(appCode string, id uint, input WithdrawalApplicationAuditInput) (*model.UserWithdrawalApplication, error) { if id == 0 { return nil, errors.New("withdrawal application id is required") } if input.Decision != model.WithdrawalApplicationStatusApproved && input.Decision != model.WithdrawalApplicationStatusRejected { return nil, errors.New("withdrawal application decision is invalid") } var application model.UserWithdrawalApplication err := s.db.Transaction(func(tx *gorm.DB) error { // 审核只能从 pending 进入终态;行锁阻止两个财务同时覆盖同一张提现单的执行结果。 query := tx.Clauses(clause.Locking{Strength: "UPDATE"}) if strings.TrimSpace(appCode) != "" { // 后台按当前 App 上下文审核,不能只凭全局自增 ID 跨 App 锁定和审核提现单。 query = query.Where("app_code = ?", strings.TrimSpace(appCode)) } if err := query.First(&application, id).Error; err != nil { return err } if application.Status != model.WithdrawalApplicationStatusPending { return ErrWithdrawalApplicationAlreadyAudited } return tx.Model(&application).Updates(map[string]any{ "status": input.Decision, "approver_user_id": input.ApproverUserID, "approver_name": strings.TrimSpace(input.ApproverName), "audit_remark": strings.TrimSpace(input.AuditRemark), "audit_image_url": strings.TrimSpace(input.AuditImageURL), "audit_command_id": strings.TrimSpace(input.AuditCommandID), "audit_transaction_id": strings.TrimSpace(input.AuditTransactionID), "approved_at_ms": input.ApprovedAtMS, "updated_at_ms": input.ApprovedAtMS, }).Error }) if err != nil { return nil, err } if strings.TrimSpace(appCode) != "" { return s.GetWithdrawalApplicationForApp(appCode, id) } return s.GetWithdrawalApplication(id) } // ApprovedWithdrawalStats 按审批通过时间聚合用户提现申请的 USDT 金额。 // 旧工资提现用 withdraw_amount_minor 保存美分;Huwaa POINT 提现该字段保存 gross points,因此必须用 withdraw_amount 的 USD 展示值折算。 type ApprovedWithdrawalStats struct { ApprovedCount int64 ApprovedUSDMinor int64 } func (s *Store) ApprovedWithdrawalStats(appCode string, startAtMS int64, endAtMS int64) (ApprovedWithdrawalStats, error) { query := s.db.Model(&model.UserWithdrawalApplication{}). Where("status = ?", model.WithdrawalApplicationStatusApproved) if appCode = strings.TrimSpace(appCode); appCode != "" { query = query.Where("app_code = ?", appCode) } if startAtMS > 0 { query = query.Where("approved_at_ms >= ?", startAtMS) } if endAtMS > 0 { query = query.Where("approved_at_ms < ?", endAtMS) } var stats ApprovedWithdrawalStats row := query.Select(` COUNT(*), COALESCE(SUM(CASE WHEN salary_asset_type IN ('POINT', 'COIN_SELLER_POINT') THEN CAST(withdraw_amount * 100 AS SIGNED) ELSE withdraw_amount_minor END), 0)`).Row() if err := row.Scan(&stats.ApprovedCount, &stats.ApprovedUSDMinor); err != nil { return ApprovedWithdrawalStats{}, err } return stats, nil } func applyWithdrawalApplicationFilters(query *gorm.DB, options WithdrawalApplicationListOptions) *gorm.DB { if appCode := strings.TrimSpace(options.AppCode); appCode != "" { query = query.Where("app_code = ?", appCode) } if keyword := strings.TrimSpace(options.Keyword); keyword != "" { like := "%" + keyword + "%" // 关键词面向财务排查,覆盖用户、收款方式、收款地址和审批人;不做金额模糊,避免 decimal 隐式转换拖慢常规查询。 query = query.Where("user_id LIKE ? OR withdraw_method LIKE ? OR withdraw_address LIKE ? OR approver_name LIKE ? OR CAST(id AS CHAR) LIKE ?", like, like, like, like, like) } return query }