feat(业务用户组): AUG26-003 业务用户组与店铺负责人分组导入
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 1h43m42s

- 迁移 000221:新增 tb_business_user_group、tb_business_user_group_member、tb_shop_business_owner_import_task,成员一账号一行由部分唯一索引保证,店铺所属组按当前负责人实时推导,不回填历史分组。
- 用户组 CRUD、成员改组/清空归属、店铺批量交接(原子失败不部分写入)。
- 店铺负责人 CSV 导入任务:逐行独立事务、逐行明细、任务级与行级失败分离。
- 读侧推导与筛选:未分组、业务线、停用组可筛出并带停用标记。
- 补齐操作审计动作与资源、openapi 清单、发布门禁巡检表清单。
- 归档 add-shop-salesperson-groups 变更并同步 openspec/specs/business-user-group,补齐 AUG26-003 验证证据链。
This commit is contained in:
2026-09-14 16:51:44 +08:00
parent 957a235585
commit c7f9e005af
56 changed files with 4166 additions and 344 deletions

View File

@@ -0,0 +1,59 @@
package shop_business_owner_import
import (
"context"
"strconv"
"gorm.io/gorm"
infraAudit "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// TaskPayload 店铺负责人导入 Worker 结构化载荷,与 Worker 侧载荷保持同一 JSON 契约。
type TaskPayload struct {
TaskID uint `json:"task_id"`
}
// writeTaskAudit 在调用方事务内写导入任务创建或入队失败审计。
// 任务资源身份快照只保留注册表允许的最小字段。
func (s *Service) writeTaskAudit(ctx context.Context, tx *gorm.DB, task *model.ShopBusinessOwnerImportTask, result string, before, after map[string]any, errorCode string) error {
return s.auditWriter.WriteTask(ctx, tx, infraAudit.TaskInput{
EventID: infraAudit.TaskEventID(constants.AuditResourceShopBusinessOwnerImportTask, task.ID, taskAuditPhase(result)),
ActionCode: constants.AuditActionShopBusinessOwnerImportTaskCreated, Summary: "创建店铺负责人导入任务",
TaskID: task.ID, TaskNo: task.TaskNo,
Actor: infraAudit.ActorInput{
Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(middleware.GetUserIDFromContext(ctx)), 10),
Name: middleware.GetUsernameFromContext(ctx),
},
Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform,
Result: result, ErrorCode: errorCode, ErrorSummary: task.ErrorMessage,
// 与 Worker 侧完成事件使用同一关联键,使同一任务的全部事件可按 correlation 串成一条时间线。
CorrelationID: task.TaskNo,
IdentitySnapshot: map[string]any{
"id": task.ID, "task_no": task.TaskNo, "file_name": task.FileName,
},
BeforeData: before, AfterData: after,
})
}
// taskAuditPhase 返回任务创建阶段的稳定事件阶段名,失败入队使用独立阶段避免覆盖首次事件。
func taskAuditPhase(result string) string {
if result == constants.AuditResultSuccess {
return "created"
}
return "enqueue_failed"
}
// taskState 生成导入任务状态快照。
func taskState(task *model.ShopBusinessOwnerImportTask) map[string]any {
if task == nil {
return nil
}
return map[string]any{
"status": task.Status, "total_count": task.TotalCount,
"success_count": task.SuccessCount, "fail_count": task.FailCount,
}
}

View File

