Files
junhong_cmp_fiber/internal/task/order_package_invalidate.go
break 5e552d99bc 收口审计治理与套餐任务进展
Constraint: 在线热修前必须保存当前迭代分支全部有效代码进展
Confidence: medium
Scope-risk: broad
Directive: 后续修改需保持审计事件与业务事务边界一致
Tested: git diff --cached --check
Not-tested: 未运行全量测试,提交用于切换分支前保存既有工作
2026-08-05 14:30:54 +08:00

370 lines
13 KiB
Go
Raw Permalink 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 task
import (
"context"
"crypto/sha256"
"encoding/csv"
"fmt"
"io"
"os"
"strings"
"github.com/bytedance/sonic"
"github.com/hibiken/asynq"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
"github.com/break/junhong_cmp_fiber/pkg/constants"
pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/storage"
)
// OrderPackageInvalidatePayload 批量失效订单套餐任务载荷
type OrderPackageInvalidatePayload struct {
TaskID uint `json:"task_id"`
}
// OrderPackageInvalidateHandler 批量失效订单套餐任务处理器
type OrderPackageInvalidateHandler struct {
taskStore *postgres.OrderPackageInvalidateTaskStore
orderStore *postgres.OrderStore
packageUsageStore *postgres.PackageUsageStore
storageService *storage.Service
logger *zap.Logger
auditWriter *audit.Writer
}
// NewOrderPackageInvalidateHandler 创建处理器实例
func NewOrderPackageInvalidateHandler(
taskStore *postgres.OrderPackageInvalidateTaskStore,
orderStore *postgres.OrderStore,
packageUsageStore *postgres.PackageUsageStore,
storageSvc *storage.Service,
logger *zap.Logger,
auditWriters ...*audit.Writer,
) *OrderPackageInvalidateHandler {
handler := &OrderPackageInvalidateHandler{
taskStore: taskStore,
orderStore: orderStore,
packageUsageStore: packageUsageStore,
storageService: storageSvc,
logger: logger,
}
if len(auditWriters) > 0 {
handler.auditWriter = auditWriters[0]
}
return handler
}
// Handle 处理批量失效订单套餐任务
// POST /api/admin/order-package-invalidate-tasks
func (h *OrderPackageInvalidateHandler) Handle(ctx context.Context, t *asynq.Task) error {
var payload OrderPackageInvalidatePayload
if err := sonic.Unmarshal(t.Payload(), &payload); err != nil {
h.logger.Error("解析批量失效任务载荷失败",
zap.Error(err),
zap.String("task_id", t.ResultWriter().TaskID()),
)
return asynq.SkipRetry
}
importTask, err := h.taskStore.GetByID(ctx, payload.TaskID)
if err != nil {
h.logger.Error("获取批量失效任务失败",
zap.Uint("task_id", payload.TaskID),
zap.Error(err),
)
return asynq.SkipRetry
}
rootEventID := audit.TaskEventID(constants.AuditResourceOrderPackageInvalidateTask, importTask.ID, "completed")
ctx = auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorSystemTask, ActorID: constants.TaskTypeOrderPackageInvalidate,
ActorName: "订单套餐批量失效任务", Source: constants.AuditSourceWorker,
CorrelationID: importTask.TaskNo, ParentEventID: rootEventID,
})
claimed, err := h.taskStore.Claim(ctx, importTask.ID)
if err != nil {
return err
}
if !claimed {
h.logger.Info("批量失效任务已处理,跳过",
zap.Uint("task_id", payload.TaskID),
zap.Int("status", importTask.Status),
)
return nil
}
h.logger.Info("开始处理批量失效订单套餐任务",
zap.Uint("task_id", importTask.ID),
zap.String("task_no", importTask.TaskNo),
)
orderNos, err := h.downloadAndParseCSV(ctx, importTask)
if err != nil {
h.logger.Error("下载或解析 CSV 失败",
zap.Uint("task_id", importTask.ID),
zap.Error(err),
)
if finishErr := h.finishInvalidateTask(ctx, importTask, 0, 0, 1, model.ImportTaskStatusFailed, err.Error(), nil); finishErr != nil {
h.resetInvalidateTaskForRetry(ctx, importTask.ID)
return finishErr
}
return asynq.SkipRetry
}
successCount, failedItems := h.processRows(ctx, importTask.ID, orderNos)
failCount := len(failedItems)
totalCount := len(orderNos)
status, errorMessage := model.ImportTaskStatusCompleted, ""
if failCount > 0 && successCount == 0 {
status, errorMessage = model.ImportTaskStatusFailed, "所有行均处理失败"
}
if err := h.finishInvalidateTask(ctx, importTask, totalCount, successCount, failCount, status, errorMessage, model.ImportResultItems(toImportResultItems(failedItems))); err != nil {
h.resetInvalidateTaskForRetry(ctx, importTask.ID)
return err
}
h.logger.Info("批量失效订单套餐任务完成",
zap.Uint("task_id", importTask.ID),
zap.Int("total", totalCount),
zap.Int("success", successCount),
zap.Int("fail", failCount),
)
return nil
}
func (h *OrderPackageInvalidateHandler) resetInvalidateTaskForRetry(ctx context.Context, taskID uint) {
_ = h.taskStore.DB().WithContext(ctx).Model(&model.OrderPackageInvalidateTask{}).
Where("id = ? AND status = ?", taskID, model.ImportTaskStatusProcessing).
Updates(map[string]any{"status": model.ImportTaskStatusPending, "started_at": nil}).Error
}
// invalidateRow 单行处理结果
type invalidateRow struct {
line int
orderNo string
reason string
}
// processRows 逐行处理订单号,返回成功数和失败列表
func (h *OrderPackageInvalidateHandler) processRows(ctx context.Context, taskID uint, rows []string) (int, []invalidateRow) {
successCount := 0
var failed []invalidateRow
for i, orderNo := range rows {
line := i + 2 // 第1行为表头数据从第2行开始
if err := h.processOneOrder(ctx, taskID, orderNo); err != nil {
failed = append(failed, invalidateRow{line: line, orderNo: orderNo, reason: err.Error()})
} else {
successCount++
}
}
return successCount, failed
}
// processOneOrder 处理单个订单号:查订单 → 查套餐 → 批量更新状态=4
func (h *OrderPackageInvalidateHandler) processOneOrder(ctx context.Context, taskID uint, orderNo string) error {
order, err := h.orderStore.GetByOrderNo(ctx, orderNo)
if err != nil {
h.appendInvalidateFailure(ctx, taskID, &model.Order{OrderNo: orderNo}, "订单不存在")
return errOrderNotFound(orderNo)
}
queryFailed := false
err = h.taskStore.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
usages, queryErr := postgres.NewPackageUsageStore(tx, nil).ListActiveByOrderID(ctx, order.ID)
if queryErr != nil {
queryFailed = true
return queryErr
}
if len(usages) == 0 {
return nil
}
ids := make([]uint, 0, len(usages))
resources := []audit.ResourceInput{audit.OrderResource(order, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleOrderTarget)}
for _, usage := range usages {
ids = append(ids, usage.ID)
resources = append(resources, audit.PackageUsageResource(usage, constants.AuditResourceRelationAffected, constants.AuditResourceRolePackageUsageTarget,
map[string]any{"status": usage.Status}, map[string]any{"status": constants.PackageUsageStatusInvalidated}))
}
if err := postgres.NewPackageUsageStore(tx, nil).BatchUpdateStatus(ctx, ids, constants.PackageUsageStatusInvalidated); err != nil {
return err
}
return h.auditWriter.Append(ctx, tx, audit.AppendInput{
EventID: audit.TaskEventID(constants.AuditResourceOrderPackageInvalidateTask, taskID, fmt.Sprintf("item:%d", order.ID)),
ActionCode: constants.AuditActionOrderPackageInvalidateItem, Summary: "失效订单套餐权益",
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess, Resources: resources,
})
})
if err != nil {
summary := "更新套餐状态失败"
if queryFailed {
summary = "查询套餐失败"
}
h.appendInvalidateFailure(ctx, taskID, order, summary)
if queryFailed {
return errQueryFailed(orderNo)
}
return errUpdateFailed(orderNo)
}
return nil
}
func (h *OrderPackageInvalidateHandler) appendInvalidateFailure(ctx context.Context, taskID uint, order *model.Order, summary string) {
if h.auditWriter == nil || order == nil || order.OrderNo == "" {
return
}
err := h.taskStore.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
keyHash := sha256.Sum256([]byte(order.OrderNo))
return h.auditWriter.Append(ctx, tx, audit.AppendInput{
EventID: fmt.Sprintf("task:order_invalidate:%d:failed:%x", taskID, keyHash[:6]),
ActionCode: constants.AuditActionOrderPackageInvalidateItem, Summary: summary,
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultFailed,
ErrorCode: fmt.Sprintf("%d", pkgerrors.CodeDatabaseError), ErrorSummary: summary,
Resources: []audit.ResourceInput{audit.OrderResource(order, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleOrderTarget)},
})
})
if err != nil {
auditfailure.RecordSecondaryWriteFailure(constants.AuditActionOrderPackageInvalidateItem, order.OrderNo, "", auditcontext.From(ctx).CorrelationID, fmt.Sprintf("%d", pkgerrors.CodeDatabaseError), err)
}
}
func (h *OrderPackageInvalidateHandler) finishInvalidateTask(ctx context.Context, task *model.OrderPackageInvalidateTask, totalCount, successCount, failCount, status int, errorMessage string, failedItems model.ImportResultItems) error {
if h.auditWriter == nil {
return pkgerrors.New(pkgerrors.CodeInvalidStatus, "订单套餐失效任务统一审计接缝未配置")
}
return h.taskStore.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
txStore := h.taskStore.WithTx(tx)
if err := txStore.UpdateResult(ctx, task.ID, totalCount, successCount, failCount, failedItems); err != nil {
return err
}
if err := txStore.UpdateStatus(ctx, task.ID, status, errorMessage); err != nil {
return err
}
rootID := audit.TaskEventID(constants.AuditResourceOrderPackageInvalidateTask, task.ID, "completed")
var actualSuccess, actualFail int64
if err := tx.WithContext(ctx).Model(&model.AuditEvent{}).
Where("parent_event_id = ? AND action_code = ? AND result = ?", rootID, constants.AuditActionOrderPackageInvalidateItem, constants.AuditResultSuccess).
Count(&actualSuccess).Error; err != nil {
return err
}
if err := tx.WithContext(ctx).Model(&model.AuditEvent{}).
Where("parent_event_id = ? AND action_code = ? AND result = ?", rootID, constants.AuditActionOrderPackageInvalidateItem, constants.AuditResultFailed).
Count(&actualFail).Error; err != nil {
return err
}
auditFailCount := int(actualFail)
if auditFailCount == 0 && failCount > 0 {
auditFailCount = failCount
}
return h.auditWriter.WriteTask(ctx, tx, audit.TaskInput{
EventID: rootID, ActionCode: constants.AuditActionOrderPackageInvalidateTaskCompleted,
Summary: "完成订单套餐批量失效任务", TaskID: task.ID, TaskNo: task.TaskNo,
Result: batchAuditResult(int(actualSuccess), auditFailCount), CorrelationID: task.TaskNo,
ParentEventID: audit.TaskEventID(constants.AuditResourceOrderPackageInvalidateTask, task.ID, "created"),
BatchTotal: int(actualSuccess) + auditFailCount, SuccessCount: int(actualSuccess), FailCount: auditFailCount,
IdentitySnapshot: map[string]any{"id": task.ID, "task_no": task.TaskNo, "file_name": task.FileName},
BeforeData: map[string]any{"status": model.ImportTaskStatusProcessing},
AfterData: map[string]any{
"status": status, "total_count": totalCount, "success_count": successCount, "fail_count": failCount,
},
Metadata: map[string]any{"task_success_count": successCount, "task_fail_count": failCount},
})
})
}
// downloadAndParseCSV 从对象存储下载 CSV 并解析 order_no 列
func (h *OrderPackageInvalidateHandler) downloadAndParseCSV(ctx context.Context, task *model.OrderPackageInvalidateTask) ([]string, error) {
if h.storageService == nil {
return nil, ErrStorageNotConfigured
}
if task.StorageKey == "" {
return nil, ErrStorageKeyEmpty
}
localPath, cleanup, err := h.storageService.DownloadToTemp(ctx, task.StorageKey)
if err != nil {
return nil, err
}
defer cleanup()
return parseOrderNoCSV(localPath)
}
// parseOrderNoCSV 解析单列 CSV第一列为 order_no首行为表头
func parseOrderNoCSV(filePath string) ([]string, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer f.Close()
reader := csv.NewReader(f)
reader.TrimLeadingSpace = true
var orderNos []string
firstRow := true
for {
record, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
if firstRow {
firstRow = false
continue // 跳过表头
}
if len(record) == 0 {
continue
}
orderNo := strings.TrimSpace(record[0])
if orderNo == "" {
continue
}
orderNos = append(orderNos, orderNo)
}
return orderNos, nil
}
// toImportResultItems 将内部失败记录转换为通用失败明细格式
// 复用 ImportResultItemICCID 字段存储 order_no
func toImportResultItems(rows []invalidateRow) []model.ImportResultItem {
items := make([]model.ImportResultItem, 0, len(rows))
for _, r := range rows {
items = append(items, model.ImportResultItem{
Line: r.line,
ICCID: r.orderNo,
Reason: r.reason,
})
}
return items
}
func errOrderNotFound(orderNo string) error {
return orderInvalidateError("订单不存在: " + orderNo)
}
func errQueryFailed(orderNo string) error {
return orderInvalidateError("查询套餐失败: " + orderNo)
}
func errUpdateFailed(orderNo string) error {
return orderInvalidateError("更新套餐状态失败: " + orderNo)
}
type orderInvalidateError string
func (e orderInvalidateError) Error() string { return string(e) }