完善退款审批材料与恢复流程
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m35s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m35s
This commit is contained in:
89
internal/infrastructure/approval/recovery.go
Normal file
89
internal/infrastructure/approval/recovery.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RecoveryPort 将恢复请求限制为本地任务入队与事件状态转换。
|
||||
type RecoveryPort struct{ outbox *outbox.Repository }
|
||||
|
||||
func NewRecoveryPort(_ *gorm.DB, _ ...any) *RecoveryPort {
|
||||
return &RecoveryPort{outbox: outbox.NewRepository()}
|
||||
}
|
||||
func (r *RecoveryPort) EnqueueSubmittedSync(ctx context.Context, tx *gorm.DB, instanceID uint) error {
|
||||
return r.enqueueSync(ctx, tx, instanceID)
|
||||
}
|
||||
func (r *RecoveryPort) EnqueueUnknownConfirm(ctx context.Context, tx *gorm.DB, instanceID uint) error {
|
||||
return r.enqueueUnknown(ctx, tx, instanceID)
|
||||
}
|
||||
func (r *RecoveryPort) enqueueSync(ctx context.Context, tx *gorm.DB, instanceID uint) error {
|
||||
var instance model.ApprovalInstance
|
||||
if err := tx.WithContext(ctx).Where("id = ? AND business_type = ?", instanceID, constants.ApprovalBusinessTypeRefund).First(&instance).Error; err != nil {
|
||||
return gorm.ErrInvalidData
|
||||
}
|
||||
var context model.WeComApprovalContext
|
||||
if err := tx.WithContext(ctx).Where("approval_instance_id = ? AND business_type = ?", instanceID, constants.ApprovalBusinessTypeRefund).First(&context).Error; err != nil || context.SPNo == "" || context.SubmissionStatus != constants.WeComSubmissionStatusSubmitted {
|
||||
return gorm.ErrInvalidData
|
||||
}
|
||||
id := strconv.FormatUint(uint64(instanceID), 10)
|
||||
payload := map[string]any{"instance_id": instanceID, "application_id": context.ApplicationID, "sp_no": context.SPNo}
|
||||
_, err := r.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{EventID: "approval:" + id + ":manual-sync", EventType: constants.OutboxEventTypeApprovalManualSyncRequested, PayloadVersion: 1, AggregateType: "approval", AggregateID: id, ResourceType: instance.BusinessType, ResourceID: strconv.FormatUint(uint64(instance.BusinessID), 10), Payload: payload})
|
||||
return err
|
||||
}
|
||||
func (r *RecoveryPort) enqueueUnknown(ctx context.Context, tx *gorm.DB, instanceID uint) error {
|
||||
var instance model.ApprovalInstance
|
||||
if err := tx.WithContext(ctx).Where("id = ? AND business_type = ?", instanceID, constants.ApprovalBusinessTypeRefund).First(&instance).Error; err != nil || instance.Status != constants.ApprovalStatusSubmissionUnknown {
|
||||
return gorm.ErrInvalidData
|
||||
}
|
||||
var context model.WeComApprovalContext
|
||||
if err := tx.WithContext(ctx).Where("approval_instance_id = ? AND business_type = ?", instanceID, constants.ApprovalBusinessTypeRefund).First(&context).Error; err != nil || context.SubmissionStatus != constants.WeComSubmissionStatusUnknown || context.SPNo != "" {
|
||||
return gorm.ErrInvalidData
|
||||
}
|
||||
id := strconv.FormatUint(uint64(instanceID), 10)
|
||||
_, err := r.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{EventID: "approval:" + id + ":unknown-recovery", EventType: constants.OutboxEventTypeApprovalUnknownRecoveryRequested, PayloadVersion: 1, AggregateType: "approval", AggregateID: id, ResourceType: instance.BusinessType, ResourceID: strconv.FormatUint(uint64(instance.BusinessID), 10), Payload: map[string]any{"instance_id": instanceID}})
|
||||
return err
|
||||
}
|
||||
func (r *RecoveryPort) RecoverSubmissionEvent(ctx context.Context, tx *gorm.DB, instanceID uint) (bool, error) {
|
||||
if tx == nil || instanceID == 0 {
|
||||
return false, gorm.ErrInvalidData
|
||||
}
|
||||
var instance model.ApprovalInstance
|
||||
if err := tx.WithContext(ctx).First(&instance, instanceID).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
if instance.Status != constants.ApprovalStatusSubmissionFailed {
|
||||
return false, gorm.ErrInvalidData
|
||||
}
|
||||
var context model.WeComApprovalContext
|
||||
if err := tx.WithContext(ctx).Where("approval_instance_id = ?", instanceID).First(&context).Error; err != nil || context.SubmissionStatus != constants.WeComSubmissionStatusFailed || context.SPNo != "" {
|
||||
return false, gorm.ErrInvalidData
|
||||
}
|
||||
eventID := "approval:" + strconv.FormatUint(uint64(instanceID), 10) + ":submission"
|
||||
var event model.OutboxEvent
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("event_id = ?", eventID).First(&event).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
id := strconv.FormatUint(uint64(instanceID), 10)
|
||||
rid := strconv.FormatUint(uint64(instance.BusinessID), 10)
|
||||
if event.EventType != constants.OutboxEventTypeApprovalSubmissionRequested || event.AggregateType != "approval" || event.AggregateID != id || event.ResourceType != instance.BusinessType || event.ResourceID != rid {
|
||||
return false, gorm.ErrInvalidData
|
||||
}
|
||||
if event.Status == constants.OutboxStatusPending || event.Status == constants.OutboxStatusDelivering {
|
||||
return false, nil
|
||||
}
|
||||
if event.Status != constants.OutboxStatusFailed && event.Status != constants.OutboxStatusDelivered {
|
||||
return false, nil
|
||||
}
|
||||
result := tx.WithContext(ctx).Model(&model.OutboxEvent{}).Where("id = ? AND status IN ?", event.ID, []int{constants.OutboxStatusFailed, constants.OutboxStatusDelivered}).Updates(map[string]any{"status": constants.OutboxStatusPending, "next_attempt_at": time.Now().UTC(), "last_error_code": "", "last_error_summary": "", "lease_owner": nil, "lease_expires_at": nil, "delivered_at": nil, "updated_at": time.Now().UTC()})
|
||||
return result.RowsAffected == 1, result.Error
|
||||
}
|
||||
|
||||
var _ approvalapp.RecoveryPort = (*RecoveryPort)(nil)
|
||||
@@ -36,3 +36,17 @@ func (w *SubmissionEventWriter) Append(ctx context.Context, tx *gorm.DB, event a
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
type UnknownRecoveryEventWriter struct{ outbox *outbox.Repository }
|
||||
|
||||
func NewUnknownRecoveryEventWriter(repository *outbox.Repository) *UnknownRecoveryEventWriter {
|
||||
return &UnknownRecoveryEventWriter{outbox: repository}
|
||||
}
|
||||
func (w *UnknownRecoveryEventWriter) Append(ctx context.Context, tx *gorm.DB, instanceID uint) error {
|
||||
if w == nil || w.outbox == nil || instanceID == 0 {
|
||||
return errors.New(errors.CodeInternalError, "审批未知恢复 Outbox Writer 未配置")
|
||||
}
|
||||
eventID := "approval:" + strconv.FormatUint(uint64(instanceID), 10) + ":unknown-recovery"
|
||||
_, err := w.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{EventID: eventID, EventType: constants.OutboxEventTypeApprovalUnknownRecoveryRequested, PayloadVersion: 1, AggregateType: "approval", AggregateID: strconv.FormatUint(uint64(instanceID), 10), ResourceType: "approval", ResourceID: strconv.FormatUint(uint64(instanceID), 10), BusinessKey: eventID, Payload: map[string]any{"instance_id": instanceID}})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -37,6 +37,24 @@ func (w *Writer) WriteApproval(ctx context.Context, tx *gorm.DB, change approval
|
||||
})
|
||||
}
|
||||
|
||||
func (w *Writer) WriteSnapshotRecovery(ctx context.Context, tx *gorm.DB, instance *model.ApprovalInstance, fields []string, actorID uint) error {
|
||||
if instance == nil || instance.ID == 0 || len(fields) == 0 || actorID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "退款审批快照恢复审计资源不完整")
|
||||
}
|
||||
id := strconv.FormatUint(uint64(instance.ID), 10)
|
||||
actor := strconv.FormatUint(uint64(actorID), 10)
|
||||
return w.Append(ctx, tx, AppendInput{EventID: "approval:" + id + ":audit:snapshot_recovered", ActionCode: constants.AuditActionApprovalSubmissionRecovered, Summary: "补齐退款审批快照字段", Actor: ActorInput{Kind: constants.AuditActorAccount, ID: actor}, Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform, Metadata: map[string]any{"fields": fields}, Resources: []ResourceInput{{Type: constants.AuditResourceApprovalInstance, ID: &id, Key: id, DisplayName: "审批实例 " + id, Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleApprovalTarget, IdentitySnapshot: map[string]any{"id": instance.ID, "fields": fields}}}})
|
||||
}
|
||||
|
||||
func (w *Writer) WriteRecoveryRequest(ctx context.Context, tx *gorm.DB, instance *model.ApprovalInstance, actorID uint, branch string) error {
|
||||
if instance == nil || instance.ID == 0 || actorID == 0 || strings.TrimSpace(branch) == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "退款审批恢复请求审计资源不完整")
|
||||
}
|
||||
id := strconv.FormatUint(uint64(instance.ID), 10)
|
||||
actor := strconv.FormatUint(uint64(actorID), 10)
|
||||
return w.Append(ctx, tx, AppendInput{EventID: "approval:" + id + ":audit:recovery_request:" + branch, ActionCode: constants.AuditActionApprovalSubmissionRecovered, Summary: "请求恢复退款审批原提交", Actor: ActorInput{Kind: constants.AuditActorAccount, ID: actor}, Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform, Metadata: map[string]any{"branch": branch}, Resources: []ResourceInput{{Type: constants.AuditResourceApprovalInstance, ID: &id, Key: id, DisplayName: "审批实例 " + id, Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleApprovalTarget, IdentitySnapshot: map[string]any{"id": instance.ID, "status": instance.Status}}}})
|
||||
}
|
||||
|
||||
func approvalResources(ctx context.Context, tx *gorm.DB, change approvalapp.AuditChange) ([]ResourceInput, error) {
|
||||
instanceID := strconv.FormatUint(uint64(change.InstanceID), 10)
|
||||
resources := []ResourceInput{{
|
||||
|
||||
@@ -375,6 +375,7 @@ func NewRegistry() *Registry {
|
||||
approvalSubmissionRecovered := approvalAction(constants.AuditActionApprovalSubmissionRecovered, "恢复审批提交结果", []ActionOrigin{
|
||||
{Actor: constants.AuditActorScheduledJob, Source: constants.AuditSourceScheduler},
|
||||
{Actor: constants.AuditActorExternalSystem, Source: constants.AuditSourceCallback},
|
||||
{Actor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI},
|
||||
})
|
||||
approvalDecisionSynced := approvalAction(constants.AuditActionApprovalDecisionSynced, "同步审批权威终态", []ActionOrigin{
|
||||
{Actor: constants.AuditActorExternalSystem, Source: constants.AuditSourceCallback},
|
||||
|
||||
@@ -371,6 +371,17 @@ func (r *ApprovalContextRepository) FindBySPNo(ctx context.Context, applicationI
|
||||
}
|
||||
return r.Get(ctx, channelContext.ApprovalInstanceID)
|
||||
}
|
||||
func (r *ApprovalContextRepository) GetRecoveryRecord(ctx context.Context, instanceID uint) (*ApprovalRecoveryRecord, error) {
|
||||
var record 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.approval_instance_id = ?", instanceID).Scan(&record).Error
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信审批恢复记录失败")
|
||||
}
|
||||
if record.InstanceID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
// SaveLatestDetail 保存最近一次权威企微审批详情,供终态同步和读取投影复用。
|
||||
func (r *ApprovalContextRepository) SaveLatestDetail(ctx context.Context, instanceID uint, spStatus int, snapshot []byte) error {
|
||||
|
||||
@@ -51,32 +51,51 @@ func buildApprovalForm(ctx context.Context, businessType string, applicationID,
|
||||
for _, control := range template.Controls {
|
||||
requiredControls[control.ID] = control.Required
|
||||
}
|
||||
// 本变更新增字段必须配置控件映射:场景里没有该字段的映射条目时,下面的遍历永远不会看到它,
|
||||
// 该字段会被静默丢弃。因此在组装前先针对本次提交使用的映射逐一确认必须字段有条目,
|
||||
// 缺失即明确失败并指出缺哪个字段/控件;本判定与场景映射校验共用同一份 RequiredSceneFields。
|
||||
// 既有未标注必须映射的可选字段不在其中,缺失映射时保持既有静默跳过行为。
|
||||
mappingFields := make(map[string]struct{}, len(mappings))
|
||||
mappingFields := make(map[string]dto.WeComControlMappingItem, len(mappings))
|
||||
for _, item := range mappings {
|
||||
mappingFields[strings.TrimSpace(item.BusinessField)] = struct{}{}
|
||||
mappingFields[strings.TrimSpace(item.BusinessField)] = item
|
||||
}
|
||||
for _, field := range wecomapp.RequiredSceneFields(businessType) {
|
||||
if _, exists := mappingFields[field.Code]; !exists {
|
||||
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus,
|
||||
"企业微信审批场景缺少必须配置的控件映射: "+field.Name+"(业务字段 "+field.Code+"),请先配置该控件映射后再提交")
|
||||
requiredFields := wecomapp.RequiredSceneFields(businessType)
|
||||
if businessType == constants.ApprovalBusinessTypeRefund {
|
||||
requiredFields = append(requiredFields, wecomapp.RequiredRefundFields(snapshot)...)
|
||||
}
|
||||
for _, field := range requiredFields {
|
||||
mapping, exists := mappingFields[field.Code]
|
||||
if !exists {
|
||||
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "企业微信审批场景缺少必须配置的控件映射: "+field.Name+"(业务字段 "+field.Code+")")
|
||||
}
|
||||
}
|
||||
// 本变更新增字段缺失快照值时同样明确失败:映射已配置却拿不到值,静默跳过会让审批材料
|
||||
// 与本地事实不一致。该校验必须在循环之前完成——循环内的既有文件类控件会经
|
||||
// attachments.Upload 触达外部系统,若把校验留在循环内,失败可能晚于外部调用(ENG-TX-001
|
||||
// 要求事务内不得持有不可回滚的长外部 I/O,同理失败必须发生在任何外呼之前)。
|
||||
// 既有未映射(或映射到既有可选字段)的控件保持既有静默跳过行为。
|
||||
requiredFields := make(map[string]string)
|
||||
for _, field := range wecomapp.RequiredSceneFields(businessType) {
|
||||
requiredFields[field.Code] = field.Name
|
||||
if _, exists := lookupSnapshotValue(snapshot, field.Code); !exists {
|
||||
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus,
|
||||
"审批业务快照缺少必须提交的字段: "+field.Name+"(业务字段 "+field.Code+"),请确认已配置对应控件与业务字段映射")
|
||||
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "审批业务快照缺少必须提交的字段: "+field.Name)
|
||||
}
|
||||
if businessType == constants.ApprovalBusinessTypeRefund && field.Code == constants.ApprovalFieldRefundMethodCode {
|
||||
methodCode, _ := snapshot[field.Code].(string)
|
||||
methodCode = strings.TrimSpace(methodCode)
|
||||
if methodCode == "" || (methodCode != constants.RefundMethodOriginalRoute && methodCode != constants.RefundMethodCustomerAccount && methodCode != constants.RefundMethodAssetWallet && methodCode != constants.RefundMethodAgentWallet) {
|
||||
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "审批业务快照缺少有效退款方式编码")
|
||||
}
|
||||
}
|
||||
if field.Code == constants.ApprovalFieldCustomerAccountInfo {
|
||||
value, _ := snapshot[field.Code].(string)
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "审批业务快照缺少非空客户收款信息")
|
||||
}
|
||||
if !strings.EqualFold(mapping.ControlType, "textarea") {
|
||||
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "客户收款信息必须映射到多行文本控件")
|
||||
}
|
||||
}
|
||||
if field.Code == constants.ApprovalFieldCustomerVoucherKey {
|
||||
refs, err := parseApprovalFileReferences(snapshot[field.Code])
|
||||
if err != nil || len(refs) == 0 {
|
||||
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "审批业务快照缺少客户收款凭证")
|
||||
}
|
||||
if !strings.EqualFold(mapping.ControlType, "file") {
|
||||
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "客户收款凭证必须映射到附件控件")
|
||||
}
|
||||
}
|
||||
}
|
||||
fieldNames := make(map[string]string, len(requiredFields))
|
||||
for _, field := range requiredFields {
|
||||
fieldNames[field.Code] = field.Name
|
||||
}
|
||||
contents := make([]map[string]any, 0, len(mappings))
|
||||
attachmentCount := 0
|
||||
@@ -86,7 +105,7 @@ func buildApprovalForm(ctx context.Context, businessType string, applicationID,
|
||||
if requiredControls[mapping.ControlID] {
|
||||
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "审批业务快照缺少模板必填字段: "+mapping.BusinessField)
|
||||
}
|
||||
if name, required := requiredFields[strings.TrimSpace(mapping.BusinessField)]; required {
|
||||
if name, required := fieldNames[strings.TrimSpace(mapping.BusinessField)]; required {
|
||||
return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "审批业务快照缺少必须提交的字段: "+name+",请确认已配置对应控件与业务字段映射")
|
||||
}
|
||||
continue
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/hibiken/asynq"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
@@ -25,12 +26,21 @@ func NewApprovalRecoveryTaskHandler(contexts *ApprovalContextRepository, infos *
|
||||
return &ApprovalRecoveryTaskHandler{contexts: contexts, infos: infos, queue: queueClient, now: time.Now}
|
||||
}
|
||||
|
||||
// Handle 扫描未终态和结果未知记录;结果未知只查询关联,绝不重新调用 applyevent。
|
||||
func (h *ApprovalRecoveryTaskHandler) Handle(ctx context.Context, _ *asynq.Task) error {
|
||||
// Handle 支持全局扫描任务与按实例人工任务,二者共享同一 unknown 确认算法。
|
||||
func (h *ApprovalRecoveryTaskHandler) Handle(ctx context.Context, task *asynq.Task) error {
|
||||
if h == nil || h.contexts == nil || h.infos == nil || h.queue == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "企业微信审批主动恢复任务未配置")
|
||||
}
|
||||
var payload struct {
|
||||
InstanceID uint `json:"instance_id"`
|
||||
}
|
||||
if task != nil && len(task.Payload()) > 0 {
|
||||
_ = sonic.Unmarshal(task.Payload(), &payload)
|
||||
}
|
||||
now := h.now().UTC()
|
||||
if payload.InstanceID > 0 {
|
||||
return h.recoverUnknownInstance(ctx, payload.InstanceID, now)
|
||||
}
|
||||
if err := h.contexts.PromoteStaleSendingToUnknown(ctx, now.Add(-constants.WeComApprovalSendingLease)); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -53,48 +63,58 @@ func (h *ApprovalRecoveryTaskHandler) enqueuePendingSync(ctx context.Context, no
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *ApprovalRecoveryTaskHandler) recoverUnknownInstance(ctx context.Context, instanceID uint, now time.Time) error {
|
||||
record, err := h.contexts.GetRecoveryRecord(ctx, instanceID)
|
||||
if err != nil || record == nil {
|
||||
return err
|
||||
}
|
||||
claimed, err := h.contexts.ClaimUnknownRecovery(ctx, instanceID, now.Add(-constants.WeComApprovalPollingInterval))
|
||||
if err != nil || !claimed {
|
||||
return err
|
||||
}
|
||||
return h.recoverUnknownRecord(ctx, *record, now)
|
||||
}
|
||||
|
||||
func (h *ApprovalRecoveryTaskHandler) recoverUnknownRecord(ctx context.Context, record ApprovalRecoveryRecord, now time.Time) error {
|
||||
spNo, submissionIntegrationID, err := h.contexts.FindSuccessfulSubmissionSPNo(ctx, record.InstanceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
integrationIDs := []string{submissionIntegrationID}
|
||||
if spNo == "" {
|
||||
var ids []string
|
||||
spNo, ids, err = h.findUniqueSPNo(ctx, record, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
integrationIDs = append(integrationIDs, ids...)
|
||||
}
|
||||
if spNo == "" {
|
||||
return nil
|
||||
}
|
||||
recovered, err := h.contexts.RecoverSubmitted(ctx, record.InstanceID, spNo, integrationIDs, constants.AuditActorScheduledJob, constants.ApprovalAuditActorRecoveryJob, constants.AuditSourceScheduler)
|
||||
if err != nil || !recovered {
|
||||
return err
|
||||
}
|
||||
return h.enqueueDetailSync(ctx, record.ApplicationID, spNo)
|
||||
}
|
||||
|
||||
func (h *ApprovalRecoveryTaskHandler) recoverUnknown(ctx context.Context, now time.Time) error {
|
||||
cutoff := now.Add(-constants.WeComApprovalPollingInterval)
|
||||
records, err := h.contexts.ListUnknownRecovery(ctx, cutoff, constants.WeComApprovalUnknownRecoveryBatchSize)
|
||||
records, err := h.contexts.ListUnknownRecovery(ctx, now.Add(-constants.WeComApprovalPollingInterval), constants.WeComApprovalUnknownRecoveryBatchSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, record := range records {
|
||||
claimed, err := h.contexts.ClaimUnknownRecovery(ctx, record.InstanceID, cutoff)
|
||||
claimed, err := h.contexts.ClaimUnknownRecovery(ctx, record.InstanceID, now.Add(-constants.WeComApprovalPollingInterval))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !claimed {
|
||||
continue
|
||||
}
|
||||
spNo, submissionIntegrationID, err := h.contexts.FindSuccessfulSubmissionSPNo(ctx, record.InstanceID)
|
||||
if err != nil {
|
||||
if err := h.recoverUnknownRecord(ctx, record, now); err != nil {
|
||||
return err
|
||||
}
|
||||
integrationIDs := []string{submissionIntegrationID}
|
||||
if spNo == "" {
|
||||
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, integrationIDs,
|
||||
constants.AuditActorScheduledJob, constants.ApprovalAuditActorRecoveryJob, constants.AuditSourceScheduler,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if recovered {
|
||||
if err := h.enqueueDetailSync(ctx, record.ApplicationID, spNo); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
53
internal/infrastructure/wecom/manual_sync_consumer.go
Normal file
53
internal/infrastructure/wecom/manual_sync_consumer.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package wecom
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"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/queue"
|
||||
)
|
||||
|
||||
type ManualSyncConsumer struct {
|
||||
db *gorm.DB
|
||||
queue *queue.Client
|
||||
}
|
||||
|
||||
func NewManualSyncConsumer(db *gorm.DB, client *queue.Client) *ManualSyncConsumer {
|
||||
return &ManualSyncConsumer{db: db, queue: client}
|
||||
}
|
||||
|
||||
func (c *ManualSyncConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
|
||||
if c == nil || c.db == nil || c.queue == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "审批手动同步消费者未配置")
|
||||
}
|
||||
var payload struct {
|
||||
InstanceID uint `json:"instance_id"`
|
||||
ApplicationID uint `json:"application_id"`
|
||||
SPNo string `json:"sp_no"`
|
||||
}
|
||||
if err := sonic.Unmarshal(envelope.Payload, &payload); err != nil || payload.InstanceID == 0 || payload.ApplicationID == 0 || payload.SPNo == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "审批手动同步事件载荷无效")
|
||||
}
|
||||
id := strconv.FormatUint(uint64(payload.InstanceID), 10)
|
||||
var instance model.ApprovalInstance
|
||||
if err := c.db.WithContext(ctx).Where("id = ?", payload.InstanceID).First(&instance).Error; err != nil {
|
||||
return errors.New(errors.CodeConflict, "审批手动同步实例不存在")
|
||||
}
|
||||
if envelope.EventType != constants.OutboxEventTypeApprovalManualSyncRequested || envelope.AggregateType != "approval" || envelope.AggregateID != id || envelope.ResourceType != instance.BusinessType || envelope.ResourceID != strconv.FormatUint(uint64(instance.BusinessID), 10) {
|
||||
return errors.New(errors.CodeInvalidParam, "审批手动同步事件身份无效")
|
||||
}
|
||||
var channelContext model.WeComApprovalContext
|
||||
if err := c.db.WithContext(ctx).Where("approval_instance_id = ? AND application_id = ? AND sp_no = ? AND submission_status = ?", payload.InstanceID, payload.ApplicationID, payload.SPNo, constants.WeComSubmissionStatusSubmitted).First(&channelContext).Error; err != nil {
|
||||
return errors.New(errors.CodeConflict, "审批手动同步上下文不一致")
|
||||
}
|
||||
return c.queue.EnqueueTask(ctx, constants.TaskTypeWeComApprovalSync, ApprovalDetailSyncTask{ApplicationID: payload.ApplicationID, SPNo: payload.SPNo, Source: constants.ApprovalSyncSourceManual})
|
||||
}
|
||||
|
||||
var _ outbox.EventConsumer = (*ManualSyncConsumer)(nil)
|
||||
51
internal/infrastructure/wecom/unknown_recovery_consumer.go
Normal file
51
internal/infrastructure/wecom/unknown_recovery_consumer.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package wecom
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"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/queue"
|
||||
)
|
||||
|
||||
type UnknownRecoveryConsumer struct {
|
||||
db *gorm.DB
|
||||
queue *queue.Client
|
||||
}
|
||||
|
||||
func NewUnknownRecoveryConsumer(db *gorm.DB, client *queue.Client) *UnknownRecoveryConsumer {
|
||||
return &UnknownRecoveryConsumer{db: db, queue: client}
|
||||
}
|
||||
|
||||
func (c *UnknownRecoveryConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
|
||||
if c == nil || c.db == nil || c.queue == nil {
|
||||
return errors.New(errors.CodeServiceUnavailable, "审批未知恢复消费者未配置")
|
||||
}
|
||||
var payload struct {
|
||||
InstanceID uint `json:"instance_id"`
|
||||
}
|
||||
if err := sonic.Unmarshal(envelope.Payload, &payload); err != nil || payload.InstanceID == 0 {
|
||||
return errors.New(errors.CodeInvalidParam, "审批未知恢复事件载荷无效")
|
||||
}
|
||||
id := strconv.FormatUint(uint64(payload.InstanceID), 10)
|
||||
var instance model.ApprovalInstance
|
||||
if err := c.db.WithContext(ctx).Where("id = ?", payload.InstanceID).First(&instance).Error; err != nil {
|
||||
return errors.New(errors.CodeConflict, "审批未知恢复实例不存在")
|
||||
}
|
||||
if envelope.EventType != constants.OutboxEventTypeApprovalUnknownRecoveryRequested || envelope.AggregateType != "approval" || envelope.AggregateID != id || envelope.ResourceType != instance.BusinessType || envelope.ResourceID != strconv.FormatUint(uint64(instance.BusinessID), 10) {
|
||||
return errors.New(errors.CodeInvalidParam, "审批未知恢复事件身份无效")
|
||||
}
|
||||
var approvalContext model.WeComApprovalContext
|
||||
if err := c.db.WithContext(ctx).Where("approval_instance_id = ? AND submission_status = ? AND sp_no = ''", payload.InstanceID, constants.WeComSubmissionStatusUnknown).First(&approvalContext).Error; err != nil || instance.Status != constants.ApprovalStatusSubmissionUnknown {
|
||||
return errors.New(errors.CodeConflict, "审批未知恢复上下文不一致")
|
||||
}
|
||||
return c.queue.EnqueueTask(ctx, constants.TaskTypeWeComApprovalRecovery, map[string]any{"instance_id": payload.InstanceID})
|
||||
}
|
||||
|
||||
var _ outbox.EventConsumer = (*UnknownRecoveryConsumer)(nil)
|
||||
Reference in New Issue
Block a user