All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 13m40s
统一时间筛选:新增共享严格解析器 pkg/utils/time_range.go,只接受带显式时区的 RFC3339 秒级时间(拒绝小数秒、无时区、date-only、空格分隔、±hhmm、未补零、非法日期与越界偏移),闭区间含两端、归一为 UTC 瞬时,创建期与执行期共用同一份实现。 端点改造(13 个入口):IoT 卡导入任务、设备导入任务、导出任务列表、订单列表参数名不变仅收紧解析;换货、分配记录、代理充值、临期列表改名 start_time/end_time(旧参数名显式拒绝);提现记录两处删除解析失败静默跳过,非法参数一律 1001;授权记录由起始闭结束开改为闭区间含两端;临期列表改按当前生效主套餐最终到期时刻比较,保留剩余天数上下限与既有粗放窗口。 临期导出新建:新场景 expiring_asset 与受控入口 POST /api/admin/expiring-assets/export,复用列表候选预筛与最终到期推算,一行一资产、加油包不单独成行,列序与 111 §18.1 逐列一致,店铺/业务员/用户组按执行时当前归属补充且不超出创建时冻结范围。 佣金明细导出新增按创建时间闭区间筛选(原佣金与回溯两条分支各自创建时间列),记录粒度、列定义与余额口径不变。 冻结与遗留任务:创建期把筛选与时间边界规范化为 UTC RFC3339 秒级串写入既有 query_json,无新列无迁移;执行期只按冻结值严格解析,非法冻结值在任何分片与文件动作前落任务失败并写安全摘要,不放行全量;重试沿用原快照。达量预警导出执行期同样纳入严格解析(其入口契约、列定义与触发快照口径不变)。 归档 add-export-time-filter-standards 并新建主 Spec openspec/specs/export-time-filter/spec.md,同步 requirement-evidence.json 与入口能力矩阵,README 导出场景清单更新为 11 个场景。 验证:junhong_cmp_test + 本地隔离 Redis(DB7,测试部署共享队列 DB6 未被占用)实跑 85 PASS / 0 FAIL(接受/拒绝集合、区间与顺序语义、列表与导出同筛选行集一致、代理 HTTP 全链路与范围冻结、遗留旧格式任务安全失败、列与余额口径回归、表头逐字),门禁 gofmt/go build/gendocs 两次一致/openspec validate/doctor/context-health 全绿;无 Schema 变更、无迁移、无运行时开关。
283 lines
8.1 KiB
Go
283 lines
8.1 KiB
Go
package iot_card_import
|
||
|
||
import (
|
||
"context"
|
||
"path/filepath"
|
||
"strconv"
|
||
"time"
|
||
|
||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||
"github.com/break/junhong_cmp_fiber/internal/store"
|
||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
||
"github.com/hibiken/asynq"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
type Service struct {
|
||
db *gorm.DB
|
||
importTaskStore *postgres.IotCardImportTaskStore
|
||
carrierStore carrierGetter
|
||
queueClient *queue.Client
|
||
auditWriter *audit.Writer
|
||
}
|
||
|
||
type carrierGetter interface {
|
||
GetByID(ctx context.Context, id uint) (*model.Carrier, error)
|
||
}
|
||
|
||
type CarrierStore struct {
|
||
db *gorm.DB
|
||
}
|
||
|
||
func NewCarrierStore(db *gorm.DB) *CarrierStore {
|
||
return &CarrierStore{db: db}
|
||
}
|
||
|
||
func (s *CarrierStore) GetByID(ctx context.Context, id uint) (*model.Carrier, error) {
|
||
var carrier model.Carrier
|
||
if err := s.db.WithContext(ctx).First(&carrier, id).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
return &carrier, nil
|
||
}
|
||
|
||
func New(
|
||
db *gorm.DB,
|
||
importTaskStore *postgres.IotCardImportTaskStore,
|
||
queueClient *queue.Client,
|
||
auditWriters ...*audit.Writer,
|
||
) *Service {
|
||
service := &Service{
|
||
db: db,
|
||
importTaskStore: importTaskStore,
|
||
carrierStore: NewCarrierStore(db),
|
||
queueClient: queueClient,
|
||
}
|
||
if len(auditWriters) > 0 {
|
||
service.auditWriter = auditWriters[0]
|
||
}
|
||
return service
|
||
}
|
||
|
||
type IotCardImportPayload struct {
|
||
TaskID uint `json:"task_id"`
|
||
}
|
||
|
||
func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportIotCardRequest) (*dto.ImportIotCardResponse, error) {
|
||
userID := middleware.GetUserIDFromContext(ctx)
|
||
if userID == 0 {
|
||
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||
}
|
||
|
||
carrier, err := s.carrierStore.GetByID(ctx, req.CarrierID)
|
||
if err != nil {
|
||
return nil, errors.New(errors.CodeInvalidParam, "运营商不存在")
|
||
}
|
||
|
||
taskNo := s.importTaskStore.GenerateTaskNo(ctx)
|
||
fileName := filepath.Base(req.FileKey)
|
||
|
||
cardCategory := req.CardCategory
|
||
if cardCategory == "" {
|
||
cardCategory = constants.CardCategoryNormal
|
||
}
|
||
|
||
task := &model.IotCardImportTask{
|
||
TaskNo: taskNo,
|
||
Status: model.ImportTaskStatusPending,
|
||
CarrierID: req.CarrierID,
|
||
CarrierType: carrier.CarrierType,
|
||
CarrierName: carrier.CarrierName,
|
||
BatchNo: req.BatchNo,
|
||
FileName: fileName,
|
||
StorageKey: req.FileKey,
|
||
CardCategory: cardCategory,
|
||
RealnamePolicy: req.RealnamePolicy,
|
||
CreatorName: middleware.GetUsernameFromContext(ctx),
|
||
}
|
||
task.Creator = userID
|
||
task.Updater = userID
|
||
|
||
if s.auditWriter == nil {
|
||
return nil, errors.New(errors.CodeInvalidStatus, "IoT 卡导入任务统一审计接缝未配置")
|
||
}
|
||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
if err := tx.WithContext(ctx).Create(task).Error; err != nil {
|
||
return err
|
||
}
|
||
return s.writeImportTaskAudit(ctx, tx, task, nil, importTaskState(task), constants.AuditResultSuccess, "created", "", "")
|
||
}); err != nil {
|
||
appErr := errors.Wrap(errors.CodeInternalError, err, "创建导入任务失败")
|
||
s.recordImportTaskAudit(ctx, task, nil, importTaskState(task), constants.AuditResultFailed, "create_failed", errors.CodeDatabaseError, "创建 IoT 卡导入任务失败")
|
||
return nil, appErr
|
||
}
|
||
|
||
payload := IotCardImportPayload{TaskID: task.ID}
|
||
err = s.queueClient.EnqueueTask(
|
||
ctx,
|
||
constants.TaskTypeIotCardImport,
|
||
payload,
|
||
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeIotCardImport)),
|
||
)
|
||
if err != nil {
|
||
secondaryErr := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
before := importTaskState(task)
|
||
if updateErr := tx.WithContext(ctx).Model(&model.IotCardImportTask{}).Where("id = ?", task.ID).Updates(map[string]any{
|
||
"status": model.ImportTaskStatusFailed, "error_message": "任务入队失败", "completed_at": time.Now(), "updated_at": time.Now(),
|
||
}).Error; updateErr != nil {
|
||
return updateErr
|
||
}
|
||
task.Status, task.ErrorMessage = model.ImportTaskStatusFailed, "任务入队失败"
|
||
return s.writeImportTaskAudit(ctx, tx, task, before, importTaskState(task), constants.AuditResultFailed, "enqueue_failed", strconv.Itoa(errors.CodeTaskQueueError), "IoT 卡导入任务入队失败")
|
||
})
|
||
if secondaryErr != nil {
|
||
s.recordImportTaskAudit(ctx, task, nil, importTaskState(task), constants.AuditResultFailed, "enqueue_audit_failed", errors.CodeTaskQueueError, "IoT 卡导入任务入队失败")
|
||
}
|
||
appErr := errors.Wrap(errors.CodeInternalError, err, "任务入队失败")
|
||
return nil, appErr
|
||
}
|
||
|
||
return &dto.ImportIotCardResponse{
|
||
TaskID: task.ID,
|
||
TaskNo: taskNo,
|
||
Message: "导入任务已创建,Worker 将异步处理文件",
|
||
}, nil
|
||
}
|
||
|
||
func (s *Service) List(ctx context.Context, req *dto.ListImportTaskRequest, startTime, endTime *time.Time) (*dto.ListImportTaskResponse, error) {
|
||
page := req.Page
|
||
pageSize := req.PageSize
|
||
if page == 0 {
|
||
page = 1
|
||
}
|
||
if pageSize == 0 {
|
||
pageSize = constants.DefaultPageSize
|
||
}
|
||
|
||
opts := &store.QueryOptions{
|
||
Page: page,
|
||
PageSize: pageSize,
|
||
}
|
||
|
||
filters := make(map[string]interface{})
|
||
if req.Status != nil {
|
||
filters["status"] = *req.Status
|
||
}
|
||
if req.CarrierID != nil {
|
||
filters["carrier_id"] = *req.CarrierID
|
||
}
|
||
if req.BatchNo != "" {
|
||
filters["batch_no"] = req.BatchNo
|
||
}
|
||
if startTime != nil {
|
||
filters["start_time"] = *startTime
|
||
}
|
||
if endTime != nil {
|
||
filters["end_time"] = *endTime
|
||
}
|
||
|
||
tasks, total, err := s.importTaskStore.List(ctx, opts, filters)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
list := make([]*dto.ImportTaskResponse, 0, len(tasks))
|
||
for _, task := range tasks {
|
||
list = append(list, s.toTaskResponse(task))
|
||
}
|
||
|
||
return &dto.ListImportTaskResponse{
|
||
List: list,
|
||
Total: total,
|
||
Page: page,
|
||
PageSize: pageSize,
|
||
}, nil
|
||
}
|
||
|
||
func (s *Service) GetByID(ctx context.Context, id uint) (*dto.ImportTaskDetailResponse, error) {
|
||
task, err := s.importTaskStore.GetByID(ctx, id)
|
||
if err != nil {
|
||
return nil, errors.New(errors.CodeNotFound, "导入任务不存在")
|
||
}
|
||
|
||
resp := &dto.ImportTaskDetailResponse{
|
||
ImportTaskResponse: *s.toTaskResponse(task),
|
||
SkippedItems: make([]*dto.ImportResultItemDTO, 0),
|
||
FailedItems: make([]*dto.ImportResultItemDTO, 0),
|
||
}
|
||
|
||
for _, item := range task.SkippedItems {
|
||
resp.SkippedItems = append(resp.SkippedItems, &dto.ImportResultItemDTO{
|
||
Line: item.Line,
|
||
ICCID: item.ICCID,
|
||
MSISDN: item.MSISDN,
|
||
Reason: item.Reason,
|
||
})
|
||
}
|
||
|
||
for _, item := range task.FailedItems {
|
||
resp.FailedItems = append(resp.FailedItems, &dto.ImportResultItemDTO{
|
||
Line: item.Line,
|
||
ICCID: item.ICCID,
|
||
MSISDN: item.MSISDN,
|
||
Reason: item.Reason,
|
||
})
|
||
}
|
||
|
||
return resp, nil
|
||
}
|
||
|
||
func (s *Service) toTaskResponse(task *model.IotCardImportTask) *dto.ImportTaskResponse {
|
||
var startedAt, completedAt *time.Time
|
||
if task.StartedAt != nil {
|
||
startedAt = task.StartedAt
|
||
}
|
||
if task.CompletedAt != nil {
|
||
completedAt = task.CompletedAt
|
||
}
|
||
|
||
return &dto.ImportTaskResponse{
|
||
ID: task.ID,
|
||
TaskNo: task.TaskNo,
|
||
Status: task.Status,
|
||
StatusText: getStatusText(task.Status),
|
||
CarrierID: task.CarrierID,
|
||
CarrierType: task.CarrierType,
|
||
CarrierName: task.CarrierName,
|
||
BatchNo: task.BatchNo,
|
||
CardCategory: task.CardCategory,
|
||
RealnamePolicy: task.RealnamePolicy,
|
||
FileName: task.FileName,
|
||
TotalCount: task.TotalCount,
|
||
SuccessCount: task.SuccessCount,
|
||
SkipCount: task.SkipCount,
|
||
FailCount: task.FailCount,
|
||
StartedAt: startedAt,
|
||
CompletedAt: completedAt,
|
||
ErrorMessage: task.ErrorMessage,
|
||
CreatorName: task.CreatorName,
|
||
CreatedAt: task.CreatedAt,
|
||
}
|
||
}
|
||
|
||
func getStatusText(status int) string {
|
||
switch status {
|
||
case model.ImportTaskStatusPending:
|
||
return "待处理"
|
||
case model.ImportTaskStatusProcessing:
|
||
return "处理中"
|
||
case model.ImportTaskStatusCompleted:
|
||
return "已完成"
|
||
case model.ImportTaskStatusFailed:
|
||
return "失败"
|
||
default:
|
||
return "未知"
|
||
}
|
||
}
|