@@ -0,0 +1,176 @@
// Package shop_business_owner_import 提供店铺负责人 CSV 导入任务的创建与查询能力。
// 导入独立成表、独立队列,逐行独立事务由 Worker 执行;本包只负责受理、入队与投影。
package shop_business_owner_import
import (
"context"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/hibiken/asynq"
"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/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/auditfailure"
"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"
)
// Service 店铺负责人 CSV 导入任务服务。
type Service struct {
db *gorm.DB
taskStore *postgres.ShopBusinessOwnerImportTaskStore
queueClient *queue.Client
auditWriter *audit.Writer
}
// New 创建店铺负责人 CSV 导入任务服务。
func New(taskStore *postgres.ShopBusinessOwnerImportTaskStore, queueClient *queue.Client, auditWriters ...*audit.Writer) *Service {
service := &Service{db: taskStore.DB(), taskStore: taskStore, queueClient: queueClient}
if len(auditWriters) > 0 {
service.auditWriter = auditWriters[0]
}
return service
}
// Create 创建导入任务并在同一事务写入创建审计,随后投递到独立导入队列。
func (s *Service) Create(ctx context.Context, request *dto.CreateShopBusinessOwnerImportRequest) (*dto.ShopBusinessOwnerImportTaskResponse, error) {
if !strings.HasPrefix(request.FileKey, constants.ShopBusinessOwnerImportStoragePrefix+"/") {
return nil, errors.New(errors.CodeInvalidParam, "导入文件Key不属于指定上传目录")
}
if !strings.EqualFold(filepath.Ext(request.FileKey), ".csv") {
return nil, errors.New(errors.CodeInvalidParam, "店铺负责人导入文件必须为CSV格式")
}
userID := middleware.GetUserIDFromContext(ctx)
if userID == 0 {
return nil, errors.New(errors.CodeUnauthorized)
}
if s.auditWriter == nil {
return nil, errors.New(errors.CodeInvalidStatus, "店铺负责人导入统一审计接缝未配置")
}
taskRecord := &model.ShopBusinessOwnerImportTask{
TaskNo: s.taskStore.GenerateTaskNo(), FileName: filepath.Base(request.FileKey),
StorageKey: request.FileKey, Status: model.ImportTaskStatusPending,
ResultItems: model.ShopBusinessOwnerImportResults{},
CreatorName: middleware.GetUsernameFromContext(ctx),
BaseModel: model.BaseModel{Creator: userID, Updater: userID},
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.taskStore.WithTx(tx).Create(ctx, taskRecord); err != nil {
return err
}
return s.writeTaskAudit(ctx, tx, taskRecord, constants.AuditResultSuccess, nil, nil, "")
}); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建店铺负责人导入任务失败")
}
var enqueueErr error
if s.queueClient == nil {
enqueueErr = errors.New(errors.CodeTaskQueueError, "店铺负责人导入任务队列未配置")
} else {
enqueueErr = s.queueClient.EnqueueTask(ctx, constants.TaskTypeShopBusinessOwnerImport,
TaskPayload{TaskID: taskRecord.ID},
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeShopBusinessOwnerImport)),
asynq.Timeout(constants.ShopBusinessOwnerImportTaskTimeout))
}
if enqueueErr != nil {
message := "导入任务入队失败"
secondaryErr := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
before := taskState(taskRecord)
hit, err := s.taskStore.WithTx(tx).MarkFailed(ctx, taskRecord.ID, message)
if err != nil {
return err
}
if !hit {
// 任务已到终态Enqueue 报错但消息实际已投递且 Worker 已跑完。
// 库内才是事实,绝不回写失败态,也不写失败审计。
return nil
}
// 失败原因必须回填到内存快照,响应与失败审计才与库内一致。
taskRecord.Status, taskRecord.ErrorMessage = model.ImportTaskStatusFailed, message
now := time.Now()
taskRecord.CompletedAt = &now
return s.writeTaskAudit(ctx, tx, taskRecord, constants.AuditResultFailed, before, taskState(taskRecord), strconv.Itoa(errors.CodeTaskQueueError))
})
if secondaryErr != nil {
auditfailure.RecordSecondaryWriteFailure(constants.AuditActionShopBusinessOwnerImportTaskCreated,
taskRecord.TaskNo, "", taskRecord.TaskNo, strconv.Itoa(errors.CodeTaskQueueError), secondaryErr)
} else if taskRecord.Status != model.ImportTaskStatusFailed {
// 未命中非终态时重新读取任务行,让响应反映库内真实终态。
if stored, err := s.taskStore.GetByID(ctx, taskRecord.ID); err == nil {
taskRecord = stored
}
}
}
return toResponse(taskRecord), nil
}
// List 分页查询导入任务。
func (s *Service) List(ctx context.Context, request *dto.ListShopBusinessOwnerImportRequest) (*dto.ShopBusinessOwnerImportTaskPageResult, error) {
page, pageSize := request.Page, request.PageSize
if page <= 0 {
page = constants.DefaultPage
}
if pageSize <= 0 {
pageSize = constants.DefaultPageSize
}
if pageSize > constants.MaxPageSize {
pageSize = constants.MaxPageSize
}
tasks, total, err := s.taskStore.List(ctx, &store.QueryOptions{Page: page, PageSize: pageSize}, request.Status)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺负责人导入任务失败")
}
items := make([]*dto.ShopBusinessOwnerImportTaskResponse, 0, len(tasks))
for _, taskRecord := range tasks {
items = append(items, toResponse(taskRecord))
}
return &dto.ShopBusinessOwnerImportTaskPageResult{Items: items, Total: total, Page: page, Size: pageSize}, nil
}
// GetByID 查询导入任务详情与逐行结果。
func (s *Service) GetByID(ctx context.Context, id uint) (*dto.ShopBusinessOwnerImportTaskDetailResponse, error) {
taskRecord, err := s.taskStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "店铺负责人导入任务不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺负责人导入任务失败")
}
items := make([]dto.ShopBusinessOwnerImportItemResponse, 0, len(taskRecord.ResultItems))
for _, item := range taskRecord.ResultItems {
items = append(items, dto.ShopBusinessOwnerImportItemResponse{
Line: item.Line, ShopCode: item.ShopCode, OperationType: item.OperationType,
Status: item.Status, StatusName: constants.GetShopBusinessOwnerImportItemStatusName(item.Status),
Reason: item.Reason,
})
}
return &dto.ShopBusinessOwnerImportTaskDetailResponse{
ShopBusinessOwnerImportTaskResponse: *toResponse(taskRecord), Items: items,
}, nil
}
func toResponse(taskRecord *model.ShopBusinessOwnerImportTask) *dto.ShopBusinessOwnerImportTaskResponse {
response := &dto.ShopBusinessOwnerImportTaskResponse{
ID: taskRecord.ID, TaskNo: taskRecord.TaskNo, FileName: taskRecord.FileName,
Status: taskRecord.Status, StatusName: model.ImportTaskStatusName(taskRecord.Status),
TotalCount: taskRecord.TotalCount, SuccessCount: taskRecord.SuccessCount, FailCount: taskRecord.FailCount,
ErrorMessage: taskRecord.ErrorMessage, CreatorName: taskRecord.CreatorName,
CreatedAt: taskRecord.CreatedAt.Format(time.RFC3339),
}
if taskRecord.StartedAt != nil {
response.StartedAt = taskRecord.StartedAt.Format(time.RFC3339)
}
if taskRecord.CompletedAt != nil {
response.CompletedAt = taskRecord.CompletedAt.Format(time.RFC3339)
}
return response
}