package distributionwithdrawal import ( "context" "time" "gorm.io/gorm" "gorm.io/gorm/clause" approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/pkg/auditcontext" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) // QualificationApprovalHandler 将渠道无关企业微信终态应用到提现资料资格版本。 // 通过才使版本生效;驳回只标记该版本,不影响其他版本已记录的审批结果。 type QualificationApprovalHandler struct { db *gorm.DB audit AuditWriter } // NewQualificationApprovalHandler 创建提现资料资格审批终态消费者。 func NewQualificationApprovalHandler(db *gorm.DB, audit AuditWriter) *QualificationApprovalHandler { return &QualificationApprovalHandler{db: db, audit: audit} } // Handle 幂等消费标准审批终态。 // 业务标识为资料版本主键;先锁定版本并校验审批实例一致,再以条件更新推进状态。 func (h *QualificationApprovalHandler) Handle(ctx context.Context, event approvalapp.TerminalDecisionEvent) error { if h == nil || h.db == nil || h.audit == nil { return errors.New(errors.CodeInternalError, "提现资料资格审批终态能力未配置") } if event.BusinessType != constants.ApprovalBusinessTypeWithdrawalQualification || event.BusinessID == 0 || event.InstanceID == 0 { return errors.New(errors.CodeInvalidParam, "提现资料资格审批终态参数无效") } ctx = auditcontext.With(ctx, auditcontext.Context{ CorrelationID: event.CorrelationID, ParentEventID: event.EventID, }) return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var version model.WithdrawalQualification if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). First(&version, event.BusinessID).Error; err != nil { if err == gorm.ErrRecordNotFound { return errors.New(errors.CodeNotFound, "提现资料资格版本不存在") } return errors.Wrap(errors.CodeDatabaseError, err, "锁定提现资料资格版本失败") } if version.ApprovalInstanceID == nil || *version.ApprovalInstanceID != event.InstanceID { return errors.New(errors.CodeConflict, "提现资料资格版本关联的审批实例不一致") } if version.Status != constants.WithdrawalQualificationStatusPending { // 已是终态(含被替换或作废):重复或乱序回调不再改变事实。 return nil } // 店铺可能已被软删除:终态必须仍能收敛,不得把「店铺不存在」当成致命错误, // 否则该版本永久卡在待审批且终态事件永久重投。审计的店铺资源此时允许为空。 shop := loadShopOrNil(ctx, tx, version.ShopID) before := qualificationAuditSnapshot(&version) switch event.Decision { case constants.ApprovalDecisionApproved: return h.applyApproved(ctx, tx, &version, shop, before, event) case constants.ApprovalDecisionRejected, constants.ApprovalDecisionCancelled, constants.ApprovalDecisionDeleted, constants.ApprovalDecisionRevokedAfterApproved: return h.applyRejected(ctx, tx, &version, shop, before, event) default: return errors.New(errors.CodeInvalidParam, "不支持的提现资料资格审批终态") } }) } // applyApproved 使资料版本生效。 // 代理已提交替换版本时该版本已被失效,条件更新不再命中,不会覆盖更新版本。 func (h *QualificationApprovalHandler) applyApproved( ctx context.Context, tx *gorm.DB, version *model.WithdrawalQualification, shop *model.Shop, before map[string]any, event approvalapp.TerminalDecisionEvent, ) error { now := time.Now().UTC() if qualificationShopDisabled(ctx, tx, version.ShopID) { // 店铺停用或不存在时资格必须失效:若停留在待审批,则「有待审批版本」门禁会让该店铺 // 永远无法获得有效资格(作废仅接受有效版本),因此就地收敛为已失效终态并写审计。 return h.invalidateForDisabledShop(ctx, tx, version, before, event, now) } result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}). Where("id = ? AND status = ?", version.ID, constants.WithdrawalQualificationStatusPending). Updates(map[string]any{ "status": constants.WithdrawalQualificationStatusApproved, "decided_at": now, "updater": 0, }) if result.Error != nil { return errors.Wrap(errors.CodeDatabaseError, result.Error, "使提现资料资格版本生效失败") } if result.RowsAffected != 1 { return errors.New(errors.CodeConflict, "提现资料资格版本状态已变化") } version.Status = constants.WithdrawalQualificationStatusApproved version.DecidedAt = &now return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{ EventID: "withdrawal-qualification:" + uintText(version.ID) + ":approved", ActionCode: constants.AuditActionWithdrawalQualificationApproved, Summary: "企业微信通过提现资料资格,版本已生效", CorrelationID: event.CorrelationID, Qualification: version, Shop: shop, BeforeData: before, AfterData: qualificationAuditSnapshot(version), }) } // invalidateForDisabledShop 在店铺停用或不存在时把待审批资料版本收敛为已失效。 // 与代理停用联动失效语义一致(invalidated_by=0 表示系统联动),使该店铺可重新提交资格。 func (h *QualificationApprovalHandler) invalidateForDisabledShop( ctx context.Context, tx *gorm.DB, version *model.WithdrawalQualification, before map[string]any, event approvalapp.TerminalDecisionEvent, now time.Time, ) error { reason := "代理店铺已停用,资格自动失效" result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}). Where("id = ? AND status = ?", version.ID, constants.WithdrawalQualificationStatusPending). Updates(map[string]any{ "status": constants.WithdrawalQualificationStatusInvalidated, "invalid_reason": reason, "invalidated_at": now, "invalidated_by": 0, "decided_at": now, "updater": 0, }) if result.Error != nil { return errors.Wrap(errors.CodeDatabaseError, result.Error, "失效停用店铺的提现资料资格失败") } if result.RowsAffected != 1 { return errors.New(errors.CodeConflict, "提现资料资格版本状态已变化") } version.Status = constants.WithdrawalQualificationStatusInvalidated version.InvalidReason = reason version.InvalidatedAt = &now version.InvalidatedBy = 0 version.DecidedAt = &now return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{ EventID: "withdrawal-qualification:" + uintText(version.ID) + ":disabled", ActionCode: constants.AuditActionWithdrawalQualificationInvalidated, Summary: "企业微信通过时店铺已停用,提现资料资格直接失效", CorrelationID: event.CorrelationID, Qualification: version, BeforeData: before, AfterData: qualificationAuditSnapshot(version), }) } // qualificationShopDisabled 判断资料版本所属店铺是否已停用或不存在。 func qualificationShopDisabled(ctx context.Context, tx *gorm.DB, shopID uint) bool { var enabled int64 if err := tx.WithContext(ctx).Model(&model.Shop{}). Where("id = ? AND status = ?", shopID, constants.ShopStatusEnabled). Count(&enabled).Error; err != nil { return true } return enabled == 0 } // applyRejected 标记资料版本已驳回,不影响其他版本已记录的审批结果。 func (h *QualificationApprovalHandler) applyRejected( ctx context.Context, tx *gorm.DB, version *model.WithdrawalQualification, shop *model.Shop, before map[string]any, event approvalapp.TerminalDecisionEvent, ) error { now := time.Now().UTC() result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}). Where("id = ? AND status = ?", version.ID, constants.WithdrawalQualificationStatusPending). Updates(map[string]any{ "status": constants.WithdrawalQualificationStatusRejected, "decided_at": now, "updater": 0, }) if result.Error != nil { return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记提现资料资格版本已驳回失败") } if result.RowsAffected != 1 { return errors.New(errors.CodeConflict, "提现资料资格版本状态已变化") } version.Status = constants.WithdrawalQualificationStatusRejected version.DecidedAt = &now return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{ EventID: "withdrawal-qualification:" + uintText(version.ID) + ":rejected", ActionCode: constants.AuditActionWithdrawalQualificationRejected, Summary: "企业微信未通过提现资料资格", CorrelationID: event.CorrelationID, Qualification: version, Shop: shop, BeforeData: before, AfterData: qualificationAuditSnapshot(version), }) }