package distributionwithdrawal import ( "context" "strings" "time" "github.com/google/uuid" "gorm.io/gorm" "gorm.io/gorm/clause" approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval" distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/middleware" ) // QualificationResult 返回已落库的资料版本与审批实例引用。 type QualificationResult struct { QualificationID uint Status int ApprovalInstanceID uint ApprovalStatus int } // QualificationService 受理提现资料资格的提交、替换、作废与停用失效。 // 资格事实按版本不可变保存;替换合同或法人身份证即新增版本并在同一事务内失效旧有效版本。 type QualificationService struct { db *gorm.DB approval approvalapp.Port audit AuditWriter } // NewQualificationService 创建提现资料资格用例。 func NewQualificationService(db *gorm.DB, approval approvalapp.Port, audit AuditWriter) *QualificationService { return &QualificationService{db: db, approval: approval, audit: audit} } // Submit 提交或替换本人代理店铺的提现资料资格。 // 已有待审批版本时拒绝;已有效版本在合同或法人身份证未变化时拒绝重复提交。 func (s *QualificationService) Submit( ctx context.Context, shopID uint, input distributiondomain.QualificationInput, ) (*QualificationResult, error) { if s == nil || s.db == nil || s.approval == nil || s.audit == nil { return nil, errors.New(errors.CodeServiceUnavailable, "提现资料资格能力尚未配置") } if err := ensureOwnAgentShop(ctx, shopID); err != nil { // 越权提交资格属关键拒绝,必须留痕:以目标店铺为主要资源记录拒绝事实。 RecordFailure(ctx, s.db, s.audit, AuditChange{ // 不手工构造 EventID:本条是失败/拒绝事实,同一店铺可被拒绝多次, // 手工 ID 会与既有的拒绝记录在 event_id 唯一约束上冲突并被静默吞掉。 // 由审计 Writer 生成唯一 evt_(与既有 recordRefundFailure 的做法一致)。 ActionCode: constants.AuditActionWithdrawalQualificationSubmitRejected, Summary: "提交提现资料资格被拒绝:越权或非本人店铺", Shop: failureShopResolved(ctx, s.db, shopID, nil), }, err) return nil, err } normalized, err := distributiondomain.ValidateQualificationInput(input) if err != nil { return nil, err } operatorID := middleware.GetUserIDFromContext(ctx) submitter, err := resolveShopPrimaryAccount(ctx, s.db, shopID) if err != nil { return nil, err } correlationID := "withdrawal_qualification:" + uuid.NewString() preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{ BusinessType: constants.ApprovalBusinessTypeWithdrawalQualification, SubmitterAccountID: submitter.ID, CorrelationID: correlationID, }) if err != nil { return nil, err } shop, err := loadShop(ctx, s.db, shopID) if err != nil { return nil, err } version := &model.WithdrawalQualification{ ShopID: shopID, SubjectType: normalized.SubjectType, SubjectCode: normalized.SubjectCode, LegalPersonIDCard: normalized.LegalPersonIDCard, ContractFileKey: normalized.ContractFileKey, IDCardFrontFileKey: normalized.IDCardFrontFileKey, IDCardBackFileKey: normalized.IDCardBackFileKey, BusinessLicenseFileKey: normalized.BusinessLicenseFileKey, ShopFrontFileKey: normalized.ShopFrontFileKey, InvoiceFileKey: normalized.InvoiceFileKey, InvoiceTitle: normalized.InvoiceTitle, InvoiceSubjectCode: normalized.InvoiceSubjectCode, Status: constants.WithdrawalQualificationStatusPending, Creator: operatorID, Updater: operatorID, } submitterSnapshot, requestSnapshot, err := approvalSnapshots(submitter.ID, submitter.Username, qualificationApprovalForm(version, shop)) if err != nil { return nil, err } result := &QualificationResult{} err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { replaced, err := invalidateReplacedVersion(ctx, tx, shopID, normalized, operatorID) if err != nil { return err } if err := tx.WithContext(ctx).Create(version).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "创建提现资料资格版本失败") } reference, err := createApprovalInTx(ctx, tx, s.approval, preparation, constants.ApprovalBusinessTypeWithdrawalQualification, version.ID, submitter.ID, submitterSnapshot, requestSnapshot, correlationID) if err != nil { return err } if err := attachQualificationInstance(ctx, tx, version, reference.InstanceID); err != nil { return err } if err := s.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{ EventID: "withdrawal-qualification:" + uintText(version.ID) + ":submit", ActionCode: constants.AuditActionWithdrawalQualificationSubmitted, Summary: qualificationSubmitSummary(replaced), CorrelationID: correlationID, Qualification: version, Shop: shop, AfterData: qualificationAuditSnapshot(version), }); err != nil { return err } result.QualificationID = version.ID result.Status = version.Status result.ApprovalInstanceID = reference.InstanceID result.ApprovalStatus = reference.Status return nil }) if err != nil { // 提交在创建资料版本前被拒绝(存在待审批版本或参数非法),此时没有资料版本可作主要资源, // 以店铺为主要资源记录拒绝事实。 RecordFailure(ctx, s.db, s.audit, AuditChange{ // 不手工构造 EventID:本条是失败/拒绝事实,同一店铺可被拒绝多次, // 手工 ID 会与既有的拒绝记录在 event_id 唯一约束上冲突并被静默吞掉。 // 由审计 Writer 生成唯一 evt_(与既有 recordRefundFailure 的做法一致)。 ActionCode: constants.AuditActionWithdrawalQualificationSubmitRejected, Summary: "提交提现资料资格被拒绝", CorrelationID: correlationID, Shop: shop, }, err) return nil, err } return result, nil } // Void 由超级管理员填写原因后作废有效提现资料资格。 // 原因必填;已失效或非有效版本返回稳定冲突错误。 func (s *QualificationService) Void(ctx context.Context, id uint, reason string) error { if s == nil || s.db == nil || s.audit == nil { return errors.New(errors.CodeServiceUnavailable, "提现资料资格能力尚未配置") } if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin { return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") } reason = strings.TrimSpace(reason) if reason == "" { businessErr := errors.New(errors.CodeInvalidParam, "作废提现资料资格必须填写原因") // 关键拒绝必须留痕:作废原因必填是权限相关拒绝,按超管作废动作记录拒绝事实。 RecordFailure(ctx, s.db, s.audit, AuditChange{ // 不手工构造 EventID:该拒绝与「作废成功」是同一实体的两次不同发生, // 手工 ID 会让随后的成功作废审计被 event_id 唯一约束吞掉,造成审计与事实相反。 ActionCode: constants.AuditActionWithdrawalQualificationVoided, Summary: "作废提现资料资格被拒绝:未填写原因", Qualification: &model.WithdrawalQualification{ID: id}, }, businessErr) return businessErr } operatorID := middleware.GetUserIDFromContext(ctx) var version model.WithdrawalQualification err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). First(&version, id).Error; err != nil { if err == gorm.ErrRecordNotFound { return errors.New(errors.CodeNotFound, "提现资料资格不存在") } return errors.Wrap(errors.CodeDatabaseError, err, "锁定提现资料资格版本失败") } if version.Status != constants.WithdrawalQualificationStatusApproved { return errors.New(errors.CodeConflict, "仅有效提现资料资格可作废") } before := qualificationAuditSnapshot(&version) if err := invalidateVersion(ctx, tx, &version, reason, operatorID); err != nil { return err } shop, err := loadShop(ctx, tx, version.ShopID) if err != nil { return err } return s.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{ EventID: "withdrawal-qualification:" + uintText(version.ID) + ":void", ActionCode: constants.AuditActionWithdrawalQualificationVoided, Summary: "超级管理员作废提现资料资格", Qualification: &version, Shop: shop, BeforeData: before, AfterData: qualificationAuditSnapshot(&version), }) }) if err != nil { // 失败审计必须可追溯且恰好有一个主要资源:带上目标资料版本(至少含 ID)。 RecordFailure(ctx, s.db, s.audit, AuditChange{ ActionCode: constants.AuditActionWithdrawalQualificationVoided, Summary: "作废提现资料资格失败", Qualification: &model.WithdrawalQualification{ID: id}, }, err) return err } return nil } // InvalidateByShopDisable 在店铺停用事务内使该店铺全部有效资格失效。 // 历史版本与审批结果保留;由调用方保证与店铺停用处于同一事务。 func (s *QualificationService) InvalidateByShopDisable( ctx context.Context, tx *gorm.DB, shopID uint, reason string, ) error { if tx == nil || shopID == 0 { return nil } var versions []model.WithdrawalQualification if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). Where("shop_id = ? AND status = ?", shopID, constants.WithdrawalQualificationStatusApproved). Order("id ASC").Find(&versions).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询待失效提现资料资格失败") } if len(versions) == 0 { return nil } now := time.Now().UTC() result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}). Where("shop_id = ? AND status = ?", shopID, constants.WithdrawalQualificationStatusApproved). Updates(map[string]any{ "status": constants.WithdrawalQualificationStatusInvalidated, "invalid_reason": reason, "invalidated_at": now, "invalidated_by": 0, "updater": 0, }) if result.Error != nil { return errors.Wrap(errors.CodeDatabaseError, result.Error, "失效提现资料资格失败") } if result.RowsAffected == 0 { return nil } shop, err := loadShop(ctx, tx, shopID) if err != nil { return err } first := versions[0] first.Status = constants.WithdrawalQualificationStatusInvalidated first.InvalidReason = reason first.InvalidatedAt = &now summary := "代理店铺停用,全部有效提现资料资格失效" if strings.Contains(reason, "删除") { summary = "代理店铺已删除,全部有效提现资料资格失效" } // 不手工构造 EventID:同一店铺可先停用失效、后删除失效,属同一实体的两次不同发生, // 手工 ID 会让第二次失效审计被吞掉。 return s.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{ ActionCode: constants.AuditActionWithdrawalQualificationInvalidated, Summary: summary, Qualification: &first, Shop: shop, AfterData: map[string]any{ "shop_id": shopID, "invalidated_count": result.RowsAffected, "status": constants.WithdrawalQualificationStatusInvalidated, "invalid_reason": reason, }, }) } // invalidateReplacedVersion 在替换合同或法人身份证时失效旧有效版本。 // 返回被失效的版本;没有需失效的版本时返回 nil。 func invalidateReplacedVersion( ctx context.Context, tx *gorm.DB, shopID uint, input distributiondomain.QualificationInput, operatorID uint, ) (*model.WithdrawalQualification, error) { var pending int64 if err := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}). Where("shop_id = ? AND status = ?", shopID, constants.WithdrawalQualificationStatusPending). Count(&pending).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询待审批提现资料资格失败") } if pending > 0 { return nil, errors.New(errors.CodeConflict, "已存在待审批的提现资料资格,请等待审批结果") } var current model.WithdrawalQualification err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). Where("shop_id = ? AND status = ?", shopID, constants.WithdrawalQualificationStatusApproved). First(¤t).Error if err == gorm.ErrRecordNotFound { return nil, nil } if err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定有效提现资料资格失败") } if !qualificationRequiresApproval(¤t, input) { return nil, errors.New(errors.CodeConflict, "提现资料资格已生效,合同与法人身份证未变化") } if err := invalidateVersion(ctx, tx, ¤t, "代理替换合同或法人身份证资料", operatorID); err != nil { return nil, err } return ¤t, nil } // qualificationRequiresApproval 判断本次提交是否改变了合同或法人身份证事实。 func qualificationRequiresApproval( current *model.WithdrawalQualification, input distributiondomain.QualificationInput, ) bool { return current.ContractFileKey != input.ContractFileKey || current.IDCardFrontFileKey != input.IDCardFrontFileKey || current.IDCardBackFileKey != input.IDCardBackFileKey || current.SubjectCode != input.SubjectCode || current.LegalPersonIDCard != input.LegalPersonIDCard } // invalidateVersion 条件更新单个资料版本为已失效。 func invalidateVersion( ctx context.Context, tx *gorm.DB, version *model.WithdrawalQualification, reason string, operatorID uint, ) error { now := time.Now().UTC() result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}). Where("id = ? AND status = ?", version.ID, version.Status). Updates(map[string]any{ "status": constants.WithdrawalQualificationStatusInvalidated, "invalid_reason": reason, "invalidated_at": now, "invalidated_by": operatorID, "updater": operatorID, }) 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 = operatorID return nil } // attachQualificationInstance 回写资料版本关联的审批实例,写入一次后不可修改。 func attachQualificationInstance( ctx context.Context, tx *gorm.DB, version *model.WithdrawalQualification, instanceID uint, ) error { result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}). Where("id = ? AND approval_instance_id IS NULL", version.ID). Update("approval_instance_id", instanceID) if result.Error != nil { return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联提现资料资格审批实例失败") } if result.RowsAffected != 1 { return errors.New(errors.CodeConflict, "提现资料资格审批实例关联已变化") } version.ApprovalInstanceID = &instanceID return nil } // qualificationApprovalForm 生成企业微信审批表单业务快照。 // 证件号按脱敏值写入,附件只写入对象存储 Key 引用,不写入附件内容。 func qualificationApprovalForm(version *model.WithdrawalQualification, shop *model.Shop) map[string]any { shopName := "" if shop != nil { shopName = shop.ShopName } return map[string]any{ constants.ApprovalFieldQualificationShopID: version.ShopID, constants.ApprovalFieldQualificationShopName: shopName, constants.ApprovalFieldQualificationSubjectType: constants.GetWithdrawalQualificationSubjectTypeName(version.SubjectType), constants.ApprovalFieldQualificationSubjectCodeMasked: distributiondomain.MaskSubjectCode(version.SubjectCode), constants.ApprovalFieldQualificationLegalPersonMasked: distributiondomain.MaskSubjectCode(version.LegalPersonIDCard), constants.ApprovalFieldQualificationContractKey: version.ContractFileKey, constants.ApprovalFieldQualificationIDCardFrontKey: version.IDCardFrontFileKey, constants.ApprovalFieldQualificationIDCardBackKey: version.IDCardBackFileKey, constants.ApprovalFieldQualificationBusinessLicenseKey: version.BusinessLicenseFileKey, constants.ApprovalFieldQualificationShopFrontKey: version.ShopFrontFileKey, constants.ApprovalFieldQualificationInvoiceKey: version.InvoiceFileKey, constants.ApprovalFieldQualificationInvoiceTitle: version.InvoiceTitle, } } // qualificationAuditSnapshot 生成资料版本审计快照,证件号按脱敏值记录,不含附件内容。 func qualificationAuditSnapshot(version *model.WithdrawalQualification) map[string]any { instanceID := uint(0) if version.ApprovalInstanceID != nil { instanceID = *version.ApprovalInstanceID } return map[string]any{ "id": version.ID, "shop_id": version.ShopID, "subject_type": version.SubjectType, "subject_code_masked": distributiondomain.MaskSubjectCode(version.SubjectCode), "status": version.Status, "approval_instance_id": instanceID, "invalid_reason": version.InvalidReason, "attachment_count": 3 + boolToInt(version.BusinessLicenseFileKey != "") + boolToInt(version.ShopFrontFileKey != "") + boolToInt(version.InvoiceFileKey != ""), } } // qualificationSubmitSummary 区分首次提交与替换提交的审计摘要。 func qualificationSubmitSummary(replaced *model.WithdrawalQualification) string { if replaced != nil { return "替换合同或法人身份证资料,旧有效提现资料资格已失效" } return "提交提现资料资格" } // ensureOwnAgentShop 校验当前账号为代理身份且目标即本人店铺。 func ensureOwnAgentShop(ctx context.Context, shopID uint) error { if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeAgent { return errors.New(errors.CodeForbidden, "仅代理商用户可提交提现资料资格") } if shopID == 0 || shopID != middleware.GetShopIDFromContext(ctx) { return errors.New(errors.CodeForbidden, "仅可为本人店铺提交提现资料资格") } return nil } // resolveShopPrimaryAccount 解析店铺启用的主账号,作为审批发起主体。 func resolveShopPrimaryAccount(ctx context.Context, db *gorm.DB, shopID uint) (*model.Account, error) { var account model.Account if err := db.WithContext(ctx). Where("shop_id = ? AND status = ? AND is_primary = TRUE", shopID, constants.StatusEnabled). First(&account).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, errors.New(errors.CodeInvalidStatus, "店铺缺少启用的主账号,无法提交审批") } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺主账号失败") } return &account, nil } // loadShopOrNil 读取店铺事实;店铺已软删除或不存在时返回 nil,供终态收敛使用。 func loadShopOrNil(ctx context.Context, db *gorm.DB, shopID uint) *model.Shop { shop, err := loadShop(ctx, db, shopID) if err != nil { return nil } return shop } // loadShop 读取店铺事实,未找到返回稳定不存在错误。 func loadShop(ctx context.Context, db *gorm.DB, shopID uint) (*model.Shop, error) { var shop model.Shop if err := db.WithContext(ctx).First(&shop, shopID).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, errors.New(errors.CodeNotFound, "店铺不存在") } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺失败") } return &shop, nil } // boolToInt 将布尔值转换为 0/1,用于审计计数。 func boolToInt(value bool) int { if value { return 1 } return 0 }