收口审计治理与套餐任务进展
Constraint: 在线热修前必须保存当前迭代分支全部有效代码进展 Confidence: medium Scope-risk: broad Directive: 后续修改需保持审计事件与业务事务边界一致 Tested: git diff --cached --check Not-tested: 未运行全量测试,提交用于切换分支前保存既有工作
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
"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/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
@@ -35,13 +36,18 @@ type ApprovalRecoveryRecord struct {
|
||||
|
||||
// ApprovalContextRepository 管理企微提交的领取、终态和结果未知状态。
|
||||
type ApprovalContextRepository struct {
|
||||
db *gorm.DB
|
||||
now func() time.Time
|
||||
db *gorm.DB
|
||||
audit approvalapp.AuditWriter
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewApprovalContextRepository 创建企微审批渠道上下文 Repository。
|
||||
func NewApprovalContextRepository(db *gorm.DB) *ApprovalContextRepository {
|
||||
return &ApprovalContextRepository{db: db, now: time.Now}
|
||||
func NewApprovalContextRepository(db *gorm.DB, audits ...approvalapp.AuditWriter) *ApprovalContextRepository {
|
||||
var audit approvalapp.AuditWriter
|
||||
if len(audits) > 0 {
|
||||
audit = audits[0]
|
||||
}
|
||||
return &ApprovalContextRepository{db: db, audit: audit, now: time.Now}
|
||||
}
|
||||
|
||||
// ClaimSubmission 将待提交上下文原子置为请求处理中,阻止并发或重投重复提单。
|
||||
@@ -68,7 +74,7 @@ func (r *ApprovalContextRepository) ClaimSubmission(ctx context.Context, instanc
|
||||
|
||||
// PromoteStaleSendingToUnknown 将超出租约的提交中记录保守转为结果未知,禁止直接重新提交。
|
||||
func (r *ApprovalContextRepository) PromoteStaleSendingToUnknown(ctx context.Context, cutoff time.Time) error {
|
||||
if r == nil || r.db == nil || cutoff.IsZero() {
|
||||
if r == nil || r.db == nil || r.audit == nil || cutoff.IsZero() {
|
||||
return errors.New(errors.CodeInvalidParam, "企业微信审批恢复参数无效")
|
||||
}
|
||||
now := r.now().UTC()
|
||||
@@ -97,13 +103,38 @@ func (r *ApprovalContextRepository) PromoteStaleSendingToUnknown(ctx context.Con
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记企业微信审批提交结果未知失败")
|
||||
}
|
||||
if err := tx.Model(&model.ApprovalInstance{}).
|
||||
instanceResult := 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, "同步通用审批提交结果未知状态失败")
|
||||
})
|
||||
if instanceResult.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, instanceResult.Error, "同步通用审批提交结果未知状态失败")
|
||||
}
|
||||
if instanceResult.RowsAffected != result.RowsAffected {
|
||||
return errors.New(errors.CodeConflict, "通用审批提交结果未知状态已变化")
|
||||
}
|
||||
for _, channelContext := range stale {
|
||||
instance, err := loadApprovalAuditInstance(ctx, tx, channelContext.ApprovalInstanceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
beforeStatus := constants.ApprovalStatusSubmitting
|
||||
afterStatus := constants.ApprovalStatusSubmissionUnknown
|
||||
if err := r.audit.WriteApproval(ctx, tx, approvalapp.AuditChange{
|
||||
EventID: approvalSubmissionAuditEventID(instance.ID, afterStatus),
|
||||
ActionCode: constants.AuditActionApprovalSubmissionSynced, Summary: "审批提交处理中断,结果转为未知",
|
||||
InstanceID: instance.ID, BusinessType: instance.BusinessType, BusinessID: instance.BusinessID,
|
||||
SubmitterAccountID: instance.SubmitterAccountID, SubmitterSnapshot: instance.SubmitterSnapshot,
|
||||
Provider: instance.Provider, BeforeExternalRef: instance.ExternalRef, AfterExternalRef: instance.ExternalRef,
|
||||
CorrelationID: instance.CorrelationID, BeforeStatus: &beforeStatus, AfterStatus: &afterStatus,
|
||||
ActorKind: constants.AuditActorScheduledJob, ActorID: constants.ApprovalAuditActorRecoveryJob,
|
||||
Source: constants.AuditSourceScheduler, Result: constants.AuditResultUnknown,
|
||||
ErrorSummary: "企业微信审批提交处理中断,已进入结果未知恢复",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
@@ -160,7 +191,7 @@ func (r *ApprovalContextRepository) ListPendingSync(ctx context.Context, cutoff
|
||||
}
|
||||
|
||||
// FindSuccessfulSubmissionSPNo 从已成功的安全 Integration Log 摘要恢复本地未保存的审批单号。
|
||||
func (r *ApprovalContextRepository) FindSuccessfulSubmissionSPNo(ctx context.Context, instanceID uint) (string, error) {
|
||||
func (r *ApprovalContextRepository) FindSuccessfulSubmissionSPNo(ctx context.Context, instanceID uint) (string, string, error) {
|
||||
resourceID := strconv.FormatUint(uint64(instanceID), 10)
|
||||
var log model.IntegrationLog
|
||||
err := r.db.WithContext(ctx).
|
||||
@@ -169,18 +200,18 @@ func (r *ApprovalContextRepository) FindSuccessfulSubmissionSPNo(ctx context.Con
|
||||
constants.IntegrationDirectionOutbound, resourceID, constants.IntegrationResultSuccess).
|
||||
Order("id DESC").First(&log).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return "", nil
|
||||
return "", "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信审批提交日志失败")
|
||||
return "", "", errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信审批提交日志失败")
|
||||
}
|
||||
var summary struct {
|
||||
SPNo string `json:"sp_no"`
|
||||
}
|
||||
if sonic.Unmarshal(log.ResponseSummary, &summary) != nil {
|
||||
return "", nil
|
||||
return "", "", nil
|
||||
}
|
||||
return strings.TrimSpace(summary.SPNo), nil
|
||||
return strings.TrimSpace(summary.SPNo), log.IntegrationID, nil
|
||||
}
|
||||
|
||||
// ExistingSPNos 批量过滤已经关联到本地审批实例的企微审批单号。
|
||||
@@ -231,13 +262,24 @@ func (r *ApprovalContextRepository) FindUniqueUnknownByFingerprint(
|
||||
}
|
||||
|
||||
// RecoverSubmitted 将唯一确认的企微审批单号原子关联回结果未知实例。
|
||||
func (r *ApprovalContextRepository) RecoverSubmitted(ctx context.Context, instanceID uint, spNo string) (bool, error) {
|
||||
func (r *ApprovalContextRepository) RecoverSubmitted(
|
||||
ctx context.Context,
|
||||
instanceID uint,
|
||||
spNo string,
|
||||
integrationIDs []string,
|
||||
actorKind string,
|
||||
actorID string,
|
||||
source string,
|
||||
) (bool, error) {
|
||||
spNo = strings.TrimSpace(spNo)
|
||||
if instanceID == 0 || spNo == "" {
|
||||
return false, errors.New(errors.CodeInvalidParam, "企业微信审批恢复关联参数无效")
|
||||
}
|
||||
now := r.now().UTC()
|
||||
recovered := false
|
||||
if r.audit == nil {
|
||||
return false, errors.New(errors.CodeServiceUnavailable, "通用审批审计 Writer 未配置")
|
||||
}
|
||||
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).
|
||||
@@ -263,6 +305,24 @@ func (r *ApprovalContextRepository) RecoverSubmitted(ctx context.Context, instan
|
||||
if instanceResult.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "通用审批恢复状态已变化")
|
||||
}
|
||||
instance, err := loadApprovalAuditInstance(ctx, tx, instanceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
beforeStatus := constants.ApprovalStatusSubmissionUnknown
|
||||
afterStatus := constants.ApprovalStatusPending
|
||||
if err := r.audit.WriteApproval(ctx, tx, approvalapp.AuditChange{
|
||||
EventID: "approval:" + strconv.FormatUint(uint64(instanceID), 10) + ":audit:submission_recovered",
|
||||
ActionCode: constants.AuditActionApprovalSubmissionRecovered, Summary: "恢复结果未知的审批提交",
|
||||
InstanceID: instance.ID, BusinessType: instance.BusinessType, BusinessID: instance.BusinessID,
|
||||
SubmitterAccountID: instance.SubmitterAccountID, SubmitterSnapshot: instance.SubmitterSnapshot,
|
||||
Provider: instance.Provider, BeforeExternalRef: "", AfterExternalRef: spNo,
|
||||
CorrelationID: instance.CorrelationID, BeforeStatus: &beforeStatus, AfterStatus: &afterStatus,
|
||||
ActorKind: actorKind, ActorID: actorID, Source: source, Result: constants.AuditResultSuccess,
|
||||
IntegrationIDs: integrationIDs,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
recovered = true
|
||||
return nil
|
||||
})
|
||||
@@ -335,7 +395,10 @@ func (r *ApprovalContextRepository) ReleaseForRetry(ctx context.Context, instanc
|
||||
}
|
||||
|
||||
// MarkSubmitted 原子保存企微 sp_no,并把通用审批实例置为审批中。
|
||||
func (r *ApprovalContextRepository) MarkSubmitted(ctx context.Context, instanceID uint, spNo string) error {
|
||||
func (r *ApprovalContextRepository) MarkSubmitted(ctx context.Context, instanceID uint, spNo string, integrationID string) error {
|
||||
if r.audit == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "通用审批审计 Writer 未配置")
|
||||
}
|
||||
now := r.now().UTC()
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
contextResult := tx.Model(&model.WeComApprovalContext{}).
|
||||
@@ -359,37 +422,90 @@ func (r *ApprovalContextRepository) MarkSubmitted(ctx context.Context, instanceI
|
||||
if instanceResult.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "通用审批提交状态已变化")
|
||||
}
|
||||
return nil
|
||||
instance, err := loadApprovalAuditInstance(ctx, tx, instanceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
beforeStatus := constants.ApprovalStatusSubmitting
|
||||
afterStatus := constants.ApprovalStatusPending
|
||||
return r.audit.WriteApproval(ctx, tx, approvalapp.AuditChange{
|
||||
EventID: approvalSubmissionAuditEventID(instanceID, afterStatus),
|
||||
ActionCode: constants.AuditActionApprovalSubmissionSynced, Summary: "企业微信审批提交成功",
|
||||
InstanceID: instance.ID, BusinessType: instance.BusinessType, BusinessID: instance.BusinessID,
|
||||
SubmitterAccountID: instance.SubmitterAccountID, SubmitterSnapshot: instance.SubmitterSnapshot,
|
||||
Provider: instance.Provider, BeforeExternalRef: "", AfterExternalRef: spNo,
|
||||
CorrelationID: instance.CorrelationID, BeforeStatus: &beforeStatus, AfterStatus: &afterStatus,
|
||||
ActorKind: constants.AuditActorSystemTask, ActorID: constants.ApprovalAuditActorSubmissionWorker,
|
||||
Source: constants.AuditSourceWorker, Result: constants.AuditResultSuccess, IntegrationIDs: []string{integrationID},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// MarkFailed 将企微明确拒绝的提交记录为提交失败。
|
||||
func (r *ApprovalContextRepository) MarkFailed(ctx context.Context, instanceID uint, message string) error {
|
||||
return r.markSubmissionState(ctx, instanceID, constants.WeComSubmissionStatusFailed, constants.ApprovalStatusSubmissionFailed, message)
|
||||
func (r *ApprovalContextRepository) MarkFailed(ctx context.Context, instanceID uint, message string, integrationID string) error {
|
||||
return r.markSubmissionState(ctx, instanceID, constants.WeComSubmissionStatusFailed, constants.ApprovalStatusSubmissionFailed, message, constants.AuditResultFailed, integrationID)
|
||||
}
|
||||
|
||||
// MarkUnknown 将请求已发出但无法确认结果的提交记录为结果未知。
|
||||
func (r *ApprovalContextRepository) MarkUnknown(ctx context.Context, instanceID uint, message string) error {
|
||||
return r.markSubmissionState(ctx, instanceID, constants.WeComSubmissionStatusUnknown, constants.ApprovalStatusSubmissionUnknown, message)
|
||||
func (r *ApprovalContextRepository) MarkUnknown(ctx context.Context, instanceID uint, message string, integrationID string) error {
|
||||
return r.markSubmissionState(ctx, instanceID, constants.WeComSubmissionStatusUnknown, constants.ApprovalStatusSubmissionUnknown, message, constants.AuditResultUnknown, integrationID)
|
||||
}
|
||||
|
||||
// markSubmissionState 在同一事务中同步企微渠道状态与通用审批状态,避免两侧事实分裂。
|
||||
func (r *ApprovalContextRepository) markSubmissionState(ctx context.Context, instanceID uint, channelStatus, approvalStatus int, message string) error {
|
||||
func (r *ApprovalContextRepository) markSubmissionState(ctx context.Context, instanceID uint, channelStatus, approvalStatus int, message, auditResult, integrationID string) error {
|
||||
if r.audit == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "通用审批审计 Writer 未配置")
|
||||
}
|
||||
now := r.now().UTC()
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&model.WeComApprovalContext{}).
|
||||
contextResult := 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, "更新企业微信审批提交结果失败")
|
||||
Updates(map[string]any{"submission_status": channelStatus, "last_error": message, "updated_at": now})
|
||||
if contextResult.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, contextResult.Error, "更新企业微信审批提交结果失败")
|
||||
}
|
||||
if err := tx.Model(&model.ApprovalInstance{}).
|
||||
if contextResult.RowsAffected == 0 {
|
||||
return nil
|
||||
}
|
||||
instanceResult := 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, "更新通用审批提交结果失败")
|
||||
})
|
||||
if instanceResult.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, instanceResult.Error, "更新通用审批提交结果失败")
|
||||
}
|
||||
return nil
|
||||
if instanceResult.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "通用审批提交结果状态已变化")
|
||||
}
|
||||
instance, err := loadApprovalAuditInstance(ctx, tx, instanceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
beforeStatus := constants.ApprovalStatusSubmitting
|
||||
return r.audit.WriteApproval(ctx, tx, approvalapp.AuditChange{
|
||||
EventID: approvalSubmissionAuditEventID(instanceID, approvalStatus),
|
||||
ActionCode: constants.AuditActionApprovalSubmissionSynced, Summary: "同步企业微信审批提交结果",
|
||||
InstanceID: instance.ID, BusinessType: instance.BusinessType, BusinessID: instance.BusinessID,
|
||||
SubmitterAccountID: instance.SubmitterAccountID, SubmitterSnapshot: instance.SubmitterSnapshot,
|
||||
Provider: instance.Provider, BeforeExternalRef: "", AfterExternalRef: instance.ExternalRef,
|
||||
CorrelationID: instance.CorrelationID, BeforeStatus: &beforeStatus, AfterStatus: &approvalStatus,
|
||||
ActorKind: constants.AuditActorSystemTask, ActorID: constants.ApprovalAuditActorSubmissionWorker,
|
||||
Source: constants.AuditSourceWorker, Result: auditResult, ErrorSummary: message,
|
||||
IntegrationIDs: []string{integrationID},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func loadApprovalAuditInstance(ctx context.Context, tx *gorm.DB, instanceID uint) (*model.ApprovalInstance, error) {
|
||||
var instance model.ApprovalInstance
|
||||
if err := tx.WithContext(ctx).First(&instance, instanceID).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通用审批审计快照失败")
|
||||
}
|
||||
return &instance, nil
|
||||
}
|
||||
|
||||
func approvalSubmissionAuditEventID(instanceID uint, status int) string {
|
||||
return "approval:" + strconv.FormatUint(uint64(instanceID), 10) + ":audit:submission:" + strconv.Itoa(status)
|
||||
}
|
||||
|
||||
@@ -19,9 +19,10 @@ import (
|
||||
|
||||
// ApprovalDetail 是企微审批详情的权威状态和安全原始 JSON 快照。
|
||||
type ApprovalDetail struct {
|
||||
SPNo string
|
||||
SPStatus int
|
||||
Snapshot []byte
|
||||
SPNo string
|
||||
SPStatus int
|
||||
Snapshot []byte
|
||||
IntegrationID string
|
||||
}
|
||||
|
||||
// ApprovalDetailClient 获取企业微信审批申请详情。
|
||||
@@ -93,7 +94,7 @@ func (c *ApprovalDetailClient) Get(ctx context.Context, applicationID uint, spNo
|
||||
ProviderCode: strconv.FormatInt(errCode, 10), ProviderMessage: errMsg,
|
||||
ResponseSummary: map[string]any{"sp_no": spNo, "sp_status": status}, DurationMS: c.now().Sub(startedAt).Milliseconds(),
|
||||
})
|
||||
return ApprovalDetail{SPNo: spNo, SPStatus: status, Snapshot: snapshot}, err
|
||||
return ApprovalDetail{SPNo: spNo, SPStatus: status, Snapshot: snapshot, IntegrationID: attempt.IntegrationID}, err
|
||||
}
|
||||
|
||||
// newRequest 组装按字符串审批单号查询权威详情的企微请求。
|
||||
|
||||
@@ -71,6 +71,7 @@ func (h *ApprovalDetailTaskHandler) Handle(ctx context.Context, task *asynq.Task
|
||||
for _, decision := range decisions {
|
||||
if _, err := h.decisions.Execute(ctx, approvalapp.SyncDecisionCommand{
|
||||
InstanceID: record.Instance.ID, Decision: decision, DecisionSnapshot: detail.Snapshot, Source: payload.Source,
|
||||
IntegrationIDs: []string{payload.IntegrationID, detail.IntegrationID},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -100,7 +101,10 @@ func (h *ApprovalDetailTaskHandler) recoverUnknownCallback(ctx context.Context,
|
||||
if err != nil || candidate == nil {
|
||||
return nil, err
|
||||
}
|
||||
recovered, err := h.contexts.RecoverSubmitted(ctx, candidate.InstanceID, payload.SPNo)
|
||||
recovered, err := h.contexts.RecoverSubmitted(
|
||||
ctx, candidate.InstanceID, payload.SPNo,
|
||||
[]string{payload.IntegrationID, detail.IntegrationID}, constants.AuditActorExternalSystem, constants.ApprovalAuditActorWeCom, constants.AuditSourceCallback,
|
||||
)
|
||||
if err != nil || !recovered {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -30,8 +30,9 @@ type ApprovalInfoQuery struct {
|
||||
|
||||
// ApprovalInfoPage 是企业微信批量审批单号接口的一页结果。
|
||||
type ApprovalInfoPage struct {
|
||||
SPNos []string
|
||||
NextCursor string
|
||||
SPNos []string
|
||||
NextCursor string
|
||||
IntegrationID string
|
||||
}
|
||||
|
||||
// ApprovalInfoClient 按提交时间窗批量获取企业微信审批单号。
|
||||
@@ -116,7 +117,7 @@ func (c *ApprovalInfoClient) List(ctx context.Context, input ApprovalInfoQuery)
|
||||
},
|
||||
DurationMS: c.now().Sub(startedAt).Milliseconds(),
|
||||
})
|
||||
return ApprovalInfoPage{SPNos: spNos, NextCursor: strings.TrimSpace(result.NewNextCursor)}, err
|
||||
return ApprovalInfoPage{SPNos: spNos, NextCursor: strings.TrimSpace(result.NewNextCursor), IntegrationID: attempt.IntegrationID}, err
|
||||
}
|
||||
|
||||
func validateApprovalInfoQuery(input ApprovalInfoQuery) error {
|
||||
|
||||
@@ -67,20 +67,26 @@ func (h *ApprovalRecoveryTaskHandler) recoverUnknown(ctx context.Context, now ti
|
||||
if !claimed {
|
||||
continue
|
||||
}
|
||||
spNo, err := h.contexts.FindSuccessfulSubmissionSPNo(ctx, record.InstanceID)
|
||||
spNo, submissionIntegrationID, err := h.contexts.FindSuccessfulSubmissionSPNo(ctx, record.InstanceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
integrationIDs := []string{submissionIntegrationID}
|
||||
if spNo == "" {
|
||||
spNo, err = h.findUniqueSPNo(ctx, record, now)
|
||||
var recoveryIntegrationIDs []string
|
||||
spNo, recoveryIntegrationIDs, err = h.findUniqueSPNo(ctx, record, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
integrationIDs = append(integrationIDs, recoveryIntegrationIDs...)
|
||||
}
|
||||
if spNo == "" {
|
||||
continue
|
||||
}
|
||||
recovered, err := h.contexts.RecoverSubmitted(ctx, record.InstanceID, spNo)
|
||||
recovered, err := h.contexts.RecoverSubmitted(
|
||||
ctx, record.InstanceID, spNo, integrationIDs,
|
||||
constants.AuditActorScheduledJob, constants.ApprovalAuditActorRecoveryJob, constants.AuditSourceScheduler,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -94,19 +100,20 @@ func (h *ApprovalRecoveryTaskHandler) recoverUnknown(ctx context.Context, now ti
|
||||
}
|
||||
|
||||
// findUniqueSPNo 使用提交时间附近的固定窄窗口分页查询,并只接受唯一未关联候选。
|
||||
func (h *ApprovalRecoveryTaskHandler) findUniqueSPNo(ctx context.Context, record ApprovalRecoveryRecord, now time.Time) (string, error) {
|
||||
func (h *ApprovalRecoveryTaskHandler) findUniqueSPNo(ctx context.Context, record ApprovalRecoveryRecord, now time.Time) (string, []string, error) {
|
||||
startTime := record.SubmissionAttemptedAt.Add(-constants.WeComApprovalRecoveryWindow)
|
||||
endTime := record.SubmissionAttemptedAt.Add(constants.WeComApprovalRecoveryWindow)
|
||||
if endTime.After(now) {
|
||||
endTime = now
|
||||
}
|
||||
if !startTime.Before(endTime) {
|
||||
return "", nil
|
||||
return "", nil, nil
|
||||
}
|
||||
cursor := ""
|
||||
seenCursors := make(map[string]struct{})
|
||||
seenCandidates := make(map[string]struct{})
|
||||
unbound := make([]string, 0, 2)
|
||||
integrationIDs := make([]string, 0, 2)
|
||||
for {
|
||||
page, err := h.infos.List(ctx, ApprovalInfoQuery{
|
||||
ApplicationID: record.ApplicationID, StartTime: startTime, EndTime: endTime,
|
||||
@@ -114,11 +121,12 @@ func (h *ApprovalRecoveryTaskHandler) findUniqueSPNo(ctx context.Context, record
|
||||
Cursor: cursor, Size: constants.WeComApprovalInfoMaxPageSize,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", nil, err
|
||||
}
|
||||
integrationIDs = append(integrationIDs, page.IntegrationID)
|
||||
existing, err := h.contexts.ExistingSPNos(ctx, record.ApplicationID, page.SPNos)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", nil, err
|
||||
}
|
||||
for _, candidate := range page.SPNos {
|
||||
if _, seen := seenCandidates[candidate]; seen {
|
||||
@@ -128,7 +136,7 @@ func (h *ApprovalRecoveryTaskHandler) findUniqueSPNo(ctx context.Context, record
|
||||
if _, exists := existing[candidate]; !exists {
|
||||
unbound = append(unbound, candidate)
|
||||
if len(unbound) > 1 {
|
||||
return "", nil
|
||||
return "", integrationIDs, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,15 +145,15 @@ func (h *ApprovalRecoveryTaskHandler) findUniqueSPNo(ctx context.Context, record
|
||||
break
|
||||
}
|
||||
if _, exists := seenCursors[next]; exists {
|
||||
return "", errors.New(errors.CodeServiceUnavailable, "企业微信批量审批单号分页游标重复")
|
||||
return "", nil, errors.New(errors.CodeServiceUnavailable, "企业微信批量审批单号分页游标重复")
|
||||
}
|
||||
seenCursors[next] = struct{}{}
|
||||
cursor = next
|
||||
}
|
||||
if len(unbound) != 1 {
|
||||
return "", nil
|
||||
return "", integrationIDs, nil
|
||||
}
|
||||
return unbound[0], nil
|
||||
return unbound[0], integrationIDs, nil
|
||||
}
|
||||
|
||||
func (h *ApprovalRecoveryTaskHandler) enqueueDetailSync(ctx context.Context, applicationID uint, spNo string) error {
|
||||
|
||||
@@ -35,10 +35,11 @@ type ApprovalSubmitRequest struct {
|
||||
|
||||
// ApprovalSubmitResult 描述企微是否明确创建审批单。
|
||||
type ApprovalSubmitResult struct {
|
||||
Outcome string
|
||||
SPNo string
|
||||
Message string
|
||||
SafeToRetry bool
|
||||
Outcome string
|
||||
SPNo string
|
||||
Message string
|
||||
SafeToRetry bool
|
||||
IntegrationID string
|
||||
}
|
||||
|
||||
// ApprovalSubmissionClient 调用企微 applyevent 并记录每次真实外呼。
|
||||
@@ -98,9 +99,9 @@ func (c *ApprovalSubmissionClient) Submit(ctx context.Context, input ApprovalSub
|
||||
RecoveryStrategy: "按申请时间窗批量获取审批单号并核对详情,确认不存在后才允许受控重提",
|
||||
})
|
||||
if completeErr != nil {
|
||||
return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message}, completeErr
|
||||
return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message, IntegrationID: attempt.IntegrationID}, completeErr
|
||||
}
|
||||
return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message}, nil
|
||||
return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message, IntegrationID: attempt.IntegrationID}, nil
|
||||
}
|
||||
defer response.Body.Close()
|
||||
responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, constants.WeComMaxResponseBodyBytes+1))
|
||||
@@ -113,9 +114,9 @@ func (c *ApprovalSubmissionClient) Submit(ctx context.Context, input ApprovalSub
|
||||
RecoveryStrategy: "按申请时间窗批量获取审批单号并核对详情,确认不存在后才允许受控重提",
|
||||
})
|
||||
if completeErr != nil {
|
||||
return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message}, completeErr
|
||||
return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message, IntegrationID: attempt.IntegrationID}, completeErr
|
||||
}
|
||||
return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message}, nil
|
||||
return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message, IntegrationID: attempt.IntegrationID}, nil
|
||||
}
|
||||
var result struct {
|
||||
ErrCode int64 `json:"errcode"`
|
||||
@@ -141,9 +142,9 @@ func (c *ApprovalSubmissionClient) Submit(ctx context.Context, input ApprovalSub
|
||||
DurationMS: c.now().Sub(startedAt).Milliseconds(),
|
||||
})
|
||||
if completeErr != nil {
|
||||
return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: message}, completeErr
|
||||
return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: message, IntegrationID: attempt.IntegrationID}, completeErr
|
||||
}
|
||||
return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: message}, nil
|
||||
return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: message, IntegrationID: attempt.IntegrationID}, nil
|
||||
}
|
||||
_, err = c.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
|
||||
Result: constants.IntegrationResultSuccess, HTTPStatus: response.StatusCode,
|
||||
@@ -151,7 +152,7 @@ func (c *ApprovalSubmissionClient) Submit(ctx context.Context, input ApprovalSub
|
||||
ResponseSummary: map[string]any{"success": true, "sp_no": strings.TrimSpace(result.SPNo)},
|
||||
DurationMS: c.now().Sub(startedAt).Milliseconds(), StateChanged: true,
|
||||
})
|
||||
return ApprovalSubmitResult{Outcome: submissionOutcomeSuccess, SPNo: strings.TrimSpace(result.SPNo)}, err
|
||||
return ApprovalSubmitResult{Outcome: submissionOutcomeSuccess, SPNo: strings.TrimSpace(result.SPNo), IntegrationID: attempt.IntegrationID}, err
|
||||
}
|
||||
|
||||
// newSubmitRequest 只组装本次企微审批请求,调用前不会产生外部副作用。
|
||||
|
||||
@@ -59,7 +59,7 @@ func (c *ApprovalSubmissionConsumer) Consume(ctx context.Context, envelope outbo
|
||||
}
|
||||
return err
|
||||
}
|
||||
if markErr := c.repository.MarkFailed(ctx, event.InstanceID, err.Error()); markErr != nil {
|
||||
if markErr := c.repository.MarkFailed(ctx, event.InstanceID, err.Error(), ""); markErr != nil {
|
||||
return markErr
|
||||
}
|
||||
return nil
|
||||
@@ -71,11 +71,11 @@ func (c *ApprovalSubmissionConsumer) Consume(ctx context.Context, envelope outbo
|
||||
})
|
||||
switch result.Outcome {
|
||||
case submissionOutcomeSuccess:
|
||||
if err := c.repository.MarkSubmitted(ctx, event.InstanceID, result.SPNo); err != nil {
|
||||
if err := c.repository.MarkSubmitted(ctx, event.InstanceID, result.SPNo, result.IntegrationID); err != nil {
|
||||
return err
|
||||
}
|
||||
case submissionOutcomeUnknown:
|
||||
if err := c.repository.MarkUnknown(ctx, event.InstanceID, result.Message); err != nil {
|
||||
if err := c.repository.MarkUnknown(ctx, event.InstanceID, result.Message, result.IntegrationID); err != nil {
|
||||
return err
|
||||
}
|
||||
case submissionOutcomeFailed:
|
||||
@@ -88,7 +88,7 @@ func (c *ApprovalSubmissionConsumer) Consume(ctx context.Context, envelope outbo
|
||||
}
|
||||
return errors.New(errors.CodeServiceUnavailable, result.Message)
|
||||
}
|
||||
if err := c.repository.MarkFailed(ctx, event.InstanceID, result.Message); err != nil {
|
||||
if err := c.repository.MarkFailed(ctx, event.InstanceID, result.Message, result.IntegrationID); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -24,28 +24,25 @@ func NewMemberRepository(db *gorm.DB) *MemberRepository {
|
||||
}
|
||||
|
||||
// ReplaceVisible 原子替换指定应用当前可见成员,历史不可见成员仅标记为不可见。
|
||||
func (r *MemberRepository) ReplaceVisible(ctx context.Context, applicationID uint, members []model.WeComMember, syncedAt time.Time) error {
|
||||
if r == nil || r.db == nil {
|
||||
func (r *MemberRepository) ReplaceVisible(ctx context.Context, tx *gorm.DB, applicationID uint, members []model.WeComMember, syncedAt time.Time) error {
|
||||
if r == nil || tx == nil {
|
||||
return errors.New(errors.CodeDatabaseError, "企业微信成员存储未配置")
|
||||
}
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&model.WeComMember{}).Where("application_id = ?", applicationID).
|
||||
Updates(map[string]any{"visible": false, "updated_at": syncedAt}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(members) == 0 {
|
||||
return nil
|
||||
}
|
||||
return tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "application_id"}, {Name: "userid"}},
|
||||
DoUpdates: clause.Assignments(map[string]any{
|
||||
"corp_id": gorm.Expr("EXCLUDED.corp_id"), "name": gorm.Expr("EXCLUDED.name"),
|
||||
"department_ids": gorm.Expr("EXCLUDED.department_ids"), "visible": true,
|
||||
"synced_at": gorm.Expr("EXCLUDED.synced_at"), "updated_at": syncedAt,
|
||||
}),
|
||||
}).CreateInBatches(&members, constants.WeComMemberSyncBatchSize).Error
|
||||
})
|
||||
if err != nil {
|
||||
if err := tx.WithContext(ctx).Model(&model.WeComMember{}).Where("application_id = ?", applicationID).
|
||||
Updates(map[string]any{"visible": false, "updated_at": syncedAt}).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "同步企业微信可见成员失败")
|
||||
}
|
||||
if len(members) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := tx.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "application_id"}, {Name: "userid"}},
|
||||
DoUpdates: clause.Assignments(map[string]any{
|
||||
"corp_id": gorm.Expr("EXCLUDED.corp_id"), "name": gorm.Expr("EXCLUDED.name"),
|
||||
"department_ids": gorm.Expr("EXCLUDED.department_ids"), "visible": true,
|
||||
"synced_at": gorm.Expr("EXCLUDED.synced_at"), "updated_at": syncedAt,
|
||||
}),
|
||||
}).CreateInBatches(&members, constants.WeComMemberSyncBatchSize).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "同步企业微信可见成员失败")
|
||||
}
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user