package wecom import ( "context" "strconv" "strings" "time" "github.com/bytedance/sonic" "gorm.io/datatypes" "gorm.io/gorm" "gorm.io/gorm/clause" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) // ApprovalSubmissionRecord 聚合 Worker 提交企微审批所需的本地事实。 type ApprovalSubmissionRecord struct { Instance model.ApprovalInstance Context model.WeComApprovalContext } // ApprovalRecoveryRecord 是主动恢复扫描需要的本地最小事实。 type ApprovalRecoveryRecord struct { InstanceID uint ApplicationID uint TemplateID string CreatorUserID string SPNo string SubmissionStatus int SubmissionAttemptedAt time.Time } // ApprovalContextRepository 管理企微提交的领取、终态和结果未知状态。 type ApprovalContextRepository struct { db *gorm.DB now func() time.Time } // NewApprovalContextRepository 创建企微审批渠道上下文 Repository。 func NewApprovalContextRepository(db *gorm.DB) *ApprovalContextRepository { return &ApprovalContextRepository{db: db, now: time.Now} } // ClaimSubmission 将待提交上下文原子置为请求处理中,阻止并发或重投重复提单。 func (r *ApprovalContextRepository) ClaimSubmission(ctx context.Context, instanceID uint) (*ApprovalSubmissionRecord, bool, error) { if r == nil || r.db == nil || instanceID == 0 { return nil, false, errors.New(errors.CodeInvalidParam, "企业微信审批提交参数无效") } now := r.now().UTC() result := r.db.WithContext(ctx).Model(&model.WeComApprovalContext{}). Where("approval_instance_id = ? AND submission_status = ?", instanceID, constants.WeComSubmissionStatusReady). Updates(map[string]any{ "submission_status": constants.WeComSubmissionStatusSending, "submission_attempted_at": now, "last_error": "", "updated_at": now, }) if result.Error != nil { return nil, false, errors.Wrap(errors.CodeDatabaseError, result.Error, "领取企业微信审批提交任务失败") } record, err := r.Get(ctx, instanceID) if err != nil { return nil, false, err } return record, result.RowsAffected == 1, nil } // PromoteStaleSendingToUnknown 将超出租约的提交中记录保守转为结果未知,禁止直接重新提交。 func (r *ApprovalContextRepository) PromoteStaleSendingToUnknown(ctx context.Context, cutoff time.Time) error { if r == nil || r.db == nil || cutoff.IsZero() { return errors.New(errors.CodeInvalidParam, "企业微信审批恢复参数无效") } now := r.now().UTC() return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var stale []model.WeComApprovalContext if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("submission_status = ? AND updated_at <= ?", constants.WeComSubmissionStatusSending, cutoff.UTC()). Order("id ASC").Limit(constants.WeComApprovalRecoveryBatchSize).Find(&stale).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询超时的企业微信审批提交失败") } if len(stale) == 0 { return nil } instanceIDs := make([]uint, 0, len(stale)) for _, channelContext := range stale { instanceIDs = append(instanceIDs, channelContext.ApprovalInstanceID) } result := tx.Model(&model.WeComApprovalContext{}). Where("approval_instance_id IN ? AND submission_status = ?", instanceIDs, constants.WeComSubmissionStatusSending). Updates(map[string]any{ "submission_status": constants.WeComSubmissionStatusUnknown, "submission_attempted_at": gorm.Expr("COALESCE(submission_attempted_at, updated_at)"), "last_error": "企业微信审批提交处理中断,已进入结果未知恢复", "updated_at": now, }) if result.Error != nil { return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记企业微信审批提交结果未知失败") } if err := tx.Model(&model.ApprovalInstance{}). Where("id IN ? AND status = ?", instanceIDs, constants.ApprovalStatusSubmitting). Updates(map[string]any{ "status": constants.ApprovalStatusSubmissionUnknown, "status_changed_at": now, "version": gorm.Expr("version + 1"), "updated_at": now, }).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "同步通用审批提交结果未知状态失败") } return nil }) } // ListUnknownRecovery 查询到期且尚未关联审批单号的结果未知记录。 func (r *ApprovalContextRepository) ListUnknownRecovery(ctx context.Context, cutoff time.Time, limit int) ([]ApprovalRecoveryRecord, error) { if limit <= 0 || limit > constants.WeComApprovalRecoveryBatchSize { return nil, errors.New(errors.CodeInvalidParam, "企业微信审批恢复批量大小无效") } var records []ApprovalRecoveryRecord err := r.db.WithContext(ctx).Table("tb_wecom_approval_context AS wc"). Select(`wc.approval_instance_id AS instance_id, wc.application_id, wc.template_id, wc.creator_userid, wc.sp_no, wc.submission_status, COALESCE(wc.submission_attempted_at, wc.updated_at) AS submission_attempted_at`). Where("wc.submission_status = ? AND wc.sp_no = '' AND (wc.last_recovery_at IS NULL OR wc.last_recovery_at <= ?)", constants.WeComSubmissionStatusUnknown, cutoff.UTC()). Order("wc.id ASC").Limit(limit).Scan(&records).Error if err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信结果未知审批失败") } return records, nil } // ClaimUnknownRecovery 领取一次结果未知恢复尝试,阻止多个 Worker 同时查询同一提交。 func (r *ApprovalContextRepository) ClaimUnknownRecovery(ctx context.Context, instanceID uint, cutoff time.Time) (bool, error) { now := r.now().UTC() result := r.db.WithContext(ctx).Model(&model.WeComApprovalContext{}). Where("approval_instance_id = ? AND submission_status = ? AND sp_no = '' AND (last_recovery_at IS NULL OR last_recovery_at <= ?)", instanceID, constants.WeComSubmissionStatusUnknown, cutoff.UTC()). UpdateColumn("last_recovery_at", now) if result.Error != nil { return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "领取企业微信结果未知恢复任务失败") } return result.RowsAffected == 1, nil } // ListPendingSync 查询已提交且仍未终态、需要再次拉取权威详情的审批。 func (r *ApprovalContextRepository) ListPendingSync(ctx context.Context, cutoff time.Time, limit int) ([]ApprovalRecoveryRecord, error) { if limit <= 0 || limit > constants.WeComApprovalRecoveryBatchSize { return nil, errors.New(errors.CodeInvalidParam, "企业微信审批轮询批量大小无效") } var records []ApprovalRecoveryRecord err := r.db.WithContext(ctx).Table("tb_wecom_approval_context AS wc"). Select(`wc.approval_instance_id AS instance_id, wc.application_id, wc.template_id, wc.creator_userid, wc.sp_no, wc.submission_status, COALESCE(wc.submission_attempted_at, wc.updated_at) AS submission_attempted_at`). Joins("JOIN tb_approval_instance AS ai ON ai.id = wc.approval_instance_id"). Where("wc.submission_status = ? AND wc.sp_no <> '' AND ai.status = ? AND (wc.last_synced_at IS NULL OR wc.last_synced_at <= ?)", constants.WeComSubmissionStatusSubmitted, constants.ApprovalStatusPending, cutoff.UTC()). Order("COALESCE(wc.last_synced_at, wc.created_at) ASC, wc.id ASC").Limit(limit).Scan(&records).Error if err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询待轮询企业微信审批失败") } return records, nil } // FindSuccessfulSubmissionSPNo 从已成功的安全 Integration Log 摘要恢复本地未保存的审批单号。 func (r *ApprovalContextRepository) FindSuccessfulSubmissionSPNo(ctx context.Context, instanceID uint) (string, error) { resourceID := strconv.FormatUint(uint64(instanceID), 10) var log model.IntegrationLog err := r.db.WithContext(ctx). Where("provider = ? AND operation = ? AND direction = ? AND resource_id = ? AND result = ?", constants.IntegrationProviderWeCom, constants.IntegrationOperationWeComApprovalSubmit, constants.IntegrationDirectionOutbound, resourceID, constants.IntegrationResultSuccess). Order("id DESC").First(&log).Error if err == gorm.ErrRecordNotFound { return "", nil } if err != nil { return "", errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信审批提交日志失败") } var summary struct { SPNo string `json:"sp_no"` } if sonic.Unmarshal(log.ResponseSummary, &summary) != nil { return "", nil } return strings.TrimSpace(summary.SPNo), nil } // ExistingSPNos 批量过滤已经关联到本地审批实例的企微审批单号。 func (r *ApprovalContextRepository) ExistingSPNos(ctx context.Context, applicationID uint, spNos []string) (map[string]struct{}, error) { result := make(map[string]struct{}) if len(spNos) == 0 { return result, nil } var values []string if err := r.db.WithContext(ctx).Model(&model.WeComApprovalContext{}). Where("application_id = ? AND sp_no IN ?", applicationID, spNos).Pluck("sp_no", &values).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询已关联企业微信审批单号失败") } for _, value := range values { result[value] = struct{}{} } return result, nil } // FindUniqueUnknownByFingerprint 按应用、模板、发起人和提交时间唯一定位结果未知实例。 func (r *ApprovalContextRepository) FindUniqueUnknownByFingerprint( ctx context.Context, applicationID uint, templateID string, creatorUserID string, submittedAt time.Time, ) (*ApprovalRecoveryRecord, error) { if applicationID == 0 || strings.TrimSpace(templateID) == "" || strings.TrimSpace(creatorUserID) == "" || submittedAt.IsZero() { return nil, nil } var records []ApprovalRecoveryRecord err := r.db.WithContext(ctx).Table("tb_wecom_approval_context AS wc"). Select(`wc.approval_instance_id AS instance_id, wc.application_id, wc.template_id, wc.creator_userid, wc.sp_no, wc.submission_status, COALESCE(wc.submission_attempted_at, wc.updated_at) AS submission_attempted_at`). Where(`wc.application_id = ? AND wc.template_id = ? AND wc.creator_userid = ? AND wc.submission_status = ? AND wc.sp_no = '' AND COALESCE(wc.submission_attempted_at, wc.updated_at) BETWEEN ? AND ?`, applicationID, strings.TrimSpace(templateID), strings.TrimSpace(creatorUserID), constants.WeComSubmissionStatusUnknown, submittedAt.Add(-constants.WeComApprovalRecoveryWindow).UTC(), submittedAt.Add(constants.WeComApprovalRecoveryWindow).UTC()). Order("wc.id ASC").Limit(2).Scan(&records).Error if err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "匹配企业微信结果未知审批失败") } if len(records) != 1 { return nil, nil } return &records[0], nil } // RecoverSubmitted 将唯一确认的企微审批单号原子关联回结果未知实例。 func (r *ApprovalContextRepository) RecoverSubmitted(ctx context.Context, instanceID uint, spNo string) (bool, error) { spNo = strings.TrimSpace(spNo) if instanceID == 0 || spNo == "" { return false, errors.New(errors.CodeInvalidParam, "企业微信审批恢复关联参数无效") } now := r.now().UTC() recovered := false err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { contextResult := tx.Model(&model.WeComApprovalContext{}). Where("approval_instance_id = ? AND submission_status = ? AND sp_no = ''", instanceID, constants.WeComSubmissionStatusUnknown). Updates(map[string]any{ "submission_status": constants.WeComSubmissionStatusSubmitted, "sp_no": spNo, "last_error": "", "updated_at": now, }) if contextResult.Error != nil { return errors.Wrap(errors.CodeDatabaseError, contextResult.Error, "恢复企业微信审批单号失败") } if contextResult.RowsAffected == 0 { return nil } instanceResult := tx.Model(&model.ApprovalInstance{}). Where("id = ? AND status = ?", instanceID, constants.ApprovalStatusSubmissionUnknown). Updates(map[string]any{ "external_ref": spNo, "status": constants.ApprovalStatusPending, "status_changed_at": now, "version": gorm.Expr("version + 1"), "updated_at": now, }) if instanceResult.Error != nil { return errors.Wrap(errors.CodeDatabaseError, instanceResult.Error, "同步恢复后的通用审批状态失败") } if instanceResult.RowsAffected != 1 { return errors.New(errors.CodeConflict, "通用审批恢复状态已变化") } recovered = true return nil }) return recovered, err } // Get 读取通用审批实例及对应企微渠道上下文。 func (r *ApprovalContextRepository) Get(ctx context.Context, instanceID uint) (*ApprovalSubmissionRecord, error) { var instance model.ApprovalInstance if err := r.db.WithContext(ctx).Where("id = ?", instanceID).First(&instance).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通用审批实例失败") } var channelContext model.WeComApprovalContext if err := r.db.WithContext(ctx).Where("approval_instance_id = ?", instanceID).First(&channelContext).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, errors.New(errors.CodeInvalidStatus, "企业微信审批渠道上下文不存在") } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信审批渠道上下文失败") } return &ApprovalSubmissionRecord{Instance: instance, Context: channelContext}, nil } // GetBySPNo 按企微审批单号读取本地通用实例和渠道上下文。 func (r *ApprovalContextRepository) GetBySPNo(ctx context.Context, applicationID uint, spNo string) (*ApprovalSubmissionRecord, error) { var channelContext model.WeComApprovalContext if err := r.db.WithContext(ctx).Where("application_id = ? AND sp_no = ?", applicationID, spNo).First(&channelContext).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信审批渠道上下文失败") } return r.Get(ctx, channelContext.ApprovalInstanceID) } // FindBySPNo 按企微审批单号查找本地记录;无关联时返回空,供回调区分无关审批。 func (r *ApprovalContextRepository) FindBySPNo(ctx context.Context, applicationID uint, spNo string) (*ApprovalSubmissionRecord, error) { var channelContext model.WeComApprovalContext if err := r.db.WithContext(ctx).Where("application_id = ? AND sp_no = ?", applicationID, spNo).First(&channelContext).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, nil } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信审批渠道上下文失败") } return r.Get(ctx, channelContext.ApprovalInstanceID) } // SaveLatestDetail 保存最近一次权威企微审批详情,供终态同步和读取投影复用。 func (r *ApprovalContextRepository) SaveLatestDetail(ctx context.Context, instanceID uint, spStatus int, snapshot []byte) error { now := r.now().UTC() if err := r.db.WithContext(ctx).Model(&model.WeComApprovalContext{}).Where("approval_instance_id = ?", instanceID).Updates(map[string]any{ "latest_sp_status": spStatus, "latest_detail_snapshot": datatypes.JSON(snapshot), "last_synced_at": now, "updated_at": now, }).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "保存企业微信审批详情快照失败") } return nil } // ReleaseForRetry 将尚未调用 applyevent 的安全失败恢复为待提交。 func (r *ApprovalContextRepository) ReleaseForRetry(ctx context.Context, instanceID uint, message string) error { result := r.db.WithContext(ctx).Model(&model.WeComApprovalContext{}). Where("approval_instance_id = ? AND submission_status = ?", instanceID, constants.WeComSubmissionStatusSending). Updates(map[string]any{"submission_status": constants.WeComSubmissionStatusReady, "last_error": message, "updated_at": r.now().UTC()}) if result.Error != nil { return errors.Wrap(errors.CodeDatabaseError, result.Error, "恢复企业微信审批待提交状态失败") } return nil } // MarkSubmitted 原子保存企微 sp_no,并把通用审批实例置为审批中。 func (r *ApprovalContextRepository) MarkSubmitted(ctx context.Context, instanceID uint, spNo string) error { now := r.now().UTC() return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { contextResult := tx.Model(&model.WeComApprovalContext{}). Where("approval_instance_id = ? AND submission_status = ?", instanceID, constants.WeComSubmissionStatusSending). Updates(map[string]any{"submission_status": constants.WeComSubmissionStatusSubmitted, "sp_no": spNo, "last_error": "", "updated_at": now}) if contextResult.Error != nil { return errors.Wrap(errors.CodeDatabaseError, contextResult.Error, "保存企业微信审批单号失败") } if contextResult.RowsAffected != 1 { return errors.New(errors.CodeConflict, "企业微信审批提交状态已变化") } instanceResult := tx.Model(&model.ApprovalInstance{}). Where("id = ? AND status = ?", instanceID, constants.ApprovalStatusSubmitting). Updates(map[string]any{ "external_ref": spNo, "status": constants.ApprovalStatusPending, "status_changed_at": now, "version": gorm.Expr("version + 1"), "updated_at": now, }) if instanceResult.Error != nil { return errors.Wrap(errors.CodeDatabaseError, instanceResult.Error, "更新通用审批提交状态失败") } if instanceResult.RowsAffected != 1 { return errors.New(errors.CodeConflict, "通用审批提交状态已变化") } return nil }) } // MarkFailed 将企微明确拒绝的提交记录为提交失败。 func (r *ApprovalContextRepository) MarkFailed(ctx context.Context, instanceID uint, message string) error { return r.markSubmissionState(ctx, instanceID, constants.WeComSubmissionStatusFailed, constants.ApprovalStatusSubmissionFailed, message) } // MarkUnknown 将请求已发出但无法确认结果的提交记录为结果未知。 func (r *ApprovalContextRepository) MarkUnknown(ctx context.Context, instanceID uint, message string) error { return r.markSubmissionState(ctx, instanceID, constants.WeComSubmissionStatusUnknown, constants.ApprovalStatusSubmissionUnknown, message) } // markSubmissionState 在同一事务中同步企微渠道状态与通用审批状态,避免两侧事实分裂。 func (r *ApprovalContextRepository) markSubmissionState(ctx context.Context, instanceID uint, channelStatus, approvalStatus int, message string) error { now := r.now().UTC() return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Model(&model.WeComApprovalContext{}). Where("approval_instance_id = ? AND submission_status = ?", instanceID, constants.WeComSubmissionStatusSending). Updates(map[string]any{"submission_status": channelStatus, "last_error": message, "updated_at": now}).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "更新企业微信审批提交结果失败") } if err := tx.Model(&model.ApprovalInstance{}). Where("id = ? AND status = ?", instanceID, constants.ApprovalStatusSubmitting). Updates(map[string]any{ "status": approvalStatus, "status_changed_at": now, "version": gorm.Expr("version + 1"), "updated_at": now, }).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "更新通用审批提交结果失败") } return nil }) }