hyapp-server/server/admin/internal/repository/withdrawal_application_repository.go
2026-07-16 18:51:58 +08:00

427 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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")
ErrWithdrawalApplicationStageNotReviewable = errors.New("withdrawal application is not ready for this review stage")
)
type WithdrawalApplicationReviewStage string
const (
WithdrawalApplicationReviewStageFinance WithdrawalApplicationReviewStage = "finance"
WithdrawalApplicationReviewStageOperations WithdrawalApplicationReviewStage = "operations"
)
type WithdrawalApplicationListOptions struct {
Page int
PageSize int
AppCode string
AllowedAppCodes []string
RestrictAppCodes bool
Keyword string
Stage WithdrawalApplicationReviewStage
}
type WithdrawalApplicationAuditInput struct {
Stage WithdrawalApplicationReviewStage
Decision string
ApproverUserID uint
ApproverName string
AuditRemark string
AuditImageURL string
AuditCommandID string
AuditTransactionID string
ApprovedAtMS int64
}
// WithdrawalApplicationAuditEffect 是持行锁期间完成的钱包命令结果。
// 运营通过没有资金副作用,因此允许返回空 command/transaction任一拒绝和财务通过都会保存可追溯的账务事实。
type WithdrawalApplicationAuditEffect struct {
CommandID string
TransactionID string
}
type WithdrawalApplicationAuditAction func(application model.UserWithdrawalApplication) (WithdrawalApplicationAuditEffect, error)
// WithdrawalApplicationClaimValidator 在拒绝 claim 提交前校验钱包释放所需的冻结快照。
// 校验必须留在持行锁的短事务内,避免坏数据先进入 rejecting 后再永久卡住人工流程。
type WithdrawalApplicationClaimValidator func(application model.UserWithdrawalApplication) error
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.ReviewWithdrawalApplicationForApp("", id, input, nil)
}
func (s *Store) AuditWithdrawalApplicationForApp(appCode string, id uint, input WithdrawalApplicationAuditInput) (*model.UserWithdrawalApplication, error) {
return s.ReviewWithdrawalApplicationForApp(appCode, id, input, nil)
}
// ClaimWithdrawalOperationsRejectionForApp 先独立提交 rejecting再允许调用 wallet/inbox。
// 这个持久门禁覆盖“钱包释放成功、但通知或 admin 最终提交失败”的窗口:后续运营通过和财务审核都会被 rejecting 拒绝,
// 只有相同阶段 command 的拒绝重试可以继续,且审核人、拒绝原因以第一次 claim 的快照为准。
func (s *Store) ClaimWithdrawalOperationsRejectionForApp(appCode string, id uint, input WithdrawalApplicationAuditInput, validate WithdrawalApplicationClaimValidator) (*model.UserWithdrawalApplication, error) {
if id == 0 {
return nil, errors.New("withdrawal application id is required")
}
if input.Stage != WithdrawalApplicationReviewStageOperations || input.Decision != model.WithdrawalApplicationStatusRejected {
return nil, errors.New("withdrawal application rejection claim is invalid")
}
input.AuditCommandID = strings.TrimSpace(input.AuditCommandID)
if input.AuditCommandID == "" || input.ApproverUserID == 0 || strings.TrimSpace(input.AuditRemark) == "" || input.ApprovedAtMS <= 0 {
return nil, errors.New("withdrawal application rejection claim is incomplete")
}
var application model.UserWithdrawalApplication
err := s.db.Transaction(func(tx *gorm.DB) error {
query := tx.Clauses(clause.Locking{Strength: "UPDATE"})
if strings.TrimSpace(appCode) != "" {
query = query.Where("app_code = ?", strings.TrimSpace(appCode))
}
if err := query.First(&application, id).Error; err != nil {
return err
}
operationsStatus := strings.TrimSpace(application.OperationsStatus)
if application.Status == model.WithdrawalApplicationStatusRejected && operationsStatus == model.WithdrawalOperationsStatusRejected {
// 已完成拒绝是终态幂等响应;不能再次触发 wallet 或 notice。
return nil
}
if application.Status != model.WithdrawalApplicationStatusPending {
return ErrWithdrawalApplicationAlreadyAudited
}
switch operationsStatus {
case model.WithdrawalOperationsStatusPending:
if validate != nil {
if err := validate(application); err != nil {
return err
}
}
updates := map[string]any{
"operations_status": model.WithdrawalOperationsStatusRejecting,
"operations_reviewer_user_id": input.ApproverUserID,
"operations_reviewer_name": strings.TrimSpace(input.ApproverName),
"operations_audit_remark": strings.TrimSpace(input.AuditRemark),
"operations_audit_command_id": input.AuditCommandID,
"operations_reviewed_at_ms": input.ApprovedAtMS,
"updated_at_ms": input.ApprovedAtMS,
}
if err := tx.Model(&application).Updates(updates).Error; err != nil {
return err
}
application.OperationsStatus = model.WithdrawalOperationsStatusRejecting
application.OperationsReviewerUserID = &input.ApproverUserID
application.OperationsReviewerName = strings.TrimSpace(input.ApproverName)
application.OperationsAuditRemark = strings.TrimSpace(input.AuditRemark)
application.OperationsAuditCommandID = input.AuditCommandID
application.OperationsReviewedAtMS = &input.ApprovedAtMS
application.UpdatedAtMS = input.ApprovedAtMS
return nil
case model.WithdrawalOperationsStatusRejecting:
// 重试不能改写第一次审核的上下文,否则钱包 metadata、通知原因和后台审计记录会彼此矛盾。
if strings.TrimSpace(application.OperationsAuditCommandID) != input.AuditCommandID {
return ErrWithdrawalApplicationStageNotReviewable
}
if validate != nil {
return validate(application)
}
return nil
default:
return ErrWithdrawalApplicationStageNotReviewable
}
})
if err != nil {
return nil, err
}
return &application, nil
}
// FinalizeWithdrawalOperationsRejectionForApp 只把已持久 claim 的 rejecting 收敛到双重 rejected 终态。
// wallet transaction id 非空且 stage command 必须与 claim 一致,防止其他流程借最终更新覆盖资金事实。
func (s *Store) FinalizeWithdrawalOperationsRejectionForApp(appCode string, id uint, commandID string, transactionID string, completedAtMS int64) (*model.UserWithdrawalApplication, error) {
commandID = strings.TrimSpace(commandID)
transactionID = strings.TrimSpace(transactionID)
if id == 0 || commandID == "" || transactionID == "" || completedAtMS <= 0 {
return nil, errors.New("withdrawal application rejection finalization is incomplete")
}
var application model.UserWithdrawalApplication
err := s.db.Transaction(func(tx *gorm.DB) error {
query := tx.Clauses(clause.Locking{Strength: "UPDATE"})
if strings.TrimSpace(appCode) != "" {
query = query.Where("app_code = ?", strings.TrimSpace(appCode))
}
if err := query.First(&application, id).Error; err != nil {
return err
}
operationsStatus := strings.TrimSpace(application.OperationsStatus)
if application.Status == model.WithdrawalApplicationStatusRejected && operationsStatus == model.WithdrawalOperationsStatusRejected {
if strings.TrimSpace(application.OperationsAuditCommandID) != commandID || strings.TrimSpace(application.OperationsAuditTransactionID) != transactionID {
return ErrWithdrawalApplicationStageNotReviewable
}
return nil
}
if application.Status != model.WithdrawalApplicationStatusPending || operationsStatus != model.WithdrawalOperationsStatusRejecting || strings.TrimSpace(application.OperationsAuditCommandID) != commandID {
return ErrWithdrawalApplicationStageNotReviewable
}
updates := map[string]any{
"status": model.WithdrawalApplicationStatusRejected,
"operations_status": model.WithdrawalOperationsStatusRejected,
"operations_audit_transaction_id": transactionID,
"updated_at_ms": completedAtMS,
}
if err := tx.Model(&application).Updates(updates).Error; err != nil {
return err
}
application.Status = model.WithdrawalApplicationStatusRejected
application.OperationsStatus = model.WithdrawalOperationsStatusRejected
application.OperationsAuditTransactionID = transactionID
application.UpdatedAtMS = completedAtMS
return nil
})
if err != nil {
return nil, err
}
return &application, nil
}
// ReviewWithdrawalApplicationForApp 把普通阶段校验、跨服务资金/通知动作和本地状态更新收敛在同一把申请行锁内。
// 提现审核是低 QPS、高价值资金边界这里有意在钱包和 inbox 网络调用期间持有行锁,让运营通过及财务通过/拒绝严格串行。
// 外部动作必须使用阶段固定的幂等 command/event id事务回滚后重试不会重复扣款或通知而相反决策会以同一 command id 的请求哈希冲突失败。
// 运营拒绝不能走本函数:它必须先用 Claim... 独立提交 rejecting再在外部动作成功后用 Finalize... 收敛终态。
func (s *Store) ReviewWithdrawalApplicationForApp(appCode string, id uint, input WithdrawalApplicationAuditInput, action WithdrawalApplicationAuditAction) (*model.UserWithdrawalApplication, error) {
if id == 0 {
return nil, errors.New("withdrawal application id is required")
}
if input.Stage == "" {
// 保留 repository 旧调用者的财务审核语义;新业务入口必须显式传阶段。
input.Stage = WithdrawalApplicationReviewStageFinance
}
if input.Stage != WithdrawalApplicationReviewStageFinance && input.Stage != WithdrawalApplicationReviewStageOperations {
return nil, errors.New("withdrawal application review stage is invalid")
}
if input.Decision != model.WithdrawalApplicationStatusApproved && input.Decision != model.WithdrawalApplicationStatusRejected {
return nil, errors.New("withdrawal application decision is invalid")
}
if input.Stage == WithdrawalApplicationReviewStageOperations && input.Decision == model.WithdrawalApplicationStatusRejected {
// 运营拒绝必须先提交 rejecting claim禁止旧调用者继续把钱包释放和本地终态放在一个可整体回滚的事务里。
return nil, errors.New("operations withdrawal rejection must use durable claim")
}
var application model.UserWithdrawalApplication
err := s.db.Transaction(func(tx *gorm.DB) error {
// 行锁保护当前阶段门禁:同一申请的运营通过/拒绝与后续财务通过/拒绝按提交顺序串行,不能跨阶段覆盖审核结果。
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 reviewErr := withdrawalApplicationReviewError(application, input.Stage); reviewErr != nil {
return reviewErr
}
effect := WithdrawalApplicationAuditEffect{
CommandID: strings.TrimSpace(input.AuditCommandID),
TransactionID: strings.TrimSpace(input.AuditTransactionID),
}
if action != nil {
var err error
effect, err = action(application)
if err != nil {
return err
}
}
updates := withdrawalApplicationReviewUpdates(input, effect)
if err := tx.Model(&application).Updates(updates).Error; err != nil {
return err
}
applyWithdrawalApplicationReview(&application, input, effect)
return nil
})
if err != nil {
return nil, err
}
return &application, nil
}
func withdrawalApplicationReviewError(application model.UserWithdrawalApplication, stage WithdrawalApplicationReviewStage) error {
if application.Status != model.WithdrawalApplicationStatusPending {
return ErrWithdrawalApplicationAlreadyAudited
}
operationsStatus := strings.TrimSpace(application.OperationsStatus)
if operationsStatus == "" {
// 数据库迁移只把历史终态行填成 skipped空值只兼容迁移前构造的模型和旧测试夹具。
operationsStatus = model.WithdrawalOperationsStatusSkipped
}
switch stage {
case WithdrawalApplicationReviewStageOperations:
if operationsStatus == model.WithdrawalOperationsStatusPending {
return nil
}
case WithdrawalApplicationReviewStageFinance:
if operationsStatus == model.WithdrawalOperationsStatusApproved || operationsStatus == model.WithdrawalOperationsStatusSkipped {
return nil
}
}
return ErrWithdrawalApplicationStageNotReviewable
}
func withdrawalApplicationReviewUpdates(input WithdrawalApplicationAuditInput, effect WithdrawalApplicationAuditEffect) map[string]any {
updates := map[string]any{"updated_at_ms": input.ApprovedAtMS}
if input.Stage == WithdrawalApplicationReviewStageOperations {
updates["operations_status"] = input.Decision
updates["operations_reviewer_user_id"] = input.ApproverUserID
updates["operations_reviewer_name"] = strings.TrimSpace(input.ApproverName)
updates["operations_audit_remark"] = strings.TrimSpace(input.AuditRemark)
updates["operations_audit_command_id"] = strings.TrimSpace(effect.CommandID)
updates["operations_audit_transaction_id"] = strings.TrimSpace(effect.TransactionID)
updates["operations_reviewed_at_ms"] = input.ApprovedAtMS
if input.Decision == model.WithdrawalApplicationStatusRejected {
updates["status"] = model.WithdrawalApplicationStatusRejected
}
return updates
}
updates["status"] = input.Decision
updates["approver_user_id"] = input.ApproverUserID
updates["approver_name"] = strings.TrimSpace(input.ApproverName)
updates["audit_remark"] = strings.TrimSpace(input.AuditRemark)
updates["audit_image_url"] = strings.TrimSpace(input.AuditImageURL)
updates["audit_command_id"] = strings.TrimSpace(effect.CommandID)
updates["audit_transaction_id"] = strings.TrimSpace(effect.TransactionID)
updates["approved_at_ms"] = input.ApprovedAtMS
return updates
}
func applyWithdrawalApplicationReview(application *model.UserWithdrawalApplication, input WithdrawalApplicationAuditInput, effect WithdrawalApplicationAuditEffect) {
if application == nil {
return
}
application.UpdatedAtMS = input.ApprovedAtMS
if input.Stage == WithdrawalApplicationReviewStageOperations {
application.OperationsStatus = input.Decision
application.OperationsReviewerUserID = &input.ApproverUserID
application.OperationsReviewerName = strings.TrimSpace(input.ApproverName)
application.OperationsAuditRemark = strings.TrimSpace(input.AuditRemark)
application.OperationsAuditCommandID = strings.TrimSpace(effect.CommandID)
application.OperationsAuditTransactionID = strings.TrimSpace(effect.TransactionID)
application.OperationsReviewedAtMS = &input.ApprovedAtMS
if input.Decision == model.WithdrawalApplicationStatusRejected {
application.Status = model.WithdrawalApplicationStatusRejected
}
return
}
application.Status = input.Decision
application.ApproverUserID = &input.ApproverUserID
application.ApproverName = strings.TrimSpace(input.ApproverName)
application.AuditRemark = strings.TrimSpace(input.AuditRemark)
application.AuditImageURL = strings.TrimSpace(input.AuditImageURL)
application.AuditCommandID = strings.TrimSpace(effect.CommandID)
application.AuditTransactionID = strings.TrimSpace(effect.TransactionID)
application.ApprovedAtMS = &input.ApprovedAtMS
}
// 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)
} else if options.RestrictAppCodes {
if len(options.AllowedAppCodes) == 0 {
// 有财务权限但未授予任何资金数据范围时必须返回空集,不能把空 IN 退化成全 App 查询。
query = query.Where("1 = 0")
} else {
query = query.Where("app_code IN ?", options.AllowedAppCodes)
}
}
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 operations_reviewer_name LIKE ? OR CAST(id AS CHAR) LIKE ?", like, like, like, like, like, like)
}
switch options.Stage {
case WithdrawalApplicationReviewStageFinance:
// skipped 是 102 迁移前的历史申请;它们保持原财务队列,不倒退补做运营初审。
query = query.Where("operations_status IN ?", []string{model.WithdrawalOperationsStatusApproved, model.WithdrawalOperationsStatusSkipped})
case WithdrawalApplicationReviewStageOperations:
// 运营页保留已审核历史,但永远不展示 skipped 旧单,避免把历史财务申请伪装成运营已审。
query = query.Where("operations_status <> ?", model.WithdrawalOperationsStatusSkipped)
}
return query
}