Files
junhong_cmp_fiber/internal/application/distributionwithdrawal/qualification_approval.go
邹进 6298a9061f fix: 第1批 M8、M13、M14 审批提交失败终态与退款关闭类终态定位
- 审批核心在实例进入提交失败的同一事务内写 submission_failed 决策投递与终态事件,消费入口单独放行
- 核销(4)、退款(7)、扫码注册(3)、资料资格(4)、线下充值(7)、佣金提现(5)新增“审批提交失败”终态并释放预占/冻结
- 三处恢复入口不再重放提交失败;退款关闭类终态按审批尝试定位退款单(M13)
- 核销列表与详情批量返回审批提交状态;筛选、字段说明、状态名称、退款导出加入新状态
- 迁移 000239 放宽五张业务表状态约束与决策投递取值约束(测试库已执行 up/down/up)
- OpenSpec Change fix-approval-submission-failure-terminal:18/26,测试部署后端到端验证与存量补数据待完成
2026-10-12 00:03:46 +09:00

220 lines
9.6 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 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)
case constants.ApprovalDecisionSubmissionFailed:
reason, failed, err := approvalapp.ConfirmSubmissionFailed(ctx, tx, event.InstanceID)
if err != nil || !failed {
return err
}
return h.closeVersion(ctx, tx, &version, shop, before, event,
constants.WithdrawalQualificationStatusSubmissionFailed, constants.AuditActionWithdrawalQualificationRejected,
"submission_failed", "提现资料资格审批提交失败:"+reason)
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 {
return h.closeVersion(ctx, tx, version, shop, before, event,
constants.WithdrawalQualificationStatusRejected, constants.AuditActionWithdrawalQualificationRejected,
"rejected", "企业微信未通过提现资料资格")
}
// closeVersion 以待审批状态条件更新把资料版本置为未生效终态(已驳回或审批提交失败),不影响其他版本。
func (h *QualificationApprovalHandler) closeVersion(
ctx context.Context,
tx *gorm.DB,
version *model.WithdrawalQualification,
shop *model.Shop,
before map[string]any,
event approvalapp.TerminalDecisionEvent,
targetStatus int,
actionCode, eventSuffix, summary string,
) 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": targetStatus,
"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 = targetStatus
version.DecidedAt = &now
return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{
EventID: "withdrawal-qualification:" + uintText(version.ID) + ":" + eventSuffix,
ActionCode: actionCode,
Summary: summary,
CorrelationID: event.CorrelationID, Qualification: version, Shop: shop,
BeforeData: before, AfterData: qualificationAuditSnapshot(version),
})
}