Files
junhong_cmp_fiber/internal/infrastructure/wecom/approval_context_repository.go
break 73f5125d3d
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m26s
七月迭代短暂完结,还有很多后端的关键东西没有弄,这是一版赶时间做的东西
2026-07-25 17:06:58 +08:00

396 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 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
})
}