diff --git a/cmd/api/docs.go b/cmd/api/docs.go index 6cdeb20..b1b6b9d 100644 --- a/cmd/api/docs.go +++ b/cmd/api/docs.go @@ -27,6 +27,8 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) { handlers := openapi.BuildDocHandlers() handlers.Audit = admin.NewAuditHandler(nil, nil) handlers.AssetPackageBatchOrder = admin.NewAssetPackageBatchOrderHandler(nil, nil) + handlers.BusinessUserGroup = admin.NewBusinessUserGroupHandler(nil, nil) + handlers.ShopBusinessOwnerImport = admin.NewShopBusinessOwnerImportHandler(nil) // 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。 handlers.WeCom = admin.NewWeComHandler(nil, nil) handlers.PaymentMerchant = admin.NewPaymentMerchantHandler(nil) diff --git a/cmd/gendocs/main.go b/cmd/gendocs/main.go index 75a32d3..8774fa0 100644 --- a/cmd/gendocs/main.go +++ b/cmd/gendocs/main.go @@ -36,6 +36,8 @@ func generateAdminDocs(outputPath string) error { handlers := openapi.BuildDocHandlers() handlers.Audit = admin.NewAuditHandler(nil, nil) handlers.AssetPackageBatchOrder = admin.NewAssetPackageBatchOrderHandler(nil, nil) + handlers.BusinessUserGroup = admin.NewBusinessUserGroupHandler(nil, nil) + handlers.ShopBusinessOwnerImport = admin.NewShopBusinessOwnerImportHandler(nil) // 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。 handlers.WeCom = admin.NewWeComHandler(nil, nil) handlers.PaymentMerchant = admin.NewPaymentMerchantHandler(nil) diff --git a/cmd/worker/main.go b/cmd/worker/main.go index c9f3b71..8a78ad8 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -952,6 +952,25 @@ func createTaskHandler(runtime *workerRuntime, appLogger *zap.Logger) *queue.Han func rescuePendingImportTasks(ctx context.Context, runtime *workerRuntime, appLogger *zap.Logger) { rescuePendingIotCardImportTasks(ctx, runtime.db, runtime.asynqClient, appLogger) rescuePendingDeviceImportTasks(ctx, runtime.db, runtime.asynqClient, appLogger) + rescuePendingShopBusinessOwnerImportTasks(ctx, runtime.db, runtime.asynqClient, appLogger) +} + +// rescuePendingShopBusinessOwnerImportTasks 补偿仍停留在待处理状态的店铺负责人导入任务。 +// 只扫描本 Change 自己的任务表,补偿键按任务类型与任务 ID 隔离,与设备导入补偿互不影响。 +func rescuePendingShopBusinessOwnerImportTasks(ctx context.Context, db *gorm.DB, asynqClient *asynq.Client, appLogger *zap.Logger) { + var importTasks []model.ShopBusinessOwnerImportTask + if err := db.WithContext(ctx). + Where("status = ?", model.ImportTaskStatusPending). + Limit(importRescueLimit). + Find(&importTasks).Error; err != nil { + appLogger.Warn("扫描待补偿店铺负责人导入任务失败", zap.Error(err)) + return + } + + for _, importTask := range importTasks { + payload := task.ShopBusinessOwnerImportPayload{TaskID: importTask.ID} + enqueueImportRescueTask(ctx, asynqClient, constants.TaskTypeShopBusinessOwnerImport, payload, importTask.ID, appLogger) + } } // rescuePendingIotCardImportTasks 补偿仍停留在待处理状态的 IoT 卡导入任务。 diff --git a/docs/verification/context-reset/entry-capability-requirement-matrix.json b/docs/verification/context-reset/entry-capability-requirement-matrix.json index 1cb52ba..793e69c 100644 --- a/docs/verification/context-reset/entry-capability-requirement-matrix.json +++ b/docs/verification/context-reset/entry-capability-requirement-matrix.json @@ -1164,7 +1164,8 @@ "entry": "GET /api/admin/shops", "capability": "identity-access", "requirements": [ - "identity-access::数据范围拒绝" + "identity-access::数据范围拒绝", + "business-user-group::店铺负责人和所属组实时推导" ], "classification": "behavior" }, @@ -1201,7 +1202,8 @@ "entry": "GET /api/admin/shops/{id}", "capability": "identity-access", "requirements": [ - "identity-access::数据范围拒绝" + "identity-access::数据范围拒绝", + "business-user-group::店铺负责人和所属组实时推导" ], "classification": "behavior" }, @@ -1223,6 +1225,16 @@ ], "classification": "behavior" }, + { + "entry_type": "http", + "entry": "GET /api/admin/shops/{shop_id}/commission-records/{id}", + "capability": "agent-funds-commission", + "requirements": [ + "agent-funds-commission::回溯明细关联查询与导出", + "identity-access::数据范围拒绝" + ], + "classification": "behavior" + }, { "entry_type": "http", "entry": "GET /api/admin/shops/{shop_id}/commission-stats", @@ -3585,6 +3597,7 @@ "requirements": [ "agent-funds-commission::退款佣金回扣可靠完成", "agent-funds-commission::退款后处理可补偿", + "agent-funds-commission::套餐退款佣金回溯", "order-refund-exchange::退款终态事实与失败分类" ], "classification": "route_index_or_infrastructure" @@ -4010,5 +4023,114 @@ "agent-distribution-withdrawal::提现冻结与企业微信终审" ], "classification": "behavior" + }, + { + "entry_type": "http", + "entry": "POST /api/admin/business-user-groups", + "capability": "business-user-group", + "requirements": [ + "business-user-group::业务用户组生命周期与成员归属" + ], + "classification": "behavior" + }, + { + "entry_type": "http", + "entry": "GET /api/admin/business-user-groups", + "capability": "business-user-group", + "requirements": [ + "business-user-group::业务用户组生命周期与成员归属" + ], + "classification": "behavior" + }, + { + "entry_type": "http", + "entry": "GET /api/admin/business-user-groups/{id}", + "capability": "business-user-group", + "requirements": [ + "business-user-group::业务用户组生命周期与成员归属" + ], + "classification": "behavior" + }, + { + "entry_type": "http", + "entry": "PUT /api/admin/business-user-groups/{id}", + "capability": "business-user-group", + "requirements": [ + "business-user-group::业务用户组生命周期与成员归属" + ], + "classification": "behavior" + }, + { + "entry_type": "http", + "entry": "DELETE /api/admin/business-user-groups/{id}", + "capability": "business-user-group", + "requirements": [ + "business-user-group::业务用户组生命周期与成员归属" + ], + "classification": "behavior" + }, + { + "entry_type": "http", + "entry": "PUT /api/admin/business-user-groups/{id}/members", + "capability": "business-user-group", + "requirements": [ + "business-user-group::业务用户组生命周期与成员归属" + ], + "classification": "behavior" + }, + { + "entry_type": "http", + "entry": "DELETE /api/admin/business-user-groups/members", + "capability": "business-user-group", + "requirements": [ + "business-user-group::业务用户组生命周期与成员归属" + ], + "classification": "behavior" + }, + { + "entry_type": "http", + "entry": "PUT /api/admin/shops/business-owner/batch", + "capability": "business-user-group", + "requirements": [ + "business-user-group::店铺负责人批量交接", + "identity-access::数据范围拒绝" + ], + "classification": "behavior" + }, + { + "entry_type": "http", + "entry": "POST /api/admin/shops/business-owner-imports", + "capability": "business-user-group", + "requirements": [ + "business-user-group::店铺负责人 CSV 导入" + ], + "classification": "behavior" + }, + { + "entry_type": "http", + "entry": "GET /api/admin/shops/business-owner-imports", + "capability": "business-user-group", + "requirements": [ + "business-user-group::店铺负责人 CSV 导入" + ], + "classification": "behavior" + }, + { + "entry_type": "http", + "entry": "GET /api/admin/shops/business-owner-imports/{id}", + "capability": "business-user-group", + "requirements": [ + "business-user-group::店铺负责人 CSV 导入" + ], + "classification": "behavior" + }, + { + "entry_type": "async", + "entry": "constants.TaskTypeShopBusinessOwnerImport", + "capability": "infrastructure", + "requirements": [ + "business-user-group::店铺负责人 CSV 导入" + ], + "classification": "route_index_or_infrastructure" } ] diff --git a/docs/verification/context-reset/requirement-evidence.json b/docs/verification/context-reset/requirement-evidence.json index e208690..ac85e75 100644 --- a/docs/verification/context-reset/requirement-evidence.json +++ b/docs/verification/context-reset/requirement-evidence.json @@ -3358,5 +3358,213 @@ ], "exit_status": 0 } + }, + { + "capability": "agent-funds-commission", + "requirement": "套餐退款佣金回溯", + "spec": "openspec/specs/agent-funds-commission/spec.md", + "entries": [ + "constants.TaskTypeRefundCommissionRecovery" + ], + "handler_consumer_job": [ + "internal/service/refund/approval_decision.go", + "internal/infrastructure/commissiondelivery/event.go", + "cmd/worker/main.go" + ], + "application_service_query": [ + "internal/service/refund/clawback.go" + ], + "domain_state_amount": [ + "internal/model/commission_clawback.go", + "pkg/constants/iot.go" + ], + "store_migration_config": [ + "internal/store/postgres/commission_record_store.go", + "migrations/000220_add_commission_clawback_record.up.sql" + ], + "verification": { + "command": "grep -nE 'uk_commission_clawback_refund_original|chk_commission_clawback_amount|chk_commission_clawback_withdrawable|chk_commission_clawback_status' migrations/000220_add_commission_clawback_record.up.sql", + "literal_output": [ + "23: CONSTRAINT uk_commission_clawback_refund_original UNIQUE (refund_id, original_commission_id),", + "25: CONSTRAINT chk_commission_clawback_amount CHECK (amount < 0),", + "26: CONSTRAINT chk_commission_clawback_withdrawable CHECK (withdrawable = FALSE),", + "27: CONSTRAINT chk_commission_clawback_status CHECK (status = 5)" + ], + "exit_status": 0 + } + }, + { + "capability": "agent-funds-commission", + "requirement": "回溯明细关联查询与导出", + "spec": "openspec/specs/agent-funds-commission/spec.md", + "entries": [ + "/api/admin/shops/{shop_id}/commission-records", + "/api/admin/shops/{shop_id}/commission-records/{id}" + ], + "handler_consumer_job": [ + "internal/routes/shop.go", + "internal/handler/admin/shop_commission.go" + ], + "application_service_query": [ + "internal/service/shop_commission/service.go" + ], + "domain_state_amount": [ + "internal/model/dto/shop_commission_dto.go" + ], + "store_migration_config": [ + "internal/store/postgres/commission_record_store.go", + "internal/exporter/commission_record_scene.go" + ], + "verification": { + "command": "grep -n 'ledger_clawback' internal/store/postgres/commission_record_store.go internal/exporter/commission_record_scene.go", + "literal_output": [ + "internal/store/postgres/commission_record_store.go:118:\t\tRaw(\"SELECT * FROM (?) AS ledger_original UNION ALL SELECT * FROM (?) AS ledger_clawback\", original, clawback)", + "internal/exporter/commission_record_scene.go:60:\t\tRaw(\"SELECT * FROM (?) AS ledger_original UNION ALL SELECT * FROM (?) AS ledger_clawback\"," + ], + "exit_status": 0 + } + }, + { + "capability": "business-user-group", + "requirement": "业务用户组生命周期与成员归属", + "spec": "openspec/specs/business-user-group/spec.md", + "entries": [ + "/api/admin/business-user-groups", + "/api/admin/business-user-groups/{id}", + "/api/admin/business-user-groups/{id}/members", + "/api/admin/business-user-groups/members" + ], + "handler_consumer_job": [ + "internal/routes/business_user_group.go", + "internal/handler/admin/business_user_group.go" + ], + "application_service_query": [ + "internal/application/businessusergroup/service.go" + ], + "domain_state_amount": [ + "internal/model/business_user_group.go", + "pkg/constants/business_user_group.go" + ], + "store_migration_config": [ + "internal/store/postgres/business_user_group_store.go", + "migrations/000221_add_business_user_group_and_shop_owner_import.up.sql" + ], + "verification": { + "command": "grep -nE '用户组仍有成员|目标用户组已停用|uk_business_user_group_code|IsAvailablePlatformBusinessOwner' internal/application/businessusergroup/service.go", + "literal_output": [ + "170:\t\t\treturn errors.New(errors.CodeInvalidStatus, \"用户组仍有成员,只能停用或先移走成员\")", + "202:\t\t\treturn errors.New(errors.CodeInvalidStatus, \"目标用户组已停用,不能作为成员归属目标\")", + "316:\t\tif constants.IsAvailablePlatformBusinessOwner(account.UserType, account.Status, account.DeletedAt.Valid) {", + "338:\tif strings.Contains(strings.ToLower(err.Error()), \"uk_business_user_group_code\") {" + ], + "exit_status": 0 + } + }, + { + "capability": "business-user-group", + "requirement": "店铺负责人和所属组实时推导", + "spec": "openspec/specs/business-user-group/spec.md", + "entries": [ + "/api/admin/shops", + "/api/admin/shops/{id}" + ], + "handler_consumer_job": [ + "internal/routes/shop.go", + "internal/query/shop/business_owner.go" + ], + "application_service_query": [ + "internal/query/shop/business_owner.go" + ], + "domain_state_amount": [ + "internal/model/dto/shop_dto.go", + "internal/model/business_user_group.go" + ], + "store_migration_config": [ + "migrations/000221_add_business_user_group_and_shop_owner_import.up.sql" + ], + "verification": { + "command": "grep -nE 'EXISTS \\(SELECT 1 FROM tb_business_user_group_member|ungroupedCondition' internal/query/shop/business_owner.go", + "literal_output": [ + "126:\t\t\t\"EXISTS (SELECT 1 FROM tb_business_user_group_member m WHERE m.account_id = tb_shop.business_owner_account_id AND m.deleted_at IS NULL AND m.business_user_group_id = ?)\",", + "131:\t\t\t\"EXISTS (SELECT 1 FROM tb_business_user_group_member m WHERE m.account_id = tb_shop.business_owner_account_id AND m.deleted_at IS NULL AND EXISTS (SELECT 1 FROM tb_business_user_group g WHERE g.id = m.business_user_group_id AND g.deleted_at IS NULL AND g.business_line = ?))\",", + "136:\t\tconst ungroupedCondition = \"(tb_shop.business_owner_account_id IS NULL OR NOT EXISTS (SELECT 1 FROM tb_business_user_group_member m WHERE m.account_id = tb_shop.business_owner_account_id AND m.deleted_at IS NULL))\"", + "138:\t\t\tdb = db.Where(ungroupedCondition)", + "140:\t\t\tdb = db.Where(\"NOT \" + ungroupedCondition)" + ], + "exit_status": 0 + } + }, + { + "capability": "business-user-group", + "requirement": "店铺负责人批量交接", + "spec": "openspec/specs/business-user-group/spec.md", + "entries": [ + "/api/admin/shops/business-owner/batch" + ], + "handler_consumer_job": [ + "internal/routes/business_user_group.go", + "internal/handler/admin/business_user_group.go" + ], + "application_service_query": [ + "internal/application/shop/batch_business_owner.go" + ], + "domain_state_amount": [ + "internal/model/dto/shop_dto.go" + ], + "store_migration_config": [ + "internal/infrastructure/audit/business_user_group.go" + ], + "verification": { + "command": "grep -nE 'batchBusinessOwnerFailureMessage' internal/application/shop/batch_business_owner.go", + "literal_output": [ + "102:\t\t\treturn errors.New(errors.CodeForbidden, batchBusinessOwnerFailureMessage)", + "122:\t\t\treturn errors.New(errors.CodeForbidden, batchBusinessOwnerFailureMessage)", + "203:// batchBusinessOwnerFailureMessage 复用平台维护入口的统一失败文案,不区分无权、不存在与已删除。", + "204:const batchBusinessOwnerFailureMessage = constants.PlatformManagementForbiddenMessage", + "243:\t\treturn nil, errors.New(errors.CodeForbidden, batchBusinessOwnerFailureMessage)", + "249:\t\treturn nil, errors.New(errors.CodeForbidden, batchBusinessOwnerFailureMessage)" + ], + "exit_status": 0 + } + }, + { + "capability": "business-user-group", + "requirement": "店铺负责人 CSV 导入", + "spec": "openspec/specs/business-user-group/spec.md", + "entries": [ + "/api/admin/shops/business-owner-imports", + "/api/admin/shops/business-owner-imports/{id}", + "constants.TaskTypeShopBusinessOwnerImport" + ], + "handler_consumer_job": [ + "internal/routes/business_user_group.go", + "internal/handler/admin/shop_business_owner_import.go" + ], + "application_service_query": [ + "internal/task/shop_business_owner_import.go", + "pkg/utils/encoding.go" + ], + "domain_state_amount": [ + "internal/model/shop_business_owner_import_task.go", + "pkg/constants/shop_business_owner_import.go" + ], + "store_migration_config": [ + "internal/store/postgres/shop_business_owner_import_task_store.go", + "migrations/000221_add_business_user_group_and_shop_owner_import.up.sql" + ], + "verification": { + "command": "grep -nE 'ShopBusinessOwnerImportColumns|ShopBusinessOwnerImportErrorFileFormat|ShopBusinessOwnerImportRowErrorOwnerForbidden|ShopOwnerImportAccessDescription' pkg/constants/shop_business_owner_import.go", + "literal_output": [ + "29:// ShopBusinessOwnerImportColumns 表示导入文件首行必须完全一致的固定列序。", + "30:var ShopBusinessOwnerImportColumns = []string{\"店铺编码\", \"操作类型\", \"业务员登录账号\", \"备注\"}", + "34:\t// ShopBusinessOwnerImportErrorFileFormat 表示文件格式或表头与固定列序不符。", + "35:\tShopBusinessOwnerImportErrorFileFormat = \"文件格式或表头与固定列序不符\"", + "50:\t// ShopBusinessOwnerImportRowErrorOwnerForbidden 表示清空却填写了业务员登录账号。", + "51:\tShopBusinessOwnerImportRowErrorOwnerForbidden = \"清空不得填写业务员登录账号\"", + "59:\t// ShopOwnerImportAccessDescription 表示店铺负责人导入入口的角色说明。", + "60:\tShopOwnerImportAccessDescription = \"仅超级管理员和平台账号可访问,代理与企业账号返回 403。\"" + ], + "exit_status": 0 + } } ] diff --git a/internal/application/businessusergroup/service.go b/internal/application/businessusergroup/service.go new file mode 100644 index 0000000..c493355 --- /dev/null +++ b/internal/application/businessusergroup/service.go @@ -0,0 +1,493 @@ +// Package businessusergroup 收口业务用户组、成员归属与店铺负责人批量交接的写用例。 +// 组只描述平台用户的业务分类,不改变后台角色、登录、权限或数据范围; +// 店铺所属组始终由当前负责人实时推导,因此本包不写任何店铺组字段。 +package businessusergroup + +import ( + "context" + "strconv" + "strings" + "time" + + "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/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" +) + +// Service 业务用户组与成员归属的简单写事务脚本。 +type Service struct { + db *gorm.DB + groupStore *postgres.BusinessUserGroupStore + auditWriter *audit.Writer +} + +// New 创建业务用户组事务脚本。 +func New(db *gorm.DB, groupStore *postgres.BusinessUserGroupStore, auditWriters ...*audit.Writer) *Service { + service := &Service{db: db, groupStore: groupStore} + if len(auditWriters) > 0 { + service.auditWriter = auditWriters[0] + } + return service +} + +// Create 创建业务用户组并在同一事务写入审计。 +func (s *Service) Create(ctx context.Context, request *dto.CreateBusinessUserGroupRequest) (*dto.BusinessUserGroupResponse, error) { + operatorID, err := s.requireOperator(ctx) + if err != nil { + return nil, err + } + code := strings.TrimSpace(request.Code) + name := strings.TrimSpace(request.Name) + if code == "" || name == "" { + return nil, errors.New(errors.CodeInvalidParam, "业务用户组编码与名称不能为空") + } + if !constants.IsValidBusinessLine(request.BusinessLine) { + return nil, errors.New(errors.CodeInvalidParam, "业务线取值非法") + } + group := &model.BusinessUserGroup{ + Code: code, Name: name, BusinessLine: request.BusinessLine, + SortOrder: sortValue(request.Sort), Status: statusValue(request.Enabled), + Remark: request.Remark, BaseModel: model.BaseModel{Creator: operatorID, Updater: operatorID}, + } + var response *dto.BusinessUserGroupResponse + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + store := s.groupStore.WithTx(tx) + exists, err := store.ExistsCode(ctx, code, 0) + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "校验业务用户组编码失败") + } + if exists { + return errors.New(errors.CodeInvalidParam, "业务用户组编码已存在") + } + if err := store.Create(ctx, group); err != nil { + return mapCodeConflict(err) + } + if err := s.appendGroupAudit(ctx, tx, constants.AuditActionBusinessUserGroupCreated, "创建业务用户组", group, operatorID, nil, groupSnapshot(group)); err != nil { + return err + } + response = toGroupResponse(group) + return nil + }); err != nil { + return nil, err + } + return response, nil +} + +// Update 更新业务用户组名称、业务线、排序、启停与备注;稳定编码永不允许修改。 +func (s *Service) Update(ctx context.Context, groupID uint, request *dto.UpdateBusinessUserGroupRequest) (*dto.BusinessUserGroupResponse, error) { + operatorID, err := s.requireOperator(ctx) + if err != nil { + return nil, err + } + if groupID == 0 { + return nil, errors.New(errors.CodeInvalidParam) + } + if request.BusinessLine != nil && !constants.IsValidBusinessLine(*request.BusinessLine) { + return nil, errors.New(errors.CodeInvalidParam, "业务线取值非法") + } + if request.Name != nil && strings.TrimSpace(*request.Name) == "" { + return nil, errors.New(errors.CodeInvalidParam, "业务用户组名称不能为空") + } + var response *dto.BusinessUserGroupResponse + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + store := s.groupStore.WithTx(tx) + group, err := store.LockByID(ctx, groupID) + if err != nil { + return groupLookupError(err) + } + before := groupSnapshot(group) + if request.Name != nil { + group.Name = strings.TrimSpace(*request.Name) + } + if request.BusinessLine != nil { + group.BusinessLine = *request.BusinessLine + } + if request.Sort != nil { + group.SortOrder = *request.Sort + } + // 停用保留成员关系:已有成员继续显示已停用,只是不得新增成员或作为批量目标。 + if request.Enabled != nil { + group.Status = statusValue(request.Enabled) + } + if request.Remark != nil { + group.Remark = *request.Remark + } + if err := store.Update(ctx, group, operatorID); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新业务用户组失败") + } + after := groupSnapshot(group) + // 启停是独立状态事实,与资料变更分开记录,保证审计动作可被独立检索。 + if before["status"] != after["status"] { + action, summary := constants.AuditActionBusinessUserGroupEnabled, "启用业务用户组" + if group.Status != constants.StatusEnabled { + action, summary = constants.AuditActionBusinessUserGroupDisabled, "停用业务用户组" + } + if err := s.appendGroupAudit(ctx, tx, action, summary, group, operatorID, + map[string]any{"status": before["status"]}, map[string]any{"status": after["status"]}); err != nil { + return err + } + } + if groupProfileChanged(before, after) { + if err := s.appendGroupAudit(ctx, tx, constants.AuditActionBusinessUserGroupUpdated, "更新业务用户组", group, operatorID, before, after); err != nil { + return err + } + } + response = toGroupResponse(group) + return nil + }) + if err != nil { + return nil, err + } + return response, nil +} + +// Delete 删除无成员的业务用户组;有成员时只能停用或先移走成员。 +func (s *Service) Delete(ctx context.Context, groupID uint) error { + operatorID, err := s.requireOperator(ctx) + if err != nil { + return err + } + if groupID == 0 { + return errors.New(errors.CodeInvalidParam) + } + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + store := s.groupStore.WithTx(tx) + group, err := store.LockByID(ctx, groupID) + if err != nil { + return groupLookupError(err) + } + count, err := store.CountMembers(ctx, group.ID) + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "统计业务用户组成员失败") + } + if count > 0 { + return errors.New(errors.CodeInvalidStatus, "用户组仍有成员,只能停用或先移走成员") + } + before := groupSnapshot(group) + if err := store.Delete(ctx, group.ID, operatorID); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "删除业务用户组失败") + } + return s.appendGroupAudit(ctx, tx, constants.AuditActionBusinessUserGroupDeleted, "删除业务用户组", group, operatorID, before, nil) + }) +} + +// SetMembers 把多个启用平台用户批量设置到指定启用组,直接替换每个账号的原归属。 +// 任一账号无效则整批不修改,成员前后值审计与业务事实同事务。 +func (s *Service) SetMembers(ctx context.Context, groupID uint, request *dto.SetBusinessUserGroupMembersRequest) (*dto.BusinessUserGroupMembersResult, error) { + operatorID, err := s.requireOperator(ctx) + if err != nil { + return nil, err + } + accountIDs, err := normalizeAccountIDs(request.AccountIDs) + if err != nil { + return nil, err + } + if groupID == 0 { + return nil, errors.New(errors.CodeInvalidParam) + } + result := &dto.BusinessUserGroupMembersResult{GroupID: groupID, AccountIDs: accountIDs} + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + store := s.groupStore.WithTx(tx) + group, err := store.LockByID(ctx, groupID) + if err != nil { + return groupLookupError(err) + } + if group.Status != constants.StatusEnabled { + return errors.New(errors.CodeInvalidStatus, "目标用户组已停用,不能作为成员归属目标") + } + if err := ensureEnabledPlatformAccounts(ctx, tx, accountIDs); err != nil { + return err + } + // 按 id 升序锁账号行:账号行锁保证同一账号串行化, + // 同时消除「清空时无成员行导致锁不到行」的幻读与「多账号相反顺序」的死锁。 + if err := store.LockAccountsByIDs(ctx, accountIDs); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "锁定平台用户账号失败") + } + before, err := store.MembersByAccountIDs(ctx, accountIDs) + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "读取平台用户原分组失败") + } + if err := store.ReplaceMemberGroup(ctx, accountIDs, group.ID, operatorID); err != nil { + return mapMemberWriteError(err) + } + return s.appendMemberAudits(ctx, tx, group, accountIDs, before, operatorID) + }) + if err != nil { + return nil, err + } + return result, nil +} + +// ClearMembers 清空指定启用平台用户的业务用户组归属,任一账号无效则整批不修改。 +func (s *Service) ClearMembers(ctx context.Context, request *dto.ClearBusinessUserGroupMembersRequest) (*dto.BusinessUserGroupMembersResult, error) { + operatorID, err := s.requireOperator(ctx) + if err != nil { + return nil, err + } + accountIDs, err := normalizeAccountIDs(request.AccountIDs) + if err != nil { + return nil, err + } + result := &dto.BusinessUserGroupMembersResult{AccountIDs: accountIDs} + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + store := s.groupStore.WithTx(tx) + if err := ensureEnabledPlatformAccounts(ctx, tx, accountIDs); err != nil { + return err + } + if err := store.LockAccountsByIDs(ctx, accountIDs); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "锁定平台用户账号失败") + } + before, err := store.MembersByAccountIDs(ctx, accountIDs) + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "读取平台用户原分组失败") + } + if err := store.ClearMembers(ctx, accountIDs); err != nil { + return mapMemberWriteError(err) + } + return s.appendMemberAudits(ctx, tx, nil, accountIDs, before, operatorID) + }) + if err != nil { + return nil, err + } + return result, nil +} + +// mapMemberWriteError 把成员关系写入失败收敛为稳定业务错误。 +// 并发为同一账号新增成员关系时唯一索引是最终裁决,不能把约束冲突暴露成 500。 +func mapMemberWriteError(err error) error { + if err == nil { + return nil + } + if postgres.IsAccountMemberConflict(err) { + return errors.New(errors.CodeConflict, "平台用户分组归属已被并发修改,请重试") + } + return errors.Wrap(errors.CodeDatabaseError, err, "更新平台用户分组失败") +} + +// requireOperator 校验调用者具备平台维护入口身份,并返回其账号 ID。 +func (s *Service) requireOperator(ctx context.Context) (uint, error) { + userType := middleware.GetUserTypeFromContext(ctx) + if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform { + return 0, errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage) + } + operatorID := middleware.GetUserIDFromContext(ctx) + if operatorID == 0 { + return 0, errors.New(errors.CodeUnauthorized) + } + return operatorID, nil +} + +// normalizeAccountIDs 去重并保持首次出现顺序,空集合视为非法参数。 +func normalizeAccountIDs(values []uint) ([]uint, error) { + if len(values) == 0 { + return nil, errors.New(errors.CodeInvalidParam, "账号列表不能为空") + } + seen := make(map[uint]struct{}, len(values)) + result := make([]uint, 0, len(values)) + for _, value := range values { + if value == 0 { + return nil, errors.New(errors.CodeInvalidParam, "账号ID非法") + } + if _, exists := seen[value]; exists { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + return result, nil +} + +// ensureEnabledPlatformAccounts 校验全部账号都是当前启用的平台用户,任一不满足即整批失败。 +// 账号有效性统一走共享谓词,避免各入口对「平台 + 启用 + 未软删」出现口径分叉。 +func ensureEnabledPlatformAccounts(ctx context.Context, tx *gorm.DB, accountIDs []uint) error { + var accounts []model.Account + if err := tx.WithContext(ctx).Model(&model.Account{}). + Where("id IN ?", accountIDs).Find(&accounts).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "校验平台用户失败") + } + valid := 0 + for _, account := range accounts { + if constants.IsAvailablePlatformBusinessOwner(account.UserType, account.Status, account.DeletedAt.Valid) { + valid++ + } + } + if valid != len(accountIDs) { + return errors.New(errors.CodeInvalidParam, "存在无效或非启用的平台用户账号,整批未修改") + } + return nil +} + +func groupLookupError(err error) error { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeNotFound, "业务用户组不存在") + } + return errors.Wrap(errors.CodeDatabaseError, err, "查询业务用户组失败") +} + +// mapCodeConflict 把稳定编码唯一索引冲突映射为稳定业务错误,并发创建以唯一索引为最终裁决。 +func mapCodeConflict(err error) error { + if err == nil { + return nil + } + if strings.Contains(strings.ToLower(err.Error()), "uk_business_user_group_code") { + return errors.New(errors.CodeInvalidParam, "业务用户组编码已存在") + } + return errors.Wrap(errors.CodeDatabaseError, err, "创建业务用户组失败") +} + +func sortValue(value *int64) int64 { + if value == nil { + return 0 + } + return *value +} + +func statusValue(enabled *bool) int { + if enabled == nil || *enabled { + return constants.StatusEnabled + } + return constants.StatusDisabled +} + +func toGroupResponse(group *model.BusinessUserGroup) *dto.BusinessUserGroupResponse { + return &dto.BusinessUserGroupResponse{ + ID: group.ID, Code: group.Code, Name: group.Name, + BusinessLine: group.BusinessLine, BusinessLineName: constants.GetBusinessLineName(group.BusinessLine), + Sort: group.SortOrder, Enabled: group.Status == constants.StatusEnabled, Remark: group.Remark, + CreatedAt: group.CreatedAt.Format(time.RFC3339), UpdatedAt: group.UpdatedAt.Format(time.RFC3339), + } +} + +// groupSnapshot 生成业务用户组的前后值快照,不含任何凭证或敏感信息。 +func groupSnapshot(group *model.BusinessUserGroup) map[string]any { + if group == nil { + return nil + } + return map[string]any{ + "id": group.ID, "code": group.Code, "name": group.Name, + "business_line": group.BusinessLine, "sort_order": group.SortOrder, "status": group.Status, + "remark": group.Remark, + } +} + +// groupProfileChanged 判断除启停外的可维护字段是否发生变化;编码不可修改,不参与比较。 +func groupProfileChanged(before, after map[string]any) bool { + for _, field := range []string{"name", "business_line", "sort_order", "remark"} { + if before[field] != after[field] { + return true + } + } + return false +} + +// businessUserGroupKey 返回业务用户组审计资源的稳定 Key。 +func businessUserGroupKey(group *model.BusinessUserGroup) string { + if group == nil { + return "" + } + if group.Code != "" { + return group.Code + } + return strconv.FormatUint(uint64(group.ID), 10) +} + +// businessUserGroupIdentity 返回业务用户组审计身份快照,字段必须落在注册表白名单内。 +func businessUserGroupIdentity(group *model.BusinessUserGroup) map[string]any { + if group == nil { + return nil + } + return map[string]any{ + "id": group.ID, "code": group.Code, "name": group.Name, + "business_line": group.BusinessLine, "status": group.Status, + } +} + +// appendGroupAudit 在业务事务内追加业务用户组事件。 +func (s *Service) appendGroupAudit(ctx context.Context, tx *gorm.DB, action, summary string, group *model.BusinessUserGroup, operatorID uint, before, after map[string]any) error { + if s.auditWriter == nil { + return errors.New(errors.CodeInvalidStatus, "业务用户组统一审计接缝未配置") + } + var resourceID *string + if group.ID != 0 { + value := strconv.FormatUint(uint64(group.ID), 10) + resourceID = &value + } + s.auditWriter.Append(ctx, tx, audit.AppendInput{ + ActionCode: action, Summary: summary, Result: constants.AuditResultSuccess, + Actor: audit.ActorInput{Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(operatorID), 10)}, + Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform, + Resources: []audit.ResourceInput{{ + Type: constants.AuditResourceBusinessUserGroup, ID: resourceID, + Key: businessUserGroupKey(group), DisplayName: group.Name, + Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleBusinessUserGroupTarget, + IdentitySnapshot: businessUserGroupIdentity(group), BeforeData: before, AfterData: after, + }}, + }) + return nil +} + +// appendMemberAudits 在业务事务内为每个账号追加一条成员归属事件。 +// 账号是实际被替换归属的资源,因此作为主要资源;目标组仅作引用,清空操作没有目标组。 +func (s *Service) appendMemberAudits(ctx context.Context, tx *gorm.DB, group *model.BusinessUserGroup, accountIDs []uint, before map[uint]model.BusinessUserGroupMember, operatorID uint) error { + if s.auditWriter == nil { + return errors.New(errors.CodeInvalidStatus, "业务用户组统一审计接缝未配置") + } + var accounts []model.Account + if err := tx.WithContext(ctx).Unscoped().Where("id IN ?", accountIDs).Find(&accounts).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询成员审计账号失败") + } + accountByID := make(map[uint]model.Account, len(accounts)) + for _, account := range accounts { + accountByID[account.ID] = account + } + summary := "清空平台用户业务用户组归属" + afterGroupID := any(nil) + if group != nil { + summary = "设置平台用户业务用户组归属" + afterGroupID = group.ID + } + for _, accountID := range accountIDs { + account, exists := accountByID[accountID] + if !exists { + continue + } + beforeGroupID := any(nil) + if member, ok := before[accountID]; ok { + beforeGroupID = member.BusinessUserGroupID + } + resource := audit.AccountResource(&account, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleAccountTarget) + resource.BeforeData = map[string]any{"business_user_group_id": beforeGroupID} + resource.AfterData = map[string]any{"business_user_group_id": afterGroupID} + resources := []audit.ResourceInput{resource} + if group != nil { + resources = append(resources, audit.ResourceInput{ + Type: constants.AuditResourceBusinessUserGroup, ID: optionalID(group.ID), + Key: businessUserGroupKey(group), DisplayName: group.Name, + Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleBusinessUserGroupTarget, + IdentitySnapshot: businessUserGroupIdentity(group), SortOrder: 1, + }) + } + s.auditWriter.Append(ctx, tx, audit.AppendInput{ + ActionCode: constants.AuditActionBusinessUserGroupMembersUpdated, Summary: summary, + Result: constants.AuditResultSuccess, + Actor: audit.ActorInput{Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(operatorID), 10)}, + Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform, + Resources: resources, + }) + } + return nil +} + +func optionalID(id uint) *string { + if id == 0 { + return nil + } + value := strconv.FormatUint(uint64(id), 10) + return &value +} diff --git a/internal/application/shop/batch_business_owner.go b/internal/application/shop/batch_business_owner.go new file mode 100644 index 0000000..303e105 --- /dev/null +++ b/internal/application/shop/batch_business_owner.go @@ -0,0 +1,252 @@ +package shop + +import ( + "context" + stderrors "errors" + "strconv" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "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" +) + +// BusinessOwnerBatchChange 描述一家店铺在批量交接中的负责人前后事实。 +// 账号快照用于审计引用资源,已软删账号同样保留历史事实。 +type BusinessOwnerBatchChange struct { + Shop *model.Shop + BeforeOwnerID *uint + AfterOwnerID *uint + PreviousOwner *model.Account + Owner *model.Account +} + +// BusinessOwnerBatchAudit 描述一次批量交接的批次根事实与逐店子事实。 +// Result 为空表示成功批次,由实现写批次根事件与逐店子事件; +// 非空表示业务回滚后的失败或拒绝事实,此时只写批次根事件。 +type BusinessOwnerBatchAudit struct { + BatchKey string + Operation string + Result string + OperatorID uint + Total int + Owner *model.Account + Changes []BusinessOwnerBatchChange +} + +// BusinessOwnerBatchAuditWriter 接收店铺负责人批量交接受理事务内的审计事实。 +// 接口定义在应用层,具体实现由装配注入,避免应用层依赖下游用例包。 +type BusinessOwnerBatchAuditWriter interface { + WriteBusinessOwnerBatch(ctx context.Context, tx *gorm.DB, batch BusinessOwnerBatchAudit) error +} + +// SetBatchBusinessOwnerAudit 注入批量交接的批次审计接缝。 +func (s *BatchBusinessOwnerService) SetBatchBusinessOwnerAudit(writer BusinessOwnerBatchAuditWriter) { + s.batchAudit = writer +} + +// BatchBusinessOwnerService 收口勾选店铺批量设置或清空平台业务员负责人的事务脚本。 +// 全量预校验通过后在同一事务内统一更新并逐店写审计;任一项失败整批不修改, +// 且失败文案不区分无权、不存在与已删除。 +type BatchBusinessOwnerService struct { + db *gorm.DB + batchAudit BusinessOwnerBatchAuditWriter +} + +// NewBatchBusinessOwnerService 创建店铺负责人批量交接事务脚本。 +func NewBatchBusinessOwnerService(db *gorm.DB) *BatchBusinessOwnerService { + return &BatchBusinessOwnerService{db: db} +} + +// Execute 批量设置或清空店铺负责人。 +func (s *BatchBusinessOwnerService) Execute(ctx context.Context, request *dto.BatchUpdateShopBusinessOwnerRequest) (*dto.BatchUpdateShopBusinessOwnerResult, error) { + userType := middleware.GetUserTypeFromContext(ctx) + if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform { + return nil, errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage) + } + operatorID := middleware.GetUserIDFromContext(ctx) + if operatorID == 0 { + return nil, errors.New(errors.CodeUnauthorized) + } + if !request.BusinessOwnerAccountIDSet { + return nil, errors.New(errors.CodeInvalidParam, "必须显式提交业务员归属字段,null 表示清空") + } + shopIDs, err := normalizeShopIDs(request.ShopIDs) + if err != nil { + return nil, err + } + if s.batchAudit == nil { + return nil, errors.New(errors.CodeInvalidStatus, "店铺负责人批量交接统一审计接缝未配置") + } + operation := "clear" + if request.BusinessOwnerAccountID != nil { + operation = "assign" + } + batchKey := batchEventPrefix + uuid.NewString() + + var result *dto.BatchUpdateShopBusinessOwnerResult + txErr := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + lockedShops, err := lockManageableShops(ctx, tx, shopIDs) + if err != nil { + return err + } + // 命中数不等于请求数即失败,不区分越权、不存在与已删除,避免泄露店铺存在性。 + if len(lockedShops) != len(shopIDs) { + return errors.New(errors.CodeForbidden, batchBusinessOwnerFailureMessage) + } + var owner *uint + var ownerAccount *model.Account + if request.BusinessOwnerAccountID != nil { + account, err := validateBatchBusinessOwner(ctx, tx, *request.BusinessOwnerAccountID) + if err != nil { + return err + } + ownerID := account.ID + owner, ownerAccount = &ownerID, account + } + update := tx.WithContext(ctx).Model(&model.Shop{}).Where("id IN ?", shopIDs). + Updates(map[string]any{ + "business_owner_account_id": owner, "updater": operatorID, "updated_at": time.Now(), + }) + if update.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, update.Error, "批量更新店铺负责人失败") + } + if int(update.RowsAffected) != len(shopIDs) { + return errors.New(errors.CodeForbidden, batchBusinessOwnerFailureMessage) + } + if err := s.batchAudit.WriteBusinessOwnerBatch(ctx, tx, BusinessOwnerBatchAudit{ + BatchKey: batchKey, Operation: operation, OperatorID: operatorID, + Total: len(shopIDs), Owner: ownerAccount, + Changes: collectBatchChanges(ctx, tx, lockedShops, owner, ownerAccount), + }); err != nil { + return err + } + result = &dto.BatchUpdateShopBusinessOwnerResult{ + BatchKey: batchKey, ShopCount: len(shopIDs), Cleared: owner == nil, BusinessOwnerAccountID: owner, + } + return nil + }) + if txErr != nil { + s.recordFailure(ctx, batchKey, operation, operatorID, shopIDs, txErr) + return nil, txErr + } + return result, nil +} + +// collectBatchChanges 装配逐店审计事实:锁定的店铺携带变更前负责人, +// 原负责人账号按一次批量查询载入,目标账号快照由调用方复用,避免 N+1。 +func collectBatchChanges(ctx context.Context, tx *gorm.DB, shops []*model.Shop, owner *uint, ownerAccount *model.Account) []BusinessOwnerBatchChange { + previousIDs := make([]uint, 0, len(shops)) + seen := make(map[uint]struct{}, len(shops)) + for _, shop := range shops { + if shop.BusinessOwnerAccountID == nil { + continue + } + id := *shop.BusinessOwnerAccountID + if _, exists := seen[id]; exists { + continue + } + seen[id] = struct{}{} + previousIDs = append(previousIDs, id) + } + previous := make(map[uint]*model.Account, len(previousIDs)) + if len(previousIDs) > 0 { + var accounts []*model.Account + if err := tx.WithContext(ctx).Unscoped().Where("id IN ?", previousIDs).Find(&accounts).Error; err == nil { + for _, account := range accounts { + previous[account.ID] = account + } + } + } + changes := make([]BusinessOwnerBatchChange, 0, len(shops)) + for _, shop := range shops { + change := BusinessOwnerBatchChange{Shop: shop, BeforeOwnerID: shop.BusinessOwnerAccountID, AfterOwnerID: owner, Owner: ownerAccount} + if shop.BusinessOwnerAccountID != nil { + change.PreviousOwner = previous[*shop.BusinessOwnerAccountID] + } + changes = append(changes, change) + } + return changes +} + +// recordFailure 在业务回滚后使用独立短事务记录批次失败或拒绝事实。 +// 二次写入失败不能静默丢弃,按 pkg/auditfailure 既有先例上报为关键级失败。 +func (s *BatchBusinessOwnerService) recordFailure(ctx context.Context, batchKey, operation string, operatorID uint, shopIDs []uint, originalErr error) { + writeErr := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return s.batchAudit.WriteBusinessOwnerBatch(ctx, tx, BusinessOwnerBatchAudit{ + BatchKey: batchKey, Operation: operation, Result: shopAuditFailureResult(originalErr), + OperatorID: operatorID, Total: len(shopIDs), + }) + }) + if writeErr != nil { + auditfailure.RecordSecondaryWriteFailure(constants.AuditActionShopBusinessOwnerBatchUpdated, + batchKey, "", batchKey, strconv.Itoa(errorCodeOf(originalErr)), writeErr) + } +} + +// errorCodeOf 返回稳定错误的编码文本,非稳定错误归入内部错误码。 +func errorCodeOf(err error) int { + var appErr *errors.AppError + if stderrors.As(err, &appErr) { + return appErr.Code + } + return errors.CodeInternalError +} + +// batchBusinessOwnerFailureMessage 复用平台维护入口的统一失败文案,不区分无权、不存在与已删除。 +const batchBusinessOwnerFailureMessage = constants.PlatformManagementForbiddenMessage + +// batchEventPrefix 是批次根事件标识前缀,与随机后缀共同保证稳定且不超审计列宽。 +const batchEventPrefix = "shop-owner-batch:" + +// normalizeShopIDs 去重并保持首次出现顺序,空集合视为非法参数。 +func normalizeShopIDs(values []uint) ([]uint, error) { + if len(values) == 0 { + return nil, errors.New(errors.CodeInvalidParam, "店铺ID列表不能为空") + } + seen := make(map[uint]struct{}, len(values)) + result := make([]uint, 0, len(values)) + for _, value := range values { + if value == 0 { + return nil, errors.New(errors.CodeInvalidParam, "店铺ID非法") + } + if _, exists := seen[value]; exists { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + return result, nil +} + +// lockManageableShops 在数据范围约束下按主键加行锁读取全部目标店铺。 +func lockManageableShops(ctx context.Context, tx *gorm.DB, shopIDs []uint) ([]*model.Shop, error) { + query := middleware.ApplyShopIDFilter(ctx, tx.WithContext(ctx).Model(&model.Shop{})) + var shops []*model.Shop + if err := query.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id IN ?", shopIDs).Order("id ASC").Find(&shops).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定批量交接目标店铺失败") + } + return shops, nil +} + +// validateBatchBusinessOwner 校验目标账号是当前启用的平台业务员。 +func validateBatchBusinessOwner(ctx context.Context, tx *gorm.DB, accountID uint) (*model.Account, error) { + if accountID == 0 { + return nil, errors.New(errors.CodeForbidden, batchBusinessOwnerFailureMessage) + } + var account model.Account + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "SHARE"}). + Where("id = ? AND user_type = ? AND status = ?", accountID, constants.UserTypePlatform, constants.StatusEnabled). + First(&account).Error; err != nil { + return nil, errors.New(errors.CodeForbidden, batchBusinessOwnerFailureMessage) + } + return &account, nil +} diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index f057c8d..6d84f3e 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -2,6 +2,7 @@ package bootstrap import ( agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge" + businessUserGroupApp "github.com/break/junhong_cmp_fiber/internal/application/businessusergroup" employeecollectionApp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection" merchantPaymentApp "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment" notificationApp "github.com/break/junhong_cmp_fiber/internal/application/notification" @@ -24,6 +25,7 @@ import ( agentRechargeQuery "github.com/break/junhong_cmp_fiber/internal/query/agentrecharge" assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset" auditQuery "github.com/break/junhong_cmp_fiber/internal/query/audit" + businessUserGroupQuery "github.com/break/junhong_cmp_fiber/internal/query/businessusergroup" distributionwithdrawalQuery "github.com/break/junhong_cmp_fiber/internal/query/distributionwithdrawal" employeecollectionQuery "github.com/break/junhong_cmp_fiber/internal/query/employeecollection" exchangeQuery "github.com/break/junhong_cmp_fiber/internal/query/exchange" @@ -61,6 +63,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { packageSeriesStore := postgres.NewPackageSeriesStore(deps.DB) shopSeriesAllocationStore := postgres.NewShopSeriesAllocationStore(deps.DB) deviceSimBindingStore := postgres.NewDeviceSimBindingStore(deps.DB, deps.Redis) + businessUserGroupStore := postgres.NewBusinessUserGroupStore(deps.DB) carrierStore := postgres.NewCarrierStore(deps.DB) rechargeOrderStore := postgres.NewRechargeOrderStore(deps.DB, deps.Redis) paymentStore := postgres.NewPaymentStore(deps.DB, deps.Redis) @@ -212,8 +215,20 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { handler.SetChangeCreditService(walletApp.NewChangeCreditService(deps.DB, svc.AccessAudit)) return handler }(), - ShopRole: admin.NewShopRoleHandler(svc.Shop), - AdminAuth: admin.NewAuthHandler(svc.Auth, validate), + ShopRole: admin.NewShopRoleHandler(svc.Shop), + BusinessUserGroup: func() *admin.BusinessUserGroupHandler { + handler := admin.NewBusinessUserGroupHandler( + businessUserGroupApp.New(deps.DB, businessUserGroupStore, auditInfra.NewWriter(auditInfra.NewRegistry(), nil)), + validate, + ) + handler.SetQuery(businessUserGroupQuery.NewQuery(deps.DB, businessUserGroupStore)) + batchService := shopApp.NewBatchBusinessOwnerService(deps.DB) + batchService.SetBatchBusinessOwnerAudit(auditInfra.NewWriter(auditInfra.NewRegistry(), nil)) + handler.SetBatchService(batchService) + return handler + }(), + ShopBusinessOwnerImport: admin.NewShopBusinessOwnerImportHandler(svc.ShopBusinessOwnerImport), + AdminAuth: admin.NewAuthHandler(svc.Auth, validate), ShopCommission: func() *admin.ShopCommissionHandler { handler := admin.NewShopCommissionHandler(svc.ShopCommission, validate) handler.SetFundSummaryQuery(shopQuery.NewFundSummaryQuery(deps.DB)) diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index 0fa4e5b..9eb044d 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -71,6 +71,7 @@ import ( orderPackageInvalidateSvc "github.com/break/junhong_cmp_fiber/internal/service/order_package_invalidate" pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling" refundSvc "github.com/break/junhong_cmp_fiber/internal/service/refund" + shopBusinessOwnerImportSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_business_owner_import" shopCommissionSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_commission" shopPackageBatchAllocationSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_package_batch_allocation" shopPackageBatchPricingSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_package_batch_pricing" @@ -143,6 +144,7 @@ type services struct { CustomerBinding *customerBindingSvc.Service OrderPackageInvalidate *orderPackageInvalidateSvc.Service AssetPackageBatchOrder *assetPackageBatchOrderSvc.Service + ShopBusinessOwnerImport *shopBusinessOwnerImportSvc.Service ObservationSeries cardObservationApp.BestEffortSeriesDispatcher CardObservation *cardObservationApp.Service CardObservationSeries *cardObservationApp.SeriesAttemptService @@ -510,6 +512,7 @@ func initServices(s *stores, deps *Dependencies) *services { CustomerBinding: customerBinding, OrderPackageInvalidate: orderPackageInvalidateSvc.New(s.OrderPackageInvalidateTask, deps.QueueClient, auditWriter), AssetPackageBatchOrder: assetPackageBatchOrderSvc.New(s.AssetPackageBatchOrderTask, s.Package, deps.QueueClient, auditWriter), + ShopBusinessOwnerImport: shopBusinessOwnerImportSvc.New(s.ShopBusinessOwnerImportTask, deps.QueueClient, auditWriter), ObservationSeries: observationSeries, CardObservation: cardObservationService, CardObservationSeries: cardObservationSeries, diff --git a/internal/bootstrap/stores.go b/internal/bootstrap/stores.go index 43cf911..8e26ba5 100644 --- a/internal/bootstrap/stores.go +++ b/internal/bootstrap/stores.go @@ -69,6 +69,10 @@ type stores struct { OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore // 资产套餐批量订购任务 AssetPackageBatchOrderTask *postgres.AssetPackageBatchOrderTaskStore + // 业务用户组与成员归属 + BusinessUserGroup *postgres.BusinessUserGroupStore + // 店铺负责人 CSV 导入任务 + ShopBusinessOwnerImportTask *postgres.ShopBusinessOwnerImportTaskStore // 流量系统 CardDailyUsage *postgres.CardDailyUsageStore // 资产标识符注册表 @@ -128,15 +132,17 @@ func initStores(deps *Dependencies) *stores { AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis), AgentRecharge: postgres.NewAgentRechargeStore(deps.DB, deps.Redis), // 资产钱包系统 - AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis), - AssetWalletTransaction: postgres.NewAssetWalletTransactionStore(deps.DB, deps.Redis), - RechargeOrder: postgres.NewRechargeOrderStore(deps.DB, deps.Redis), - Payment: postgres.NewPaymentStore(deps.DB, deps.Redis), - WechatConfig: postgres.NewWechatConfigStore(deps.DB, deps.Redis), - RefundRequest: postgres.NewRefundStore(deps.DB), - CardDailyUsage: postgres.NewCardDailyUsageStore(deps.DB), - AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB), - OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB), - AssetPackageBatchOrderTask: postgres.NewAssetPackageBatchOrderTaskStore(deps.DB), + AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis), + AssetWalletTransaction: postgres.NewAssetWalletTransactionStore(deps.DB, deps.Redis), + RechargeOrder: postgres.NewRechargeOrderStore(deps.DB, deps.Redis), + Payment: postgres.NewPaymentStore(deps.DB, deps.Redis), + WechatConfig: postgres.NewWechatConfigStore(deps.DB, deps.Redis), + RefundRequest: postgres.NewRefundStore(deps.DB), + CardDailyUsage: postgres.NewCardDailyUsageStore(deps.DB), + AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB), + OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB), + AssetPackageBatchOrderTask: postgres.NewAssetPackageBatchOrderTaskStore(deps.DB), + BusinessUserGroup: postgres.NewBusinessUserGroupStore(deps.DB), + ShopBusinessOwnerImportTask: postgres.NewShopBusinessOwnerImportTaskStore(deps.DB), } } diff --git a/internal/bootstrap/types.go b/internal/bootstrap/types.go index 2b61a9d..ddaef9e 100644 --- a/internal/bootstrap/types.go +++ b/internal/bootstrap/types.go @@ -76,6 +76,8 @@ type Handlers struct { Refund *admin.RefundHandler OrderPackageInvalidate *admin.OrderPackageInvalidateHandler AssetPackageBatchOrder *admin.AssetPackageBatchOrderHandler + BusinessUserGroup *admin.BusinessUserGroupHandler + ShopBusinessOwnerImport *admin.ShopBusinessOwnerImportHandler ClientWechat *app.ClientWechatHandler SuperAdmin *admin.SuperAdminHandler SystemConfig *admin.SystemConfigHandler diff --git a/internal/bootstrap/worker_stores.go b/internal/bootstrap/worker_stores.go index 0276b0a..f420a60 100644 --- a/internal/bootstrap/worker_stores.go +++ b/internal/bootstrap/worker_stores.go @@ -6,105 +6,108 @@ import ( ) type workerStores struct { - AssetAllocationRecord *postgres.AssetAllocationRecordStore - IotCardImportTask *postgres.IotCardImportTaskStore - IotCard *postgres.IotCardStore - DeviceImportTask *postgres.DeviceImportTaskStore - ExportTask *postgres.ExportTaskStore - ExportShardTask *postgres.ExportShardTaskStore - Device *postgres.DeviceStore - DeviceSimBinding *postgres.DeviceSimBindingStore - ShopSeriesCommissionStats *postgres.ShopSeriesCommissionStatsStore - ShopPackageAllocation *postgres.ShopPackageAllocationStore - CommissionRecord *postgres.CommissionRecordStore - Shop *postgres.ShopStore - ShopSeriesAllocation *postgres.ShopSeriesAllocationStore - PackageSeries *postgres.PackageSeriesStore - Order *postgres.OrderStore - OrderItem *postgres.OrderItemStore - Package *postgres.PackageStore - PackageUsage *postgres.PackageUsageStore - PackageUsageDailyRecord *postgres.PackageUsageDailyRecordStore - PollingAlertRule *postgres.PollingAlertRuleStore - PollingAlertHistory *postgres.PollingAlertHistoryStore - DataCleanupConfig *postgres.DataCleanupConfigStore - DataCleanupLog *postgres.DataCleanupLogStore - AgentWallet *postgres.AgentWalletStore - AgentWalletTransaction *postgres.AgentWalletTransactionStore - AssetWallet *postgres.AssetWalletStore - AssetIdentifier *postgres.AssetIdentifierStore - PersonalCustomer *postgres.PersonalCustomerStore - PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore - OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore - AssetPackageBatchOrderTask *postgres.AssetPackageBatchOrderTaskStore + AssetAllocationRecord *postgres.AssetAllocationRecordStore + IotCardImportTask *postgres.IotCardImportTaskStore + IotCard *postgres.IotCardStore + DeviceImportTask *postgres.DeviceImportTaskStore + ExportTask *postgres.ExportTaskStore + ExportShardTask *postgres.ExportShardTaskStore + Device *postgres.DeviceStore + DeviceSimBinding *postgres.DeviceSimBindingStore + ShopSeriesCommissionStats *postgres.ShopSeriesCommissionStatsStore + ShopPackageAllocation *postgres.ShopPackageAllocationStore + CommissionRecord *postgres.CommissionRecordStore + Shop *postgres.ShopStore + ShopSeriesAllocation *postgres.ShopSeriesAllocationStore + PackageSeries *postgres.PackageSeriesStore + Order *postgres.OrderStore + OrderItem *postgres.OrderItemStore + Package *postgres.PackageStore + PackageUsage *postgres.PackageUsageStore + PackageUsageDailyRecord *postgres.PackageUsageDailyRecordStore + PollingAlertRule *postgres.PollingAlertRuleStore + PollingAlertHistory *postgres.PollingAlertHistoryStore + DataCleanupConfig *postgres.DataCleanupConfigStore + DataCleanupLog *postgres.DataCleanupLogStore + AgentWallet *postgres.AgentWalletStore + AgentWalletTransaction *postgres.AgentWalletTransactionStore + AssetWallet *postgres.AssetWalletStore + AssetIdentifier *postgres.AssetIdentifierStore + PersonalCustomer *postgres.PersonalCustomerStore + PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore + OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore + AssetPackageBatchOrderTask *postgres.AssetPackageBatchOrderTaskStore + ShopBusinessOwnerImportTask *postgres.ShopBusinessOwnerImportTaskStore } func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores { stores := &workerStores{ - AssetAllocationRecord: postgres.NewAssetAllocationRecordStore(deps.DB, deps.Redis), - IotCardImportTask: postgres.NewIotCardImportTaskStore(deps.DB, deps.Redis), - IotCard: postgres.NewIotCardStore(deps.DB, deps.Redis), - DeviceImportTask: postgres.NewDeviceImportTaskStore(deps.DB, deps.Redis), - ExportTask: postgres.NewExportTaskStore(deps.DB, deps.Redis), - ExportShardTask: postgres.NewExportShardTaskStore(deps.DB, deps.Redis), - Device: postgres.NewDeviceStore(deps.DB, deps.Redis), - DeviceSimBinding: postgres.NewDeviceSimBindingStore(deps.DB, deps.Redis), - ShopSeriesCommissionStats: postgres.NewShopSeriesCommissionStatsStore(deps.DB), - ShopPackageAllocation: postgres.NewShopPackageAllocationStore(deps.DB), - CommissionRecord: postgres.NewCommissionRecordStore(deps.DB, deps.Redis), - Shop: postgres.NewShopStore(deps.DB, deps.Redis), - ShopSeriesAllocation: postgres.NewShopSeriesAllocationStore(deps.DB), - PackageSeries: postgres.NewPackageSeriesStore(deps.DB), - Order: postgres.NewOrderStore(deps.DB, deps.Redis), - OrderItem: postgres.NewOrderItemStore(deps.DB, deps.Redis), - Package: postgres.NewPackageStore(deps.DB), - PackageUsage: postgres.NewPackageUsageStore(deps.DB, deps.Redis), - PackageUsageDailyRecord: postgres.NewPackageUsageDailyRecordStore(deps.DB, deps.Redis), - PollingAlertRule: postgres.NewPollingAlertRuleStore(deps.DB), - PollingAlertHistory: postgres.NewPollingAlertHistoryStore(deps.DB), - DataCleanupConfig: postgres.NewDataCleanupConfigStore(deps.DB), - DataCleanupLog: postgres.NewDataCleanupLogStore(deps.DB), - AgentWallet: postgres.NewAgentWalletStore(deps.DB, deps.Redis), - AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis), - AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis), - AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB), - PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis), - PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB), - OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB), - AssetPackageBatchOrderTask: postgres.NewAssetPackageBatchOrderTaskStore(deps.DB), + AssetAllocationRecord: postgres.NewAssetAllocationRecordStore(deps.DB, deps.Redis), + IotCardImportTask: postgres.NewIotCardImportTaskStore(deps.DB, deps.Redis), + IotCard: postgres.NewIotCardStore(deps.DB, deps.Redis), + DeviceImportTask: postgres.NewDeviceImportTaskStore(deps.DB, deps.Redis), + ExportTask: postgres.NewExportTaskStore(deps.DB, deps.Redis), + ExportShardTask: postgres.NewExportShardTaskStore(deps.DB, deps.Redis), + Device: postgres.NewDeviceStore(deps.DB, deps.Redis), + DeviceSimBinding: postgres.NewDeviceSimBindingStore(deps.DB, deps.Redis), + ShopSeriesCommissionStats: postgres.NewShopSeriesCommissionStatsStore(deps.DB), + ShopPackageAllocation: postgres.NewShopPackageAllocationStore(deps.DB), + CommissionRecord: postgres.NewCommissionRecordStore(deps.DB, deps.Redis), + Shop: postgres.NewShopStore(deps.DB, deps.Redis), + ShopSeriesAllocation: postgres.NewShopSeriesAllocationStore(deps.DB), + PackageSeries: postgres.NewPackageSeriesStore(deps.DB), + Order: postgres.NewOrderStore(deps.DB, deps.Redis), + OrderItem: postgres.NewOrderItemStore(deps.DB, deps.Redis), + Package: postgres.NewPackageStore(deps.DB), + PackageUsage: postgres.NewPackageUsageStore(deps.DB, deps.Redis), + PackageUsageDailyRecord: postgres.NewPackageUsageDailyRecordStore(deps.DB, deps.Redis), + PollingAlertRule: postgres.NewPollingAlertRuleStore(deps.DB), + PollingAlertHistory: postgres.NewPollingAlertHistoryStore(deps.DB), + DataCleanupConfig: postgres.NewDataCleanupConfigStore(deps.DB), + DataCleanupLog: postgres.NewDataCleanupLogStore(deps.DB), + AgentWallet: postgres.NewAgentWalletStore(deps.DB, deps.Redis), + AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis), + AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis), + AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB), + PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis), + PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB), + OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB), + AssetPackageBatchOrderTask: postgres.NewAssetPackageBatchOrderTaskStore(deps.DB), + ShopBusinessOwnerImportTask: postgres.NewShopBusinessOwnerImportTaskStore(deps.DB), } return &queue.WorkerStores{ - AssetAllocationRecord: stores.AssetAllocationRecord, - IotCardImportTask: stores.IotCardImportTask, - IotCard: stores.IotCard, - DeviceImportTask: stores.DeviceImportTask, - ExportTask: stores.ExportTask, - ExportShardTask: stores.ExportShardTask, - Device: stores.Device, - DeviceSimBinding: stores.DeviceSimBinding, - ShopSeriesCommissionStats: stores.ShopSeriesCommissionStats, - ShopPackageAllocation: stores.ShopPackageAllocation, - CommissionRecord: stores.CommissionRecord, - Shop: stores.Shop, - ShopSeriesAllocation: stores.ShopSeriesAllocation, - PackageSeries: stores.PackageSeries, - Order: stores.Order, - OrderItem: stores.OrderItem, - Package: stores.Package, - PackageUsage: stores.PackageUsage, - PackageUsageDailyRecord: stores.PackageUsageDailyRecord, - PollingAlertRule: stores.PollingAlertRule, - PollingAlertHistory: stores.PollingAlertHistory, - DataCleanupConfig: stores.DataCleanupConfig, - DataCleanupLog: stores.DataCleanupLog, - AgentWallet: stores.AgentWallet, - AgentWalletTransaction: stores.AgentWalletTransaction, - AssetWallet: stores.AssetWallet, - AssetIdentifier: stores.AssetIdentifier, - PersonalCustomer: stores.PersonalCustomer, - PersonalCustomerPhone: stores.PersonalCustomerPhone, - OrderPackageInvalidateTask: stores.OrderPackageInvalidateTask, - AssetPackageBatchOrderTask: stores.AssetPackageBatchOrderTask, + AssetAllocationRecord: stores.AssetAllocationRecord, + IotCardImportTask: stores.IotCardImportTask, + IotCard: stores.IotCard, + DeviceImportTask: stores.DeviceImportTask, + ExportTask: stores.ExportTask, + ExportShardTask: stores.ExportShardTask, + Device: stores.Device, + DeviceSimBinding: stores.DeviceSimBinding, + ShopSeriesCommissionStats: stores.ShopSeriesCommissionStats, + ShopPackageAllocation: stores.ShopPackageAllocation, + CommissionRecord: stores.CommissionRecord, + Shop: stores.Shop, + ShopSeriesAllocation: stores.ShopSeriesAllocation, + PackageSeries: stores.PackageSeries, + Order: stores.Order, + OrderItem: stores.OrderItem, + Package: stores.Package, + PackageUsage: stores.PackageUsage, + PackageUsageDailyRecord: stores.PackageUsageDailyRecord, + PollingAlertRule: stores.PollingAlertRule, + PollingAlertHistory: stores.PollingAlertHistory, + DataCleanupConfig: stores.DataCleanupConfig, + DataCleanupLog: stores.DataCleanupLog, + AgentWallet: stores.AgentWallet, + AgentWalletTransaction: stores.AgentWalletTransaction, + AssetWallet: stores.AssetWallet, + AssetIdentifier: stores.AssetIdentifier, + PersonalCustomer: stores.PersonalCustomer, + PersonalCustomerPhone: stores.PersonalCustomerPhone, + OrderPackageInvalidateTask: stores.OrderPackageInvalidateTask, + AssetPackageBatchOrderTask: stores.AssetPackageBatchOrderTask, + ShopBusinessOwnerImportTask: stores.ShopBusinessOwnerImportTask, } } diff --git a/internal/handler/admin/business_user_group.go b/internal/handler/admin/business_user_group.go new file mode 100644 index 0000000..111f362 --- /dev/null +++ b/internal/handler/admin/business_user_group.go @@ -0,0 +1,231 @@ +package admin + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + + businessusergroupApp "github.com/break/junhong_cmp_fiber/internal/application/businessusergroup" + shopApp "github.com/break/junhong_cmp_fiber/internal/application/shop" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + businessusergroupQuery "github.com/break/junhong_cmp_fiber/internal/query/businessusergroup" + "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/response" +) + +// BusinessUserGroupHandler 业务用户组与店铺负责人批量交接处理器。 +type BusinessUserGroupHandler struct { + service *businessusergroupApp.Service + query *businessusergroupQuery.Query + batchService *shopApp.BatchBusinessOwnerService + validator *validator.Validate +} + +// NewBusinessUserGroupHandler 创建业务用户组处理器。 +func NewBusinessUserGroupHandler(service *businessusergroupApp.Service, validator *validator.Validate) *BusinessUserGroupHandler { + return &BusinessUserGroupHandler{service: service, validator: validator} +} + +// SetQuery 注入业务用户组读取投影。 +func (h *BusinessUserGroupHandler) SetQuery(query *businessusergroupQuery.Query) { + h.query = query +} + +// SetBatchService 注入店铺负责人批量交接事务脚本。 +func (h *BusinessUserGroupHandler) SetBatchService(service *shopApp.BatchBusinessOwnerService) { + h.batchService = service +} + +// Create 创建业务用户组。 +// POST /api/admin/business-user-groups +func (h *BusinessUserGroupHandler) Create(c *fiber.Ctx) error { + if err := requirePlatformManagement(c); err != nil { + return err + } + var request dto.CreateBusinessUserGroupRequest + if err := c.BodyParser(&request); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if err := h.validate(&request); err != nil { + return errors.New(errors.CodeInvalidParam) + } + result, err := h.service.Create(c.UserContext(), &request) + if err != nil { + return err + } + return response.Success(c, result) +} + +// List 查询业务用户组列表。 +// GET /api/admin/business-user-groups +func (h *BusinessUserGroupHandler) List(c *fiber.Ctx) error { + if err := requirePlatformManagement(c); err != nil { + return err + } + var request dto.BusinessUserGroupListRequest + if err := c.QueryParser(&request); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if err := h.validate(&request); err != nil { + return errors.New(errors.CodeInvalidParam) + } + if h.query == nil { + return errors.New(errors.CodeInternalError, "业务用户组查询尚未配置") + } + result, err := h.query.List(c.UserContext(), request) + if err != nil { + return err + } + return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size) +} + +// Detail 查询业务用户组详情。 +// GET /api/admin/business-user-groups/:id +func (h *BusinessUserGroupHandler) Detail(c *fiber.Ctx) error { + if err := requirePlatformManagement(c); err != nil { + return err + } + id, err := pathID(c) + if err != nil { + return err + } + if h.query == nil { + return errors.New(errors.CodeInternalError, "业务用户组查询尚未配置") + } + result, err := h.query.Detail(c.UserContext(), id) + if err != nil { + return err + } + return response.Success(c, result) +} + +// Update 更新业务用户组的名称、业务线、排序、启停与备注;编码不可修改。 +// PUT /api/admin/business-user-groups/:id +func (h *BusinessUserGroupHandler) Update(c *fiber.Ctx) error { + if err := requirePlatformManagement(c); err != nil { + return err + } + id, err := pathID(c) + if err != nil { + return err + } + var request dto.UpdateBusinessUserGroupRequest + if err := c.BodyParser(&request); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if err := h.validate(&request); err != nil { + return errors.New(errors.CodeInvalidParam) + } + result, err := h.service.Update(c.UserContext(), id, &request) + if err != nil { + return err + } + return response.Success(c, result) +} + +// Delete 二次确认删除无成员的业务用户组。 +// DELETE /api/admin/business-user-groups/:id +func (h *BusinessUserGroupHandler) Delete(c *fiber.Ctx) error { + if err := requirePlatformManagement(c); err != nil { + return err + } + id, err := pathID(c) + if err != nil { + return err + } + var request dto.BusinessUserGroupDeleteRequest + if err := c.BodyParser(&request); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if !request.Confirm { + return errors.New(errors.CodeInvalidParam, "删除业务用户组必须二次确认") + } + if err := h.service.Delete(c.UserContext(), id); err != nil { + return err + } + return response.Success(c, nil) +} + +// SetMembers 批量设置平台用户的业务用户组归属,直接替换原归属。 +// PUT /api/admin/business-user-groups/:id/members +func (h *BusinessUserGroupHandler) SetMembers(c *fiber.Ctx) error { + if err := requirePlatformManagement(c); err != nil { + return err + } + id, err := pathID(c) + if err != nil { + return err + } + var request dto.SetBusinessUserGroupMembersRequest + if err := c.BodyParser(&request); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if err := h.validate(&request); err != nil { + return errors.New(errors.CodeInvalidParam) + } + result, err := h.service.SetMembers(c.UserContext(), id, &request) + if err != nil { + return err + } + return response.Success(c, result) +} + +// ClearMembers 批量清空平台用户的业务用户组归属。 +// DELETE /api/admin/business-user-groups/members +func (h *BusinessUserGroupHandler) ClearMembers(c *fiber.Ctx) error { + if err := requirePlatformManagement(c); err != nil { + return err + } + var request dto.ClearBusinessUserGroupMembersRequest + if err := c.BodyParser(&request); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if err := h.validate(&request); err != nil { + return errors.New(errors.CodeInvalidParam) + } + result, err := h.service.ClearMembers(c.UserContext(), &request) + if err != nil { + return err + } + return response.Success(c, result) +} + +// BatchUpdateShopBusinessOwner 勾选店铺批量设置或清空平台业务员负责人。 +// PUT /api/admin/shops/business-owner/batch +func (h *BusinessUserGroupHandler) BatchUpdateShopBusinessOwner(c *fiber.Ctx) error { + if err := requirePlatformManagement(c); err != nil { + return err + } + var request dto.BatchUpdateShopBusinessOwnerRequest + if err := c.BodyParser(&request); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if err := h.validate(&request); err != nil { + return errors.New(errors.CodeInvalidParam) + } + if h.batchService == nil { + return errors.New(errors.CodeInternalError, "店铺负责人批量交接服务尚未配置") + } + result, err := h.batchService.Execute(c.UserContext(), &request) + if err != nil { + return err + } + return response.Success(c, result) +} + +func (h *BusinessUserGroupHandler) validate(request any) error { + if h.validator == nil { + return errors.New(errors.CodeInternalError) + } + return h.validator.Struct(request) +} + +// requirePlatformManagement 校验调用者仅限超级管理员与平台账号,代理与企业统一返回 403。 +func requirePlatformManagement(c *fiber.Ctx) error { + userType := middleware.GetUserTypeFromContext(c.UserContext()) + if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform { + return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage) + } + return nil +} diff --git a/internal/handler/admin/shop_business_owner_import.go b/internal/handler/admin/shop_business_owner_import.go new file mode 100644 index 0000000..c4d60e0 --- /dev/null +++ b/internal/handler/admin/shop_business_owner_import.go @@ -0,0 +1,71 @@ +package admin + +import ( + "github.com/gofiber/fiber/v2" + + "github.com/break/junhong_cmp_fiber/internal/model/dto" + importService "github.com/break/junhong_cmp_fiber/internal/service/shop_business_owner_import" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/response" +) + +// ShopBusinessOwnerImportHandler 店铺负责人 CSV 导入任务处理器。 +type ShopBusinessOwnerImportHandler struct { + service *importService.Service +} + +// NewShopBusinessOwnerImportHandler 创建店铺负责人导入任务处理器。 +func NewShopBusinessOwnerImportHandler(service *importService.Service) *ShopBusinessOwnerImportHandler { + return &ShopBusinessOwnerImportHandler{service: service} +} + +// Create 创建店铺负责人 CSV 导入任务。 +// POST /api/admin/shops/business-owner-imports +func (h *ShopBusinessOwnerImportHandler) Create(c *fiber.Ctx) error { + if err := requirePlatformManagement(c); err != nil { + return err + } + var request dto.CreateShopBusinessOwnerImportRequest + if err := c.BodyParser(&request); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + result, err := h.service.Create(c.UserContext(), &request) + if err != nil { + return err + } + return response.Success(c, result) +} + +// List 查询店铺负责人导入任务列表。 +// GET /api/admin/shops/business-owner-imports +func (h *ShopBusinessOwnerImportHandler) List(c *fiber.Ctx) error { + if err := requirePlatformManagement(c); err != nil { + return err + } + var request dto.ListShopBusinessOwnerImportRequest + if err := c.QueryParser(&request); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + result, err := h.service.List(c.UserContext(), &request) + if err != nil { + return err + } + return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size) +} + +// Detail 查询店铺负责人导入任务详情与逐行结果。 +// GET /api/admin/shops/business-owner-imports/:id +func (h *ShopBusinessOwnerImportHandler) Detail(c *fiber.Ctx) error { + if err := requirePlatformManagement(c); err != nil { + return err + } + id, err := pathID(c) + if err != nil { + return err + } + result, err := h.service.GetByID(c.UserContext(), id) + if err != nil { + return err + } + return response.Success(c, result) +} diff --git a/internal/infrastructure/audit/business_user_group.go b/internal/infrastructure/audit/business_user_group.go new file mode 100644 index 0000000..611d104 --- /dev/null +++ b/internal/infrastructure/audit/business_user_group.go @@ -0,0 +1,88 @@ +package audit + +import ( + "context" + "strconv" + + "github.com/google/uuid" + "gorm.io/gorm" + + shopapp "github.com/break/junhong_cmp_fiber/internal/application/shop" + "github.com/break/junhong_cmp_fiber/pkg/constants" + pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// WriteBusinessOwnerBatch 将店铺负责人批量交接写为批次根事件与逐店子事件。 +// 成功批次写平台作用域根事件(承载批次汇总)+ 店铺作用域子事件; +// 业务回滚后的失败或拒绝只写根事件,由调用方在独立短事务内提交。 +func (w *Writer) WriteBusinessOwnerBatch(ctx context.Context, tx *gorm.DB, batch shopapp.BusinessOwnerBatchAudit) error { + if w == nil || w.registry == nil || tx == nil { + return pkgerrors.New(pkgerrors.CodeInvalidStatus, "店铺负责人批量交接审计 Writer 未正确配置") + } + if batch.BatchKey == "" || batch.OperatorID == 0 || batch.Total <= 0 { + return pkgerrors.New(pkgerrors.CodeInvalidParam, "店铺负责人批量交接审计事实不完整") + } + root := AppendInput{ + EventID: batch.BatchKey, ActionCode: constants.AuditActionShopBusinessOwnerBatchUpdated, + Summary: "批量交接店铺负责人", ScopeType: constants.AuditScopePlatform, + Result: constants.AuditResultSuccess, BatchTotal: batch.Total, SuccessCount: len(batch.Changes), + Actor: ActorInput{Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(batch.OperatorID), 10)}, + Source: constants.AuditSourceAdminAPI, + Metadata: map[string]any{ + "operation": batch.Operation, "shop_count": batch.Total, + }, + Resources: []ResourceInput{{ + Type: constants.AuditResourceShopBusinessOwnerBatch, Key: batch.BatchKey, + DisplayName: "店铺负责人批量交接", + Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleShopBusinessOwnerBatch, + IdentitySnapshot: map[string]any{ + "batch_key": batch.BatchKey, "operation": batch.Operation, "shop_count": batch.Total, + }, + }}, + } + if batch.Owner != nil { + root.Metadata["business_owner_account_id"] = batch.Owner.ID + resource := AccountResource(batch.Owner, constants.AuditResourceRelationReference, constants.AuditResourceRoleShopBusinessOwner) + resource.SortOrder = 1 + root.Resources = append(root.Resources, resource) + } + if batch.Result != "" { + root.Result = batch.Result + root.SuccessCount = 0 + root.FailCount = batch.Total + return w.Append(ctx, tx, root) + } + children := make([]AppendInput, 0, len(batch.Changes)) + for _, change := range batch.Changes { + if change.Shop == nil || change.Shop.ID == 0 { + return pkgerrors.New(pkgerrors.CodeInvalidParam, "店铺负责人批量交接审计资源不完整") + } + shopResource := ShopResource(change.Shop, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleShopTarget) + shopResource.BeforeData = map[string]any{"business_owner_account_id": change.BeforeOwnerID} + shopResource.AfterData = map[string]any{"business_owner_account_id": change.AfterOwnerID} + shopResource.SubjectVisibility = constants.AuditSubjectResult + shopResource.SubjectSummary = "店铺业务员归属已更新" + resources := []ResourceInput{shopResource} + if change.PreviousOwner != nil { + resource := AccountResource(change.PreviousOwner, constants.AuditResourceRelationReference, constants.AuditResourceRoleShopPreviousBusinessOwner) + resource.BeforeData = map[string]any{"assigned": true} + resource.AfterData = map[string]any{"assigned": false} + resource.SortOrder = 1 + resources = append(resources, resource) + } + if change.Owner != nil { + resource := AccountResource(change.Owner, constants.AuditResourceRelationReference, constants.AuditResourceRoleShopBusinessOwner) + resource.BeforeData = map[string]any{"assigned": false} + resource.AfterData = map[string]any{"assigned": true} + resource.SortOrder = 2 + resources = append(resources, resource) + } + children = append(children, AppendInput{ + EventID: "evt_" + uuid.NewString(), + ActionCode: constants.AuditActionShopBusinessOwnerUpdated, Summary: "批量更新店铺业务员归属", + ScopeType: constants.AuditScopeShop, ScopeID: strconv.FormatUint(uint64(change.Shop.ID), 10), + Result: constants.AuditResultSuccess, Resources: resources, + }) + } + return w.AppendBatch(ctx, tx, BatchInput{Root: root, Children: children}) +} diff --git a/internal/infrastructure/audit/registry.go b/internal/infrastructure/audit/registry.go index a28298f..637a984 100644 --- a/internal/infrastructure/audit/registry.go +++ b/internal/infrastructure/audit/registry.go @@ -208,6 +208,16 @@ func NewRegistry() *Registry { orderPackageInvalidateTaskCreated := taskAction(constants.AuditActionOrderPackageInvalidateTaskCreated, "创建订单套餐批量失效任务", constants.AuditResourceOrderPackageInvalidateTask, constants.AuditActorAccount, constants.AuditSourceAdminAPI) orderPackageInvalidateTaskCompleted := taskAction(constants.AuditActionOrderPackageInvalidateTaskCompleted, "完成订单套餐批量失效任务", constants.AuditResourceOrderPackageInvalidateTask, constants.AuditActorSystemTask, constants.AuditSourceWorker) orderPackageInvalidateItem := taskAction(constants.AuditActionOrderPackageInvalidateItem, "失效订单套餐权益", constants.AuditResourceOrder, constants.AuditActorSystemTask, constants.AuditSourceWorker) + businessUserGroupCreated := businessUserGroupAction(constants.AuditActionBusinessUserGroupCreated, "创建业务用户组", constants.AuditRiskNormal, constants.AuditResourceBusinessUserGroup) + businessUserGroupUpdated := businessUserGroupAction(constants.AuditActionBusinessUserGroupUpdated, "更新业务用户组", constants.AuditRiskNormal, constants.AuditResourceBusinessUserGroup) + businessUserGroupEnabled := businessUserGroupAction(constants.AuditActionBusinessUserGroupEnabled, "启用业务用户组", constants.AuditRiskNormal, constants.AuditResourceBusinessUserGroup) + businessUserGroupDisabled := businessUserGroupAction(constants.AuditActionBusinessUserGroupDisabled, "停用业务用户组", constants.AuditRiskNormal, constants.AuditResourceBusinessUserGroup) + businessUserGroupDeleted := businessUserGroupAction(constants.AuditActionBusinessUserGroupDeleted, "删除业务用户组", constants.AuditRiskHigh, constants.AuditResourceBusinessUserGroup) + businessUserGroupMembersUpdated := businessUserGroupAction(constants.AuditActionBusinessUserGroupMembersUpdated, "批量维护业务用户组成员", constants.AuditRiskNormal, constants.AuditResourceAccount) + shopBusinessOwnerBatchUpdated := batchRootAction(constants.AuditActionShopBusinessOwnerBatchUpdated, "批量交接店铺负责人", constants.AuditResourceShopBusinessOwnerBatch) + shopBusinessOwnerImported := taskAction(constants.AuditActionShopBusinessOwnerImported, "导入变更店铺负责人", constants.AuditResourceShop, constants.AuditActorSystemTask, constants.AuditSourceWorker) + shopBusinessOwnerImportTaskCreated := taskAction(constants.AuditActionShopBusinessOwnerImportTaskCreated, "创建店铺负责人导入任务", constants.AuditResourceShopBusinessOwnerImportTask, constants.AuditActorAccount, constants.AuditSourceAdminAPI) + shopBusinessOwnerImportTaskCompleted := taskAction(constants.AuditActionShopBusinessOwnerImportTaskCompleted, "完成店铺负责人导入任务", constants.AuditResourceShopBusinessOwnerImportTask, constants.AuditActorSystemTask, constants.AuditSourceWorker) exportTaskCreated := taskAction(constants.AuditActionExportTaskCreated, "创建业务导出任务", constants.AuditResourceExportTask, constants.AuditActorAccount, constants.AuditSourceAdminAPI) exportTaskCancelled := taskAction(constants.AuditActionExportTaskCancelled, "取消业务导出任务", constants.AuditResourceExportTask, constants.AuditActorAccount, constants.AuditSourceAdminAPI) notificationDelivered := notificationAction(constants.AuditActionNotificationDelivered, "生成站内通知", constants.AuditResourceNotification, constants.AuditActorSystemTask, constants.AuditSourceWorker) @@ -554,6 +564,16 @@ func NewRegistry() *Registry { constants.AuditActionOrderPackageInvalidateTaskCreated: orderPackageInvalidateTaskCreated, constants.AuditActionOrderPackageInvalidateTaskCompleted: orderPackageInvalidateTaskCompleted, constants.AuditActionOrderPackageInvalidateItem: orderPackageInvalidateItem, + constants.AuditActionBusinessUserGroupCreated: businessUserGroupCreated, + constants.AuditActionBusinessUserGroupUpdated: businessUserGroupUpdated, + constants.AuditActionBusinessUserGroupEnabled: businessUserGroupEnabled, + constants.AuditActionBusinessUserGroupDisabled: businessUserGroupDisabled, + constants.AuditActionBusinessUserGroupDeleted: businessUserGroupDeleted, + constants.AuditActionBusinessUserGroupMembersUpdated: businessUserGroupMembersUpdated, + constants.AuditActionShopBusinessOwnerBatchUpdated: shopBusinessOwnerBatchUpdated, + constants.AuditActionShopBusinessOwnerImported: shopBusinessOwnerImported, + constants.AuditActionShopBusinessOwnerImportTaskCreated: shopBusinessOwnerImportTaskCreated, + constants.AuditActionShopBusinessOwnerImportTaskCompleted: shopBusinessOwnerImportTaskCompleted, constants.AuditActionExportTaskCreated: exportTaskCreated, constants.AuditActionExportTaskCancelled: exportTaskCancelled, constants.AuditActionNotificationDelivered: notificationDelivered, @@ -760,6 +780,18 @@ func NewRegistry() *Registry { Type: constants.AuditResourceOrderPackageInvalidateTask, Name: "订单套餐批量失效任务", IdentityFields: []string{"id", "task_no", "file_name"}, }, + constants.AuditResourceShopBusinessOwnerImportTask: { + Type: constants.AuditResourceShopBusinessOwnerImportTask, Name: "店铺负责人导入任务", + IdentityFields: []string{"id", "task_no", "file_name"}, + }, + constants.AuditResourceBusinessUserGroup: { + Type: constants.AuditResourceBusinessUserGroup, Name: "业务用户组", + IdentityFields: []string{"id", "code", "name", "business_line", "status"}, + }, + constants.AuditResourceShopBusinessOwnerBatch: { + Type: constants.AuditResourceShopBusinessOwnerBatch, Name: "店铺负责人批量交接批次", + IdentityFields: []string{"batch_key", "operation", "shop_count"}, + }, constants.AuditResourceExportTask: { Type: constants.AuditResourceExportTask, Name: "业务导出任务", IdentityFields: []string{"id", "task_no", "scene", "format", "creator_user_id", "creator_user_type", "creator_shop_id", "creator_enterprise_id", "scope_shop_ids"}, @@ -1303,6 +1335,28 @@ func taskAction(code, name, primaryResource, actor, source string) ActionDefinit } } +// businessUserGroupAction 定义业务用户组维护动作;组只承载业务分类,主体不可见事件细节。 +func businessUserGroupAction(code, name, risk, primaryResource string) ActionDefinition { + return ActionDefinition{ + Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: risk, + PrimaryResource: primaryResource, AllowedActor: constants.AuditActorAccount, + Source: constants.AuditSourceAdminAPI, RequireTransaction: true, + DefaultVisibility: constants.AuditSubjectInternalOnly, + AllowedVisibility: []string{constants.AuditSubjectInternalOnly}, + } +} + +// batchRootAction 定义同步后台批次根动作;子事件自带店铺或资源作用域。 +func batchRootAction(code, name, primaryResource string) ActionDefinition { + return ActionDefinition{ + Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskNormal, + PrimaryResource: primaryResource, AllowedActor: constants.AuditActorAccount, + Source: constants.AuditSourceAdminAPI, RequireTransaction: true, + DefaultVisibility: constants.AuditSubjectInternalOnly, + AllowedVisibility: []string{constants.AuditSubjectInternalOnly}, + } +} + func notificationAction(code, name, primaryResource, actor, source string) ActionDefinition { return ActionDefinition{ Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskLow, diff --git a/internal/infrastructure/releasegate/checker.go b/internal/infrastructure/releasegate/checker.go index f1ee1a2..086f011 100644 --- a/internal/infrastructure/releasegate/checker.go +++ b/internal/infrastructure/releasegate/checker.go @@ -240,7 +240,7 @@ func (c *Checker) checkAnomalies(ctx context.Context) ([]Finding, error) { }) } } - for _, table := range []string{"tb_export_task", "tb_iot_card_import_task", "tb_device_import_task", "tb_order_package_invalidate_task"} { + for _, table := range []string{"tb_export_task", "tb_iot_card_import_task", "tb_device_import_task", "tb_order_package_invalidate_task", "tb_shop_business_owner_import_task"} { exists, err := c.tableExists(ctx, table) if err != nil { return nil, err diff --git a/internal/model/business_user_group.go b/internal/model/business_user_group.go new file mode 100644 index 0000000..38f89bf --- /dev/null +++ b/internal/model/business_user_group.go @@ -0,0 +1,40 @@ +package model + +import ( + "gorm.io/gorm" +) + +// BusinessUserGroup 业务用户组:平台用户的业务分类。 +// 只用于店铺负责人的分组推导、筛选与批量维护,不进入登录、角色权限或数据范围判定; +// 不设上级组、层级与组管理员,所属组始终由店铺当前负责人实时推导。 +type BusinessUserGroup struct { + gorm.Model + BaseModel `gorm:"embedded"` + // Code 是创建时必填的稳定编码,未删除组内唯一,创建后不可修改。 + Code string `gorm:"column:code;type:varchar(64);not null;uniqueIndex:uk_business_user_group_code,where:deleted_at IS NULL;comment:创建时必填的稳定编码,创建后不可修改" json:"code"` + Name string `gorm:"column:name;type:varchar(100);not null;comment:用户组名称" json:"name"` + // BusinessLine 是三枚举单值,空字符串表示未设置业务线。 + BusinessLine string `gorm:"column:business_line;type:varchar(20);not null;default:'';comment:所属业务线单值 standard/smart/other,空值未设置" json:"business_line"` + SortOrder int64 `gorm:"column:sort_order;type:bigint;not null;default:0;comment:排序值,非负整数" json:"sort_order"` + Status int `gorm:"column:status;type:smallint;not null;default:1;comment:状态 0=禁用 1=启用" json:"status"` + Remark string `gorm:"column:remark;type:varchar(500);not null;default:'';comment:备注" json:"remark"` +} + +// TableName 指定业务用户组表名。 +func (BusinessUserGroup) TableName() string { + return "tb_business_user_group" +} + +// BusinessUserGroupMember 平台用户与业务用户组的唯一归属关系。 +// 一个账号至多一条未删除记录;改组直接更新组 ID,账号软删后关系保留并继续参与店铺推导。 +type BusinessUserGroupMember struct { + gorm.Model + BaseModel `gorm:"embedded"` + BusinessUserGroupID uint `gorm:"column:business_user_group_id;not null;index:idx_business_user_group_member_group,where:deleted_at IS NULL;comment:所属业务用户组ID" json:"business_user_group_id"` + AccountID uint `gorm:"column:account_id;not null;uniqueIndex:uk_business_user_group_member_account,where:deleted_at IS NULL;comment:平台用户账号ID" json:"account_id"` +} + +// TableName 指定平台用户分组关系表名。 +func (BusinessUserGroupMember) TableName() string { + return "tb_business_user_group_member" +} diff --git a/internal/model/dto/business_user_group_dto.go b/internal/model/dto/business_user_group_dto.go new file mode 100644 index 0000000..8b6b966 --- /dev/null +++ b/internal/model/dto/business_user_group_dto.go @@ -0,0 +1,196 @@ +package dto + +import "github.com/bytedance/sonic" + +// 业务用户组 DTO。 +// 枚举说明:enabled 与 business_line 取值必须与 pkg/constants 的 +// StatusDisabled/StatusEnabled 与 BusinessLineStandard/BusinessLineSmart/BusinessLineOther 保持一致。 + +// CreateBusinessUserGroupRequest 创建业务用户组请求。 +type CreateBusinessUserGroupRequest struct { + Code string `json:"code" validate:"required,min=1,max=64" required:"true" minLength:"1" maxLength:"64" description:"业务用户组稳定编码,1-64 字符,未删除组内唯一,创建后不可修改"` + Name string `json:"name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"业务用户组名称,1-100 字符"` + BusinessLine string `json:"business_line" validate:"omitempty,oneof=standard smart other" enum:"standard,smart,other" description:"所属业务线 (standard:标品, smart:智能产品, other:其他),留空表示不设置"` + Sort *int64 `json:"sort" validate:"omitempty,min=0" minimum:"0" description:"排序值,非负整数,默认 0"` + Enabled *bool `json:"enabled" description:"是否启用,默认启用;停用后不得新增成员,也不得作为批量目标"` + Remark string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"备注,最多 500 字符"` +} + +// UpdateBusinessUserGroupRequest 更新业务用户组请求。 +// 仅传入的字段被修改;稳定编码永不允许修改,本请求不提供该字段。 +// business_line 传显式空字符串表示清空业务线。 +type UpdateBusinessUserGroupRequest struct { + Name *string `json:"name" validate:"omitempty,min=1,max=100" minLength:"1" maxLength:"100" description:"业务用户组名称,1-100 字符"` + BusinessLine *string `json:"business_line" enum:"standard,smart,other" description:"所属业务线 (standard:标品, smart:智能产品, other:其他);字段缺失不修改,传空字符串清空;取值白名单在服务层校验"` + Sort *int64 `json:"sort" validate:"omitempty,min=0" minimum:"0" description:"排序值,非负整数"` + Enabled *bool `json:"enabled" description:"是否启用;停用后不得新增成员,已有成员关系保留并显示已停用"` + Remark *string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"备注,最多 500 字符"` +} + +// BusinessUserGroupListRequest 查询业务用户组列表请求。 +type BusinessUserGroupListRequest struct { + Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码,默认 1"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量,默认 20,最大 100"` + Enabled *bool `json:"enabled" query:"enabled" description:"按启用状态过滤;不传返回全部"` + Keyword string `json:"keyword" query:"keyword" validate:"omitempty,max=100" maxLength:"100" description:"按稳定编码或名称模糊搜索,最多 100 字符"` + BusinessLine *string `json:"business_line" query:"business_line" validate:"omitempty,oneof=standard smart other" enum:"standard,smart,other" description:"按所属业务线过滤 (standard:标品, smart:智能产品, other:其他)"` +} + +// BusinessUserGroupResponse 业务用户组响应。 +type BusinessUserGroupResponse struct { + ID uint `json:"id" description:"业务用户组ID"` + Code string `json:"code" description:"业务用户组稳定编码,创建后不可修改"` + Name string `json:"name" description:"业务用户组名称"` + BusinessLine string `json:"business_line" description:"所属业务线 (standard:标品, smart:智能产品, other:其他),空字符串表示未设置"` + BusinessLineName string `json:"business_line_name" description:"所属业务线中文名称,未设置时为空字符串"` + Sort int64 `json:"sort" description:"排序值"` + Enabled bool `json:"enabled" description:"是否启用;停用后不得新增成员,也不得作为批量目标"` + Remark string `json:"remark" description:"备注"` + CreatedAt string `json:"created_at" description:"创建时间"` + UpdatedAt string `json:"updated_at" description:"更新时间"` +} + +// BusinessUserGroupPageResult 业务用户组分页响应。 +type BusinessUserGroupPageResult struct { + Items []*BusinessUserGroupResponse `json:"items" description:"业务用户组列表"` + Total int64 `json:"total" description:"总记录数"` + Page int `json:"page" description:"当前页码"` + Size int `json:"size" description:"每页数量"` +} + +// SetBusinessUserGroupMembersRequest 批量设置平台用户业务用户组归属请求。 +// 设置直接替换每个账号的原归属;任一账号不是启用平台用户或目标组未启用时整批不修改。 +type SetBusinessUserGroupMembersRequest struct { + AccountIDs []uint `json:"account_ids" validate:"required,min=1,dive,min=1" required:"true" description:"平台用户账号ID列表,至少一个;每个账号必须是启用平台用户"` +} + +// ClearBusinessUserGroupMembersRequest 批量清空平台用户业务用户组归属请求。 +type ClearBusinessUserGroupMembersRequest struct { + AccountIDs []uint `json:"account_ids" validate:"required,min=1,dive,min=1" required:"true" description:"平台用户账号ID列表,至少一个;每个账号必须是启用平台用户"` +} + +// BusinessUserGroupMemberRequest 业务用户组成员维护路径参数。 +type BusinessUserGroupMemberRequest struct { + ID uint `path:"id" required:"true" description:"业务用户组ID"` +} + +// BusinessUserGroupDeleteRequest 业务用户组删除二次确认请求。 +type BusinessUserGroupDeleteRequest struct { + Confirm bool `json:"confirm" validate:"required" required:"true" description:"确认删除,必须为 true;仅无成员的用户组可删除,有成员时只能停用或先移走成员"` +} + +// BusinessUserGroupUpdateParams 更新业务用户组参数(路径参数 + 请求体,用于文档生成)。 +type BusinessUserGroupUpdateParams struct { + IDReq + UpdateBusinessUserGroupRequest +} + +// BusinessUserGroupDeleteParams 删除业务用户组参数(路径参数 + 二次确认,用于文档生成)。 +type BusinessUserGroupDeleteParams struct { + IDReq + BusinessUserGroupDeleteRequest +} + +// ClearBusinessUserGroupMembersParams 清空平台用户分组归属请求(用于 DELETE 显式请求体)。 +type ClearBusinessUserGroupMembersParams struct { + ClearBusinessUserGroupMembersRequest +} + +// BusinessUserGroupMembersResult 成员维护结果。 +type BusinessUserGroupMembersResult struct { + GroupID uint `json:"group_id" description:"目标业务用户组ID;清空操作为 0"` + AccountIDs []uint `json:"account_ids" description:"本次成功维护归属的平台用户账号ID列表"` +} + +// BatchUpdateShopBusinessOwnerRequest 勾选店铺批量设置或清空负责人请求。 +// business_owner_account_id 缺失时请求非法;显式 null 表示清空负责人。 +type BatchUpdateShopBusinessOwnerRequest struct { + ShopIDs []uint `json:"shop_ids" validate:"required,min=1,max=500,dive,min=1" required:"true" minItems:"1" maxItems:"500" description:"店铺ID列表,至少一个且去重,最多 500 个"` + BusinessOwnerAccountID *uint `json:"business_owner_account_id" nullable:"true" description:"目标平台业务员账号ID;显式 null 表示清空负责人"` + BusinessOwnerAccountIDSet bool `json:"-"` +} + +// UnmarshalJSON 解析批量交接请求并保留负责人字段「缺失」和「显式 null」的差异。 +func (r *BatchUpdateShopBusinessOwnerRequest) UnmarshalJSON(data []byte) error { + type plain BatchUpdateShopBusinessOwnerRequest + var decoded plain + if err := sonic.Unmarshal(data, &decoded); err != nil { + return err + } + var fields map[string]any + if err := sonic.Unmarshal(data, &fields); err != nil { + return err + } + decoded.BusinessOwnerAccountIDSet = false + if _, exists := fields["business_owner_account_id"]; exists { + decoded.BusinessOwnerAccountIDSet = true + } + *r = BatchUpdateShopBusinessOwnerRequest(decoded) + return nil +} + +// BatchUpdateShopBusinessOwnerResult 勾选店铺批量交接结果。 +type BatchUpdateShopBusinessOwnerResult struct { + BatchKey string `json:"batch_key" description:"批次标识,可用于审计关联时间线"` + ShopCount int `json:"shop_count" description:"本次成功变更的店铺数量"` + Cleared bool `json:"cleared" description:"本次是否为清空负责人操作"` + BusinessOwnerAccountID *uint `json:"business_owner_account_id" description:"目标平台业务员账号ID;清空时为 null"` +} + +// CreateShopBusinessOwnerImportRequest 创建店铺负责人 CSV 导入任务请求。 +type CreateShopBusinessOwnerImportRequest struct { + FileKey string `json:"file_key" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"CSV 对象存储 Key,必须以 shop-imports/ 开头且扩展名为 .csv(通过 POST /api/admin/storage/upload-url 获取)"` +} + +// ListShopBusinessOwnerImportRequest 查询店铺负责人导入任务列表请求。 +type ListShopBusinessOwnerImportRequest struct { + Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码,默认 1"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量,默认 20,最大 100"` + Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)"` +} + +// GetShopBusinessOwnerImportRequest 店铺负责人导入任务详情路径参数。 +type GetShopBusinessOwnerImportRequest struct { + ID uint `path:"id" required:"true" description:"导入任务ID"` +} + +// ShopBusinessOwnerImportTaskResponse 店铺负责人导入任务响应。 +type ShopBusinessOwnerImportTaskResponse struct { + ID uint `json:"id" description:"导入任务ID"` + TaskNo string `json:"task_no" description:"导入任务编号"` + FileName string `json:"file_name" description:"上传的源 CSV 文件名"` + Status int `json:"status" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)"` + StatusName string `json:"status_name" description:"任务状态中文名称"` + TotalCount int `json:"total_count" description:"数据行总数;任务级失败时为 0"` + SuccessCount int `json:"success_count" description:"成功行数"` + FailCount int `json:"fail_count" description:"失败行数"` + ErrorMessage string `json:"error_message" description:"任务级失败原因;与行级失败原因分开记录"` + CreatorName string `json:"creator_name" description:"任务创建人名称快照"` + CreatedAt string `json:"created_at" description:"创建时间"` + StartedAt string `json:"started_at" description:"开始处理时间"` + CompletedAt string `json:"completed_at" description:"完成时间"` +} + +// ShopBusinessOwnerImportTaskPageResult 店铺负责人导入任务分页响应。 +type ShopBusinessOwnerImportTaskPageResult struct { + Items []*ShopBusinessOwnerImportTaskResponse `json:"items" description:"导入任务列表"` + Total int64 `json:"total" description:"总记录数"` + Page int `json:"page" description:"当前页码"` + Size int `json:"size" description:"每页数量"` +} + +// ShopBusinessOwnerImportItemResponse 店铺负责人导入逐行结果。 +type ShopBusinessOwnerImportItemResponse struct { + Line int `json:"line" description:"行号,自数据首行起计(表头不计入)"` + ShopCode string `json:"shop_code" description:"店铺编码"` + OperationType string `json:"operation_type" description:"操作类型 (换绑/清空)"` + Status int `json:"status" description:"行状态 (3:成功, 4:失败)"` + StatusName string `json:"status_name" description:"行状态中文名称"` + Reason string `json:"reason" description:"失败原因;成功行为空"` +} + +// ShopBusinessOwnerImportTaskDetailResponse 店铺负责人导入任务详情响应。 +type ShopBusinessOwnerImportTaskDetailResponse struct { + ShopBusinessOwnerImportTaskResponse + Items []ShopBusinessOwnerImportItemResponse `json:"items" description:"逐行结果明细;任务级失败时为空数组"` +} diff --git a/internal/model/dto/shop_dto.go b/internal/model/dto/shop_dto.go index d61dbc1..c71d398 100644 --- a/internal/model/dto/shop_dto.go +++ b/internal/model/dto/shop_dto.go @@ -3,15 +3,18 @@ package dto import "github.com/bytedance/sonic" type ShopListRequest struct { - Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"` - PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"` - ShopName string `json:"shop_name" query:"shop_name" validate:"omitempty,max=100" maxLength:"100" description:"店铺名称模糊查询"` - ShopCode string `json:"shop_code" query:"shop_code" validate:"omitempty,max=50" maxLength:"50" description:"店铺编号精确查询"` - ContactPhone string `json:"contact_phone" query:"contact_phone" validate:"omitempty,len=11,numeric,ascii" minLength:"11" maxLength:"11" pattern:"^[0-9]{11}$" description:"联系电话精确查询(11位 ASCII 数字;空值不启用筛选;与其他条件按 AND 组合)"` - BusinessOwnerAccountID *uint `json:"business_owner_account_id" query:"business_owner_account_id" validate:"omitempty,min=1" minimum:"1" description:"平台业务员账号ID精确筛选"` - ParentID *uint `json:"parent_id" query:"parent_id" validate:"omitempty,min=1" minimum:"1" description:"上级店铺ID"` - Level *int `json:"level" query:"level" validate:"omitempty,min=1,max=7" minimum:"1" maximum:"7" description:"店铺层级 (1-7级)"` - Status *int `json:"status" query:"status" validate:"omitempty,oneof=0 1" description:"状态 (0:禁用, 1:启用)"` + Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"` + ShopName string `json:"shop_name" query:"shop_name" validate:"omitempty,max=100" maxLength:"100" description:"店铺名称模糊查询"` + ShopCode string `json:"shop_code" query:"shop_code" validate:"omitempty,max=50" maxLength:"50" description:"店铺编号精确查询"` + ContactPhone string `json:"contact_phone" query:"contact_phone" validate:"omitempty,len=11,numeric,ascii" minLength:"11" maxLength:"11" pattern:"^[0-9]{11}$" description:"联系电话精确查询(11位 ASCII 数字;空值不启用筛选;与其他条件按 AND 组合)"` + BusinessOwnerAccountID *uint `json:"business_owner_account_id" query:"business_owner_account_id" validate:"omitempty,min=1" minimum:"1" description:"平台业务员账号ID精确筛选"` + ParentID *uint `json:"parent_id" query:"parent_id" validate:"omitempty,min=1" minimum:"1" description:"上级店铺ID"` + Level *int `json:"level" query:"level" validate:"omitempty,min=1,max=7" minimum:"1" maximum:"7" description:"店铺层级 (1-7级)"` + Status *int `json:"status" query:"status" validate:"omitempty,oneof=0 1" description:"状态 (0:禁用, 1:启用)"` + BusinessUserGroupID *uint `json:"business_user_group_id" query:"business_user_group_id" validate:"omitempty,min=1" minimum:"1" description:"按业务用户组筛选,只匹配店铺当前负责人所属组;停用组同样可被筛出并携带已停用标记"` + BusinessLine *string `json:"business_line" query:"business_line" validate:"omitempty,oneof=standard smart other" enum:"standard,smart,other" description:"按负责人所属业务用户组的业务线筛选 (standard:标品, smart:智能产品, other:其他)"` + Ungrouped *bool `json:"ungrouped" query:"ungrouped" description:"按未分组筛选:true 只返回无负责人或负责人无成员关系的店铺;负责人属于停用组的店铺不计入未分组"` } type CreateShopRequest struct { @@ -98,18 +101,24 @@ type ShopResponse struct { BusinessOwnerUsername string `json:"business_owner_username" description:"平台业务员账号名"` BusinessOwnerPhoneSummary string `json:"business_owner_phone_summary" description:"平台业务员手机号摘要(前三后四)"` BusinessOwnerAvailable bool `json:"business_owner_available" description:"平台业务员当前是否可用于通知接收"` - Level int `json:"level" description:"店铺层级 (1-7级)"` - ContactName string `json:"contact_name" description:"联系人姓名"` - ContactPhone string `json:"contact_phone" description:"联系人电话"` - Province string `json:"province" description:"省份"` - City string `json:"city" description:"城市"` - District string `json:"district" description:"区县"` - Address string `json:"address" description:"详细地址"` - Status int `json:"status" description:"状态 (0:禁用, 1:启用)"` - StatusName string `json:"status_name" description:"状态名称(中文)"` - ClientLoginDisabled bool `json:"client_login_disabled" description:"是否禁止该店铺资产发起新的 C 端登录"` - CreatedAt string `json:"created_at" description:"创建时间"` - UpdatedAt string `json:"updated_at" description:"更新时间"` + // 店铺负责人业务用户组由当前负责人实时推导,店铺不保存组字段。 + BusinessUserGroupID *uint `json:"business_user_group_id" description:"负责人当前所属业务用户组ID,null 表示未分组"` + BusinessUserGroupCode string `json:"business_user_group_code" description:"业务用户组稳定编码"` + BusinessUserGroupName string `json:"business_user_group_name" description:"业务用户组名称"` + BusinessUserGroupEnabled bool `json:"business_user_group_enabled" description:"业务用户组是否启用;false 且组ID非空表示负责人属于已停用组"` + BusinessUserGroupBusinessLine string `json:"business_user_group_business_line" description:"业务用户组所属业务线 (standard:标品, smart:智能产品, other:其他),空字符串表示未设置"` + Level int `json:"level" description:"店铺层级 (1-7级)"` + ContactName string `json:"contact_name" description:"联系人姓名"` + ContactPhone string `json:"contact_phone" description:"联系人电话"` + Province string `json:"province" description:"省份"` + City string `json:"city" description:"城市"` + District string `json:"district" description:"区县"` + Address string `json:"address" description:"详细地址"` + Status int `json:"status" description:"状态 (0:禁用, 1:启用)"` + StatusName string `json:"status_name" description:"状态名称(中文)"` + ClientLoginDisabled bool `json:"client_login_disabled" description:"是否禁止该店铺资产发起新的 C 端登录"` + CreatedAt string `json:"created_at" description:"创建时间"` + UpdatedAt string `json:"updated_at" description:"更新时间"` } // ShopPageResult 店铺分页响应 diff --git a/internal/model/dto/storage_dto.go b/internal/model/dto/storage_dto.go index f783548..4467b98 100644 --- a/internal/model/dto/storage_dto.go +++ b/internal/model/dto/storage_dto.go @@ -3,7 +3,7 @@ package dto type GetUploadURLRequest struct { FileName string `json:"file_name" validate:"required,min=1,max=255" required:"true" minLength:"1" maxLength:"255" description:"文件名(如:cards.csv)"` ContentType string `json:"content_type" validate:"omitempty,max=100" maxLength:"100" description:"文件 MIME 类型(如:text/csv),留空则自动推断"` - Purpose string `json:"purpose" validate:"required,oneof=iot_import export attachment batch_purchase device_batch_allocation" required:"true" enum:"iot_import,export,attachment,batch_purchase,device_batch_allocation" description:"文件用途 (iot_import:ICCID导入, export:数据导出, attachment:附件, batch_purchase:资产套餐批量订购CSV, device_batch_allocation:设备批量分配或回收CSV)"` + Purpose string `json:"purpose" validate:"required,oneof=iot_import export attachment batch_purchase device_batch_allocation shop_import" required:"true" enum:"iot_import,export,attachment,batch_purchase,device_batch_allocation,shop_import" description:"文件用途 (iot_import:ICCID导入, export:数据导出, attachment:附件, batch_purchase:资产套餐批量订购CSV, device_batch_allocation:设备批量分配或回收CSV, shop_import:店铺负责人导入CSV)"` } type GetUploadURLResponse struct { diff --git a/internal/model/iot_card_import_task.go b/internal/model/iot_card_import_task.go index 4d2eae0..3bdebaf 100644 --- a/internal/model/iot_card_import_task.go +++ b/internal/model/iot_card_import_task.go @@ -102,3 +102,19 @@ const ( ImportTaskStatusCompleted = 3 ImportTaskStatusFailed = 4 ) + +// ImportTaskStatusName 返回既有导入任务状态机的中文名称,供导入场景统一投影。 +func ImportTaskStatusName(status int) string { + switch status { + case ImportTaskStatusPending: + return "待处理" + case ImportTaskStatusProcessing: + return "处理中" + case ImportTaskStatusCompleted: + return "已完成" + case ImportTaskStatusFailed: + return "失败" + default: + return "未知" + } +} diff --git a/internal/model/shop_business_owner_import_task.go b/internal/model/shop_business_owner_import_task.go new file mode 100644 index 0000000..2d3b5d0 --- /dev/null +++ b/internal/model/shop_business_owner_import_task.go @@ -0,0 +1,67 @@ +package model + +import ( + "database/sql/driver" + "time" + + "github.com/bytedance/sonic" + "gorm.io/gorm" +) + +// ShopBusinessOwnerImportTask 店铺负责人 CSV 导入任务。 +// 独立成表以复用统一的导入状态机,不与设备导入任务共享表与启动补偿口径; +// 逐行独立事务,成功行提交、失败行保留原值,任务级失败与行级失败分开记录。 +type ShopBusinessOwnerImportTask struct { + gorm.Model + BaseModel `gorm:"embedded"` + TaskNo string `gorm:"column:task_no;type:varchar(50);not null;uniqueIndex:uq_shop_business_owner_import_task_no,where:deleted_at IS NULL;comment:任务编号" json:"task_no"` + FileName string `gorm:"column:file_name;type:varchar(255);not null;default:'';comment:上传的源CSV文件名" json:"file_name"` + StorageKey string `gorm:"column:storage_key;type:varchar(500);not null;comment:源CSV对象存储Key" json:"storage_key"` + Status int `gorm:"column:status;type:int;not null;default:1;comment:任务状态 1-待处理 2-处理中 3-已完成 4-失败" json:"status"` + TotalCount int `gorm:"column:total_count;not null;default:0;comment:任务数据行总数" json:"total_count"` + SuccessCount int `gorm:"column:success_count;not null;default:0;comment:处理成功行数" json:"success_count"` + FailCount int `gorm:"column:fail_count;not null;default:0;comment:处理失败行数" json:"fail_count"` + ResultItems ShopBusinessOwnerImportResults `gorm:"column:result_items;type:jsonb;not null;default:'[]';comment:逐行结果明细" json:"result_items"` + ErrorMessage string `gorm:"column:error_message;type:text;not null;default:'';comment:任务级失败原因" json:"error_message"` + CreatorName string `gorm:"column:creator_name;type:varchar(100);not null;default:'';comment:任务创建人名称快照" json:"creator_name"` + StartedAt *time.Time `gorm:"column:started_at;comment:任务开始处理时间" json:"started_at"` + CompletedAt *time.Time `gorm:"column:completed_at;comment:任务处理完成时间" json:"completed_at"` +} + +// TableName 指定店铺负责人导入任务表名。 +func (ShopBusinessOwnerImportTask) TableName() string { + return "tb_shop_business_owner_import_task" +} + +// ShopBusinessOwnerImportResultItem 导入单行结果;行号自数据首行起计,表头不计入。 +type ShopBusinessOwnerImportResultItem struct { + Line int `json:"line"` + ShopCode string `json:"shop_code"` + OperationType string `json:"operation_type"` + Status int `json:"status"` + Reason string `json:"reason,omitempty"` +} + +// ShopBusinessOwnerImportResults 导入逐行结果集合。 +type ShopBusinessOwnerImportResults []ShopBusinessOwnerImportResultItem + +// Value 将逐行结果序列化为 JSONB。 +func (items ShopBusinessOwnerImportResults) Value() (driver.Value, error) { + if items == nil { + return "[]", nil + } + return sonic.Marshal(items) +} + +// Scan 从 JSONB 读取逐行结果。 +func (items *ShopBusinessOwnerImportResults) Scan(value any) error { + if value == nil { + *items = ShopBusinessOwnerImportResults{} + return nil + } + data, ok := value.([]byte) + if !ok { + return nil + } + return sonic.Unmarshal(data, items) +} diff --git a/internal/query/audit/timeline.go b/internal/query/audit/timeline.go index f054174..f43e441 100644 --- a/internal/query/audit/timeline.go +++ b/internal/query/audit/timeline.go @@ -294,6 +294,7 @@ func isAsynqTaskResource(resourceType string) bool { switch resourceType { case constants.AuditResourceDeviceBatchTask, constants.AuditResourceIotCardImportTask, constants.AuditResourceDeviceImportTask, constants.AuditResourceAssetPackageBatchOrderTask, + constants.AuditResourceShopBusinessOwnerImportTask, constants.AuditResourceOrderPackageInvalidateTask, constants.AuditResourceExportTask: return true default: diff --git a/internal/query/businessusergroup/query.go b/internal/query/businessusergroup/query.go new file mode 100644 index 0000000..8fe1791 --- /dev/null +++ b/internal/query/businessusergroup/query.go @@ -0,0 +1,116 @@ +// Package businessusergroup 提供业务用户组及其成员规模的只读投影。 +// Query 只做筛选、分页与 DTO 投影,不修改任何状态。 +package businessusergroup + +import ( + "context" + "strings" + "time" + + "gorm.io/gorm" + + "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/postgres" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// Query 查询业务用户组。 +type Query struct { + db *gorm.DB + store *postgres.BusinessUserGroupStore +} + +// NewQuery 创建业务用户组查询。 +func NewQuery(db *gorm.DB, groupStore *postgres.BusinessUserGroupStore) *Query { + return &Query{db: db, store: groupStore} +} + +// List 分页查询业务用户组,供维护与筛选下拉使用。 +func (q *Query) List(ctx context.Context, request dto.BusinessUserGroupListRequest) (*dto.BusinessUserGroupPageResult, error) { + if q == nil || q.db == nil { + return nil, errors.New(errors.CodeServiceUnavailable, "业务用户组查询尚未配置") + } + page, pageSize := normalizePage(request.Page, request.PageSize) + status := enabledToStatus(request.Enabled) + query := q.db.WithContext(ctx).Model(&model.BusinessUserGroup{}) + if status != nil { + query = query.Where("status = ?", *status) + } + if request.BusinessLine != nil { + query = query.Where("business_line = ?", *request.BusinessLine) + } + if keyword := strings.TrimSpace(request.Keyword); keyword != "" { + like := "%" + keyword + "%" + query = query.Where("name LIKE ? OR code LIKE ?", like, like) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询业务用户组总数失败") + } + var groups []*model.BusinessUserGroup + if err := query.Order("sort_order ASC, id ASC"). + Offset((page - 1) * pageSize).Limit(pageSize).Find(&groups).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询业务用户组列表失败") + } + items := make([]*dto.BusinessUserGroupResponse, 0, len(groups)) + for _, group := range groups { + items = append(items, toResponse(group)) + } + return &dto.BusinessUserGroupPageResult{Items: items, Total: total, Page: page, Size: pageSize}, nil +} + +// Detail 查询单个业务用户组详情。 +func (q *Query) Detail(ctx context.Context, groupID uint) (*dto.BusinessUserGroupResponse, error) { + if q == nil || q.store == nil { + return nil, errors.New(errors.CodeServiceUnavailable, "业务用户组查询尚未配置") + } + if groupID == 0 { + return nil, errors.New(errors.CodeInvalidParam) + } + group, err := q.store.GetByID(ctx, groupID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "业务用户组不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询业务用户组失败") + } + return toResponse(group), nil +} + +// toResponse 将业务用户组投影为对外响应。 +func toResponse(group *model.BusinessUserGroup) *dto.BusinessUserGroupResponse { + return &dto.BusinessUserGroupResponse{ + ID: group.ID, Code: group.Code, Name: group.Name, + BusinessLine: group.BusinessLine, BusinessLineName: constants.GetBusinessLineName(group.BusinessLine), + Sort: group.SortOrder, Enabled: group.Status == constants.StatusEnabled, Remark: group.Remark, + CreatedAt: group.CreatedAt.Format(time.RFC3339), UpdatedAt: group.UpdatedAt.Format(time.RFC3339), + } +} + +// enabledToStatus 把对外启停布尔映射为既有状态值;未提交时返回 nil 表示不过滤。 +func enabledToStatus(enabled *bool) *int { + if enabled == nil { + return nil + } + status := constants.StatusDisabled + if *enabled { + status = constants.StatusEnabled + } + return &status +} + +// normalizePage 归一化分页参数,执行 DefaultPageSize 与 MaxPageSize 上限。 +func normalizePage(page, pageSize int) (int, int) { + if page <= 0 { + page = constants.DefaultPage + } + if pageSize <= 0 { + pageSize = constants.DefaultPageSize + } + if pageSize > constants.MaxPageSize { + pageSize = constants.MaxPageSize + } + return page, pageSize +} diff --git a/internal/query/shop/business_owner.go b/internal/query/shop/business_owner.go index 6657597..5ef1080 100644 --- a/internal/query/shop/business_owner.go +++ b/internal/query/shop/business_owner.go @@ -119,6 +119,27 @@ func applyShopFilters(db *gorm.DB, request dto.ShopListRequest) *gorm.DB { if request.Status != nil { db = db.Where("status = ?", *request.Status) } + // 店铺所属组由当前负责人实时推导,因此筛选一律用存在性子查询; + // 主查询保持先 Count 再 Find 的同一 SQL,改用 JOIN 会放大计数行并引入列歧义。 + if request.BusinessUserGroupID != nil { + db = db.Where( + "EXISTS (SELECT 1 FROM tb_business_user_group_member m WHERE m.account_id = tb_shop.business_owner_account_id AND m.deleted_at IS NULL AND m.business_user_group_id = ?)", + *request.BusinessUserGroupID) + } + if request.BusinessLine != nil { + db = db.Where( + "EXISTS (SELECT 1 FROM tb_business_user_group_member m WHERE m.account_id = tb_shop.business_owner_account_id AND m.deleted_at IS NULL AND EXISTS (SELECT 1 FROM tb_business_user_group g WHERE g.id = m.business_user_group_id AND g.deleted_at IS NULL AND g.business_line = ?))", + *request.BusinessLine) + } + if request.Ungrouped != nil { + // 未分组只包含无负责人与负责人无未删除成员关系两种情形;停用组的负责人仍属于已分组。 + const ungroupedCondition = "(tb_shop.business_owner_account_id IS NULL OR NOT EXISTS (SELECT 1 FROM tb_business_user_group_member m WHERE m.account_id = tb_shop.business_owner_account_id AND m.deleted_at IS NULL))" + if *request.Ungrouped { + db = db.Where(ungroupedCondition) + } else { + db = db.Where("NOT " + ungroupedCondition) + } + } return db } @@ -132,6 +153,11 @@ func (q *BusinessOwnerQuery) project(ctx context.Context, shops []*model.Shop) ( if err != nil { return nil, err } + // 在既有 loadBusinessOwners 之上追加两次批量查询即完成组推导,避免 N+1。 + groups, err := q.loadBusinessUserGroups(ctx, ownerIDs) + if err != nil { + return nil, err + } responses := make([]*dto.ShopResponse, 0, len(shops)) for _, shop := range shops { response := &dto.ShopResponse{ @@ -151,7 +177,16 @@ func (q *BusinessOwnerQuery) project(ctx context.Context, shops []*model.Shop) ( if owner, exists := owners[*shop.BusinessOwnerAccountID]; exists { response.BusinessOwnerUsername = owner.Username response.BusinessOwnerPhoneSummary = maskPhone(owner.Phone) - response.BusinessOwnerAvailable = owner.UserType == constants.UserTypePlatform && owner.Status == constants.StatusEnabled && !owner.DeletedAt.Valid + response.BusinessOwnerAvailable = owner.UserType == constants.UserTypePlatform && + owner.Status == constants.StatusEnabled && !owner.DeletedAt.Valid + } + if group, exists := groups[*shop.BusinessOwnerAccountID]; exists { + groupID := group.ID + response.BusinessUserGroupID = &groupID + response.BusinessUserGroupCode = group.Code + response.BusinessUserGroupName = group.Name + response.BusinessUserGroupEnabled = group.Status == constants.StatusEnabled + response.BusinessUserGroupBusinessLine = group.BusinessLine } } responses = append(responses, response) @@ -204,6 +239,43 @@ func (q *BusinessOwnerQuery) loadBusinessOwners(ctx context.Context, ids []uint) return result, nil } +// loadBusinessUserGroups 按负责人账号批量推导当前所属业务用户组。 +// 一账号至多一条未删除成员关系,因此先在成员上按 account_id 定位、再按组 ID 回表, +// 停用组与负责人为已软删账号的成员关系都照常返回,保证展示与筛选口径一致。 +func (q *BusinessOwnerQuery) loadBusinessUserGroups(ctx context.Context, accountIDs []uint) (map[uint]model.BusinessUserGroup, error) { + result := make(map[uint]model.BusinessUserGroup, len(accountIDs)) + if len(accountIDs) == 0 { + return result, nil + } + var members []model.BusinessUserGroupMember + if err := q.db.WithContext(ctx).Model(&model.BusinessUserGroupMember{}). + Where("account_id IN ?", accountIDs).Find(&members).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询负责人业务用户组归属失败") + } + if len(members) == 0 { + return result, nil + } + groupIDs := make(map[uint]struct{}, len(members)) + for _, member := range members { + groupIDs[member.BusinessUserGroupID] = struct{}{} + } + var groups []model.BusinessUserGroup + if err := q.db.WithContext(ctx).Model(&model.BusinessUserGroup{}). + Where("id IN ?", mapKeys(groupIDs)).Find(&groups).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询业务用户组失败") + } + groupByID := make(map[uint]model.BusinessUserGroup, len(groups)) + for _, group := range groups { + groupByID[group.ID] = group + } + for _, member := range members { + if group, exists := groupByID[member.BusinessUserGroupID]; exists { + result[member.AccountID] = group + } + } + return result, nil +} + func mapKeys(values map[uint]struct{}) []uint { keys := make([]uint, 0, len(values)) for key := range values { diff --git a/internal/routes/admin.go b/internal/routes/admin.go index 06e77bd..6630fd5 100644 --- a/internal/routes/admin.go +++ b/internal/routes/admin.go @@ -21,6 +21,13 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd if handlers.Permission != nil { registerPermissionRoutes(authGroup, handlers.Permission, doc, basePath) } + // 店铺负责人批量交接与导入的静态路径必须先于 /shops/:id 注册,否则会被动态参数吞掉。 + if handlers.BusinessUserGroup != nil { + registerShopBusinessOwnerBatchRoute(authGroup, handlers.BusinessUserGroup, doc, basePath) + } + if handlers.ShopBusinessOwnerImport != nil { + registerShopBusinessOwnerImportRoutes(authGroup, handlers.ShopBusinessOwnerImport, doc, basePath) + } if handlers.Shop != nil { registerShopRoutes(authGroup, handlers.Shop, doc, basePath) } @@ -150,6 +157,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd if handlers.AssetPackageBatchOrder != nil { registerAssetPackageBatchOrderRoutes(authGroup, handlers.AssetPackageBatchOrder, doc, basePath) } + if handlers.BusinessUserGroup != nil { + registerBusinessUserGroupRoutes(authGroup, handlers.BusinessUserGroup, doc, basePath) + } if handlers.SuperAdmin != nil { registerSuperAdminRoutes(authGroup, handlers.SuperAdmin, doc, basePath) } diff --git a/internal/routes/business_user_group.go b/internal/routes/business_user_group.go new file mode 100644 index 0000000..b90a218 --- /dev/null +++ b/internal/routes/business_user_group.go @@ -0,0 +1,158 @@ +package routes + +import ( + "github.com/gofiber/fiber/v2" + + "github.com/break/junhong_cmp_fiber/internal/handler/admin" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/openapi" +) + +// shopOwnerImportDoc 说明店铺负责人导入 CSV 的模板要求。 +// 模板由前端提供,后端只描述固定列序、取值与编码要求,不提供模板下载端点与模板资源。 +const shopOwnerImportDoc = `仅超级管理员和平台账号可操作,代理与企业账号返回 403。 + +### 完整导入流程 + +1. **获取上传 URL**:调用 ` + "`POST /api/admin/storage/upload-url`" + `,purpose 传 ` + "`shop_import`" + ` +2. **上传 CSV**:使用返回的预签名 URL 上传文件到对象存储 +3. **调用本接口**:使用返回的 ` + "`file_key`" + ` 创建导入任务(必须以 ` + "`shop-imports/`" + ` 开头且扩展名为 ` + "`.csv`" + `) + +### 模板列序(首行表头必须完全一致) + +` + "`店铺编码`" + `、` + "`操作类型`" + `、` + "`业务员登录账号`" + `、` + "`备注`" + ` + +- 店铺编码:以店铺编号唯一定位店铺;不存在或已删除时该行失败 +- 操作类型:取值仅 ` + "`换绑`" + ` 与 ` + "`清空`" + `;换绑必须填写业务员登录账号,清空不得填写 +- 业务员登录账号:以登录账号唯一定位业务员;必须是启用平台用户 +- 备注:可选,填写时写入该行审计 + +### 编码要求 + +文件编码为 UTF-8,可带 BOM;非 UTF-8 时按 GBK 尝试解码,仍失败时按任务级失败并给出明确原因。 + +### 执行语义 + +每行独立校验与执行:有效行成功更新,失败行保留原值且不影响其他已成功行。结果返回行号(自数据首行起计,表头不计入)、成功或失败状态与失败原因。表头或编码不符为任务级失败,不产生行明细。不设行数硬上限。` + +// registerBusinessUserGroupRoutes 注册业务用户组、成员维护与店铺负责人批量交接路由。 +// 入口只读认证上下文判断身份,代理与企业一律 403,既有单店行为不变。 +func registerBusinessUserGroupRoutes(router fiber.Router, handler *admin.BusinessUserGroupHandler, doc *openapi.Generator, basePath string) { + groups := router.Group("/business-user-groups") + groupPath := basePath + "/business-user-groups" + + Register(groups, doc, groupPath, "POST", "", handler.Create, RouteSpec{ + Summary: "创建业务用户组", + Description: "仅超级管理员和平台账号可操作。稳定编码创建时必填、未删除组内唯一且创建后不可修改;用户组不设上级、层级与组管理员,不改变角色权限与数据范围。", + Tags: []string{"业务用户组"}, + Input: new(dto.CreateBusinessUserGroupRequest), + Output: new(dto.BusinessUserGroupResponse), + Auth: true, + }) + + Register(groups, doc, groupPath, "GET", "", handler.List, RouteSpec{ + Summary: "查询业务用户组列表", + Description: "仅超级管理员和平台账号可操作。分页默认第 1 页、每页 20 条,最大 100;返回组字段与业务线,供维护与筛选下拉使用。", + Tags: []string{"业务用户组"}, + Input: new(dto.BusinessUserGroupListRequest), + Output: new(dto.BusinessUserGroupPageResult), + Auth: true, + }) + + // 批量清空归属使用静态路径 /members,必须先于动态 /:id 注册,否则会被 :id 吞掉。 + Register(groups, doc, groupPath, "DELETE", "/members", handler.ClearMembers, RouteSpec{ + Summary: "批量清空平台用户业务用户组归属", + Description: "仅超级管理员和平台账号可操作。与批量设置使用同一校验:所有账号必须是启用平台用户,任一账号无效则全量回滚。清空后账号回到未分组。", + Tags: []string{"业务用户组"}, + Input: new(dto.ClearBusinessUserGroupMembersParams), + Output: new(dto.BusinessUserGroupMembersResult), + Auth: true, + }) + + Register(groups, doc, groupPath, "GET", "/:id", handler.Detail, RouteSpec{ + Summary: "查询业务用户组详情", + Description: "仅超级管理员和平台账号可操作。返回组字段含业务线,供维护与筛选下拉使用;不存在或已删除返回资源不存在。", + Tags: []string{"业务用户组"}, + Input: new(dto.IDReq), + Output: new(dto.BusinessUserGroupResponse), + Auth: true, + }) + + Register(groups, doc, groupPath, "PUT", "/:id", handler.Update, RouteSpec{ + Summary: "更新业务用户组", + Description: "仅超级管理员和平台账号可操作。允许更新名称、业务线、排序、启停与备注;稳定编码永不允许修改;不存在或已删除返回资源不存在。", + Tags: []string{"业务用户组"}, + Input: new(dto.BusinessUserGroupUpdateParams), + Output: new(dto.BusinessUserGroupResponse), + Auth: true, + }) + + Register(groups, doc, groupPath, "DELETE", "/:id", handler.Delete, RouteSpec{ + Summary: "删除业务用户组", + Description: "仅超级管理员和平台账号可操作,必须提交二次确认。仅无成员的用户组可删除;存在成员时返回“用户组仍有成员,只能停用或先移走成员”,不做物理删除。", + Tags: []string{"业务用户组"}, + Input: new(dto.IDReq), + Body: new(dto.BusinessUserGroupDeleteRequest), + Output: nil, + Auth: true, + }) + + Register(groups, doc, groupPath, "PUT", "/:id/members", handler.SetMembers, RouteSpec{ + Summary: "批量设置平台用户业务用户组归属", + Description: "仅超级管理员和平台账号可操作。请求为账号ID数组,所有账号必须是启用平台用户且目标组启用;事务内直接替换每个账号原归属,任一账号无效则全量回滚。停用组不得作为目标,已有成员关系保留并显示已停用。", + Tags: []string{"业务用户组"}, + Input: new(dto.BusinessUserGroupMemberRequest), + Body: new(dto.SetBusinessUserGroupMembersRequest), + Output: new(dto.BusinessUserGroupMembersResult), + Auth: true, + }) +} + +// registerShopBusinessOwnerBatchRoute 注册勾选店铺批量设置或清空负责人路由。 +func registerShopBusinessOwnerBatchRoute(router fiber.Router, handler *admin.BusinessUserGroupHandler, doc *openapi.Generator, basePath string) { + shops := router.Group("/shops") + groupPath := basePath + "/shops" + + Register(shops, doc, groupPath, "PUT", "/business-owner/batch", handler.BatchUpdateShopBusinessOwner, RouteSpec{ + Summary: "批量设置或清空店铺负责人", + Description: "仅超级管理员和平台账号可操作。提交前校验全部目标店铺均存在、未删除且可管理,并校验目标业务员为启用平台用户;任一项失败时整批不修改,失败文案不区分无权、不存在与已删除。business_owner_account_id 缺失时请求非法,显式 null 表示清空负责人。成功时在同一事务内统一更新并为每家店铺记录负责人前后值、操作者、时间与入口审计,同时保留批次汇总结果。", + Tags: []string{"店铺管理"}, + Input: new(dto.BatchUpdateShopBusinessOwnerRequest), + Output: new(dto.BatchUpdateShopBusinessOwnerResult), + Auth: true, + }) +} + +// registerShopBusinessOwnerImportRoutes 注册店铺负责人 CSV 导入任务路由。 +func registerShopBusinessOwnerImportRoutes(router fiber.Router, handler *admin.ShopBusinessOwnerImportHandler, doc *openapi.Generator, basePath string) { + shops := router.Group("/shops") + groupPath := basePath + "/shops" + + Register(shops, doc, groupPath, "POST", "/business-owner-imports", handler.Create, RouteSpec{ + Summary: "创建店铺负责人 CSV 导入任务", + Description: shopOwnerImportDoc, + Tags: []string{"店铺负责人导入"}, + Input: new(dto.CreateShopBusinessOwnerImportRequest), + Output: new(dto.ShopBusinessOwnerImportTaskResponse), + Auth: true, + }) + + Register(shops, doc, groupPath, "GET", "/business-owner-imports", handler.List, RouteSpec{ + Summary: "查询店铺负责人导入任务列表", + Description: constants.ShopOwnerImportAccessDescription, + Tags: []string{"店铺负责人导入"}, + Input: new(dto.ListShopBusinessOwnerImportRequest), + Output: new(dto.ShopBusinessOwnerImportTaskPageResult), + Auth: true, + }) + + Register(shops, doc, groupPath, "GET", "/business-owner-imports/:id", handler.Detail, RouteSpec{ + Summary: "查询店铺负责人导入任务详情", + Description: constants.ShopOwnerImportAccessDescription + "返回成功数、失败数与逐行失败原因,并单独返回任务级错误原因。", + Tags: []string{"店铺负责人导入"}, + Input: new(dto.GetShopBusinessOwnerImportRequest), + Output: new(dto.ShopBusinessOwnerImportTaskDetailResponse), + Auth: true, + }) +} diff --git a/internal/routes/storage.go b/internal/routes/storage.go index dcdc4a5..e13581c 100644 --- a/internal/routes/storage.go +++ b/internal/routes/storage.go @@ -136,6 +136,7 @@ await api.post('/iot-cards/import', { | iot_import | ICCID/设备导入 (Excel) | imports/YYYY/MM/DD/uuid.xlsx | | batch_purchase | 资产套餐批量订购 (CSV) | batch-purchases/YYYY/MM/DD/uuid.csv | | device_batch_allocation | 设备批量分配、设置套餐系列或回收 (CSV) | device-batch-allocations/YYYY/MM/DD/uuid.csv | +| shop_import | 店铺负责人导入 (CSV) | shop-imports/YYYY/MM/DD/uuid.csv | | export | 数据导出 | exports/YYYY/MM/DD/uuid.xlsx | | attachment | 附件上传 | attachments/YYYY/MM/DD/uuid.ext | diff --git a/internal/service/shop_business_owner_import/audit.go b/internal/service/shop_business_owner_import/audit.go new file mode 100644 index 0000000..b9f7414 --- /dev/null +++ b/internal/service/shop_business_owner_import/audit.go @@ -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, + } +} diff --git a/internal/service/shop_business_owner_import/service.go b/internal/service/shop_business_owner_import/service.go new file mode 100644 index 0000000..2e67c4c --- /dev/null +++ b/internal/service/shop_business_owner_import/service.go @@ -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 +} diff --git a/internal/store/postgres/business_user_group_store.go b/internal/store/postgres/business_user_group_store.go new file mode 100644 index 0000000..c8fb41d --- /dev/null +++ b/internal/store/postgres/business_user_group_store.go @@ -0,0 +1,179 @@ +package postgres + +import ( + "context" + "strings" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/break/junhong_cmp_fiber/internal/model" +) + +// BusinessUserGroupStore 业务用户组及其成员归属的数据访问层。 +// 成员关系属于用户组聚合:一账号至多一条未删除记录(partial 唯一索引保证), +// 改组更新组 ID,清空走软删,历史归属变更由审计事件承担。 +type BusinessUserGroupStore struct { + db *gorm.DB +} + +// NewBusinessUserGroupStore 创建业务用户组 Store。 +func NewBusinessUserGroupStore(db *gorm.DB) *BusinessUserGroupStore { + return &BusinessUserGroupStore{db: db} +} + +// DB 返回 Store 使用的数据库连接。 +func (s *BusinessUserGroupStore) DB() *gorm.DB { return s.db } + +// WithTx 返回绑定指定事务的 Store。 +func (s *BusinessUserGroupStore) WithTx(tx *gorm.DB) *BusinessUserGroupStore { + return &BusinessUserGroupStore{db: tx} +} + +// Create 创建业务用户组。 +func (s *BusinessUserGroupStore) Create(ctx context.Context, group *model.BusinessUserGroup) error { + return s.db.WithContext(ctx).Create(group).Error +} + +// GetByID 查询未删除的业务用户组。 +func (s *BusinessUserGroupStore) GetByID(ctx context.Context, id uint) (*model.BusinessUserGroup, error) { + var group model.BusinessUserGroup + if err := s.db.WithContext(ctx).First(&group, id).Error; err != nil { + return nil, err + } + return &group, nil +} + +// LockByID 在事务内按主键加行锁查询未删除的业务用户组。 +func (s *BusinessUserGroupStore) LockByID(ctx context.Context, id uint) (*model.BusinessUserGroup, error) { + var group model.BusinessUserGroup + if err := s.db.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&group, id).Error; err != nil { + return nil, err + } + return &group, nil +} + +// ExistsCode 判断稳定编码是否已被其他未删除组占用。 +func (s *BusinessUserGroupStore) ExistsCode(ctx context.Context, code string, excludeID uint) (bool, error) { + query := s.db.WithContext(ctx).Model(&model.BusinessUserGroup{}).Where("code = ?", code) + if excludeID != 0 { + query = query.Where("id <> ?", excludeID) + } + var count int64 + if err := query.Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + +// Update 保存业务用户组可变字段;稳定编码不参与更新。 +func (s *BusinessUserGroupStore) Update(ctx context.Context, group *model.BusinessUserGroup, operatorID uint) error { + now := time.Now() + return s.db.WithContext(ctx).Model(&model.BusinessUserGroup{}).Where("id = ?", group.ID).Updates(map[string]any{ + "name": group.Name, + "business_line": group.BusinessLine, + "sort_order": group.SortOrder, + "status": group.Status, + "remark": group.Remark, + "updater": operatorID, + "updated_at": now, + }).Error +} + +// Delete 软删除业务用户组;调用方必须先确认组内已无成员。 +func (s *BusinessUserGroupStore) Delete(ctx context.Context, id, operatorID uint) error { + now := time.Now() + return s.db.WithContext(ctx).Model(&model.BusinessUserGroup{}). + Where("id = ? AND deleted_at IS NULL", id). + Updates(map[string]any{"deleted_at": now, "updater": operatorID, "updated_at": now}).Error +} + +// CountMembers 统计组内未删除的成员关系数。 +func (s *BusinessUserGroupStore) CountMembers(ctx context.Context, groupID uint) (int64, error) { + var count int64 + if err := s.db.WithContext(ctx).Model(&model.BusinessUserGroupMember{}). + Where("business_user_group_id = ?", groupID).Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +// MembersByAccountIDs 批量读取账号当前的未删除成员关系。 +func (s *BusinessUserGroupStore) MembersByAccountIDs(ctx context.Context, accountIDs []uint) (map[uint]model.BusinessUserGroupMember, error) { + result := make(map[uint]model.BusinessUserGroupMember, len(accountIDs)) + if len(accountIDs) == 0 { + return result, nil + } + var members []model.BusinessUserGroupMember + if err := s.db.WithContext(ctx).Model(&model.BusinessUserGroupMember{}). + Where("account_id IN ?", accountIDs).Find(&members).Error; err != nil { + return nil, err + } + for _, member := range members { + result[member.AccountID] = member + } + return result, nil +} + +// ReplaceMemberGroup 将给定账号的归属直接替换为目标组:已有关系更新组 ID,缺失关系新增一行。 +func (s *BusinessUserGroupStore) ReplaceMemberGroup(ctx context.Context, accountIDs []uint, groupID, operatorID uint) error { + if len(accountIDs) == 0 { + return nil + } + existing, err := s.MembersByAccountIDs(ctx, accountIDs) + if err != nil { + return err + } + now := time.Now() + missing := make([]model.BusinessUserGroupMember, 0, len(accountIDs)) + for _, accountID := range accountIDs { + if member, ok := existing[accountID]; ok { + if member.BusinessUserGroupID == groupID { + continue + } + if err := s.db.WithContext(ctx).Model(&model.BusinessUserGroupMember{}).Where("id = ?", member.ID). + Updates(map[string]any{ + "business_user_group_id": groupID, "updater": operatorID, "updated_at": now, + }).Error; err != nil { + return err + } + continue + } + missing = append(missing, model.BusinessUserGroupMember{ + BusinessUserGroupID: groupID, AccountID: accountID, + BaseModel: model.BaseModel{Creator: operatorID, Updater: operatorID}, + }) + } + if len(missing) == 0 { + return nil + } + return s.db.WithContext(ctx).Create(&missing).Error +} + +// ClearMembers 删除给定账号的未删除成员关系,使账号回到未分组。 +func (s *BusinessUserGroupStore) ClearMembers(ctx context.Context, accountIDs []uint) error { + if len(accountIDs) == 0 { + return nil + } + return s.db.WithContext(ctx).Where("account_id IN ?", accountIDs).Delete(&model.BusinessUserGroupMember{}).Error +} + +// LockAccountsByIDs 在事务内对账号行本身按主键升序加行锁。 +// 锁账号(而非仅锁已有成员行)同时消除两种并发缺陷: +// 一是清空时账号尚无成员行导致锁不到行(幻读),二是多账号按不同请求顺序插入成员行导致的相反顺序死锁。 +// 显式 ORDER BY id ASC 保证所有事务以同一顺序取锁,避免交叉等待。 +func (s *BusinessUserGroupStore) LockAccountsByIDs(ctx context.Context, accountIDs []uint) error { + if len(accountIDs) == 0 { + return nil + } + var accounts []model.Account + return s.db.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). + Select("id").Where("id IN ?", accountIDs).Order("id ASC").Find(&accounts).Error +} + +// IsAccountMemberConflict 判断错误是否为成员唯一索引冲突。 +// 并发为同一账号新增成员关系时唯一索引是最终裁决,调用方据此返回稳定业务错误。 +func IsAccountMemberConflict(err error) bool { + return err != nil && strings.Contains(strings.ToLower(err.Error()), "uk_business_user_group_member_account") +} diff --git a/internal/store/postgres/shop_business_owner_import_task_store.go b/internal/store/postgres/shop_business_owner_import_task_store.go new file mode 100644 index 0000000..2e83423 --- /dev/null +++ b/internal/store/postgres/shop_business_owner_import_task_store.go @@ -0,0 +1,124 @@ +package postgres + +import ( + "context" + "fmt" + "time" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/store" +) + +// ShopBusinessOwnerImportTaskStore 店铺负责人 CSV 导入任务数据访问层。 +type ShopBusinessOwnerImportTaskStore struct { + db *gorm.DB +} + +// NewShopBusinessOwnerImportTaskStore 创建店铺负责人导入任务 Store。 +func NewShopBusinessOwnerImportTaskStore(db *gorm.DB) *ShopBusinessOwnerImportTaskStore { + return &ShopBusinessOwnerImportTaskStore{db: db} +} + +// DB 返回任务 Store 使用的数据库连接。 +func (s *ShopBusinessOwnerImportTaskStore) DB() *gorm.DB { return s.db } + +// WithTx 返回绑定指定事务的任务 Store。 +func (s *ShopBusinessOwnerImportTaskStore) WithTx(tx *gorm.DB) *ShopBusinessOwnerImportTaskStore { + return &ShopBusinessOwnerImportTaskStore{db: tx} +} + +// Create 创建店铺负责人导入任务。 +func (s *ShopBusinessOwnerImportTaskStore) Create(ctx context.Context, task *model.ShopBusinessOwnerImportTask) error { + return s.db.WithContext(ctx).Create(task).Error +} + +// GetByID 按 ID 查询店铺负责人导入任务。 +func (s *ShopBusinessOwnerImportTaskStore) GetByID(ctx context.Context, id uint) (*model.ShopBusinessOwnerImportTask, error) { + var task model.ShopBusinessOwnerImportTask + if err := s.db.WithContext(ctx).First(&task, id).Error; err != nil { + return nil, err + } + return &task, nil +} + +// List 分页查询店铺负责人导入任务。 +func (s *ShopBusinessOwnerImportTaskStore) List(ctx context.Context, opts *store.QueryOptions, status *int) ([]*model.ShopBusinessOwnerImportTask, int64, error) { + query := s.db.WithContext(ctx).Model(&model.ShopBusinessOwnerImportTask{}) + if status != nil { + query = query.Where("status = ?", *status) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + if opts == nil { + opts = store.DefaultQueryOptions() + } + var tasks []*model.ShopBusinessOwnerImportTask + if err := query.Order("created_at DESC").Offset((opts.Page - 1) * opts.PageSize).Limit(opts.PageSize).Find(&tasks).Error; err != nil { + return nil, 0, err + } + return tasks, total, nil +} + +// ResetForProcessing 把待处理任务或上次中断的处理中任务置为处理中,并重置进度计数与行明细。 +// 返回 false 表示任务已到达终态,重复消费直接跳过;处理中一律视为中断重跑, +// 重跑前清空计数与明细,避免逐行结果重复追加。 +func (s *ShopBusinessOwnerImportTaskStore) ResetForProcessing(ctx context.Context, id uint) (bool, error) { + now := time.Now() + result := s.db.WithContext(ctx).Model(&model.ShopBusinessOwnerImportTask{}). + Where("id = ? AND status IN ?", id, []int{model.ImportTaskStatusPending, model.ImportTaskStatusProcessing}). + Updates(map[string]any{ + "status": model.ImportTaskStatusProcessing, "started_at": now, "success_count": 0, + "fail_count": 0, "total_count": 0, "result_items": model.ShopBusinessOwnerImportResults{}, + "error_message": "", "updated_at": now, + }) + return result.RowsAffected == 1, result.Error +} + +// UpdateProgress 按批更新进度计数,不触碰逐行明细,失败不回滚已提交行。 +// 必须同时写入本任务已知的行总数:表约束要求 success_count + fail_count <= total_count, +// 只写计数会让处理中的中间态违反该约束,导致进度更新静默失败。 +func (s *ShopBusinessOwnerImportTaskStore) UpdateProgress(ctx context.Context, id uint, totalCount, successCount, failCount int) error { + return s.db.WithContext(ctx).Model(&model.ShopBusinessOwnerImportTask{}).Where("id = ?", id). + Updates(map[string]any{ + "total_count": totalCount, "success_count": successCount, "fail_count": failCount, + "updated_at": time.Now(), + }).Error +} + +// MarkFailed 将任务标记为任务级失败,并返回是否确实命中非终态。 +// 命中返回 true;返回 false 表示任务已到终态(例如 Enqueue 实际已投递成功且 Worker 抢先跑完), +// 此时调用方必须按库内真实状态对外呈现,不得把响应与审计置为失败。 +func (s *ShopBusinessOwnerImportTaskStore) MarkFailed(ctx context.Context, id uint, message string) (bool, error) { + now := time.Now() + result := s.db.WithContext(ctx).Model(&model.ShopBusinessOwnerImportTask{}). + Where("id = ? AND status IN ?", id, []int{model.ImportTaskStatusPending, model.ImportTaskStatusProcessing}). + Updates(map[string]any{ + "status": model.ImportTaskStatusFailed, "error_message": message, + "total_count": 0, "success_count": 0, "fail_count": 0, + "result_items": model.ShopBusinessOwnerImportResults{}, + "completed_at": now, "updated_at": now, + }) + return result.RowsAffected == 1, result.Error +} + +// Complete 保存逐行结果与汇总并完成任务。 +func (s *ShopBusinessOwnerImportTaskStore) Complete(ctx context.Context, id uint, totalCount, successCount, failCount int, items model.ShopBusinessOwnerImportResults) error { + now := time.Now() + return s.db.WithContext(ctx).Model(&model.ShopBusinessOwnerImportTask{}). + Where("id = ? AND status = ?", id, model.ImportTaskStatusProcessing). + Updates(map[string]any{ + "status": model.ImportTaskStatusCompleted, "total_count": totalCount, + "success_count": successCount, "fail_count": failCount, + "result_items": items, "completed_at": now, "updated_at": now, + }).Error +} + +// GenerateTaskNo 生成店铺负责人导入任务编号。 +func (s *ShopBusinessOwnerImportTaskStore) GenerateTaskNo() string { + now := time.Now() + return fmt.Sprintf("SBI-%s-%06d", now.Format("20060102"), now.UnixNano()%1000000) +} diff --git a/internal/task/shop_business_owner_import.go b/internal/task/shop_business_owner_import.go new file mode 100644 index 0000000..9b0e93c --- /dev/null +++ b/internal/task/shop_business_owner_import.go @@ -0,0 +1,403 @@ +package task + +import ( + "bytes" + "context" + "encoding/csv" + stderrors "errors" + "fmt" + "io" + "os" + "strconv" + "strings" + + "github.com/bytedance/sonic" + "github.com/hibiken/asynq" + "go.uber.org/zap" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "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/constants" + pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/storage" + "github.com/break/junhong_cmp_fiber/pkg/utils" +) + +// ShopBusinessOwnerImportPayload 店铺负责人 CSV 导入任务载荷。 +type ShopBusinessOwnerImportPayload struct { + TaskID uint `json:"task_id"` +} + +// ShopBusinessOwnerImportHandler 店铺负责人 CSV 导入任务处理器。 +// 逐行独立事务:成功行提交、失败行不写店铺并保留原值;任务级失败与行级失败分开记录。 +type ShopBusinessOwnerImportHandler struct { + db *gorm.DB + taskStore *postgres.ShopBusinessOwnerImportTaskStore + storageService *storage.Service + auditWriter *audit.Writer + logger *zap.Logger +} + +// NewShopBusinessOwnerImportHandler 创建店铺负责人 CSV 导入任务处理器。 +func NewShopBusinessOwnerImportHandler( + db *gorm.DB, + taskStore *postgres.ShopBusinessOwnerImportTaskStore, + storageService *storage.Service, + logger *zap.Logger, + auditWriters ...*audit.Writer, +) *ShopBusinessOwnerImportHandler { + handler := &ShopBusinessOwnerImportHandler{ + db: db, taskStore: taskStore, storageService: storageService, logger: logger, + } + if len(auditWriters) > 0 { + handler.auditWriter = auditWriters[0] + } + return handler +} + +// Handle 处理店铺负责人 CSV 导入任务。 +func (h *ShopBusinessOwnerImportHandler) Handle(ctx context.Context, taskMessage *asynq.Task) error { + var payload ShopBusinessOwnerImportPayload + if err := sonic.Unmarshal(taskMessage.Payload(), &payload); err != nil { + h.logger.Error("解析店铺负责人导入任务载荷失败", zap.Error(err)) + return asynq.SkipRetry + } + taskRecord, 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 + } + if h.auditWriter == nil { + return pkgerrors.New(pkgerrors.CodeInvalidStatus, "店铺负责人导入统一审计接缝未配置") + } + ctx = auditcontext.With(ctx, auditcontext.Context{ + ActorKind: constants.AuditActorSystemTask, ActorID: constants.TaskTypeShopBusinessOwnerImport, + ActorName: "店铺负责人导入任务", Source: constants.AuditSourceWorker, + CorrelationID: taskRecord.TaskNo, + ParentEventID: audit.TaskEventID(constants.AuditResourceShopBusinessOwnerImportTask, taskRecord.ID, "completed"), + }) + claimed, err := h.taskStore.ResetForProcessing(ctx, taskRecord.ID) + if err != nil { + return err + } + if !claimed { + h.logger.Info("店铺负责人导入任务已终结,跳过重复消费", zap.Uint("task_id", taskRecord.ID)) + return nil + } + + rows, err := h.downloadAndParse(ctx, taskRecord.StorageKey) + if err != nil { + h.logger.Warn("下载或解析店铺负责人导入CSV失败", zap.Uint("task_id", taskRecord.ID), zap.Error(err)) + if finishErr := h.finishTask(ctx, taskRecord, nil, 0, 0, model.ImportTaskStatusFailed, err.Error()); finishErr != nil { + return finishErr + } + return asynq.SkipRetry + } + + items, successCount, err := h.processRows(ctx, taskRecord, rows) + if err != nil { + message := "导入执行中断:" + err.Error() + h.logger.Error("店铺负责人导入行执行中断", zap.Uint("task_id", taskRecord.ID), zap.Error(err)) + if finishErr := h.finishTask(ctx, taskRecord, nil, 0, 0, model.ImportTaskStatusFailed, message); finishErr != nil { + return finishErr + } + return asynq.SkipRetry + } + failCount := len(items) - successCount + if err := h.finishTask(ctx, taskRecord, items, successCount, failCount, model.ImportTaskStatusCompleted, ""); err != nil { + return err + } + h.logger.Info("店铺负责人导入任务完成", + zap.Uint("task_id", taskRecord.ID), zap.Int("success", successCount), zap.Int("fail", failCount)) + return nil +} + +// finishTask 在单事务内写任务终态、逐行明细与任务根审计事件。 +// 任务级失败不产生行明细,与行级失败原因分开记录。 +func (h *ShopBusinessOwnerImportHandler) finishTask( + ctx context.Context, + taskRecord *model.ShopBusinessOwnerImportTask, + items model.ShopBusinessOwnerImportResults, + successCount, failCount, status int, + errorMessage string, +) error { + return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + store := h.taskStore.WithTx(tx) + if status == model.ImportTaskStatusFailed { + // 任务级失败:未命中非终态说明任务已被其他执行路径终结,按库内事实跳过重复收尾。 + hit, err := store.MarkFailed(ctx, taskRecord.ID, errorMessage) + if err != nil { + return err + } + if !hit { + return nil + } + } else if err := store.Complete(ctx, taskRecord.ID, len(items), successCount, failCount, items); err != nil { + return err + } + result := batchAuditResult(successCount, failCount) + afterData := map[string]any{ + "status": status, "total_count": len(items), "success_count": successCount, "fail_count": failCount, + } + if status == model.ImportTaskStatusFailed { + result = constants.AuditResultFailed + afterData["error_message"] = errorMessage + } + return h.auditWriter.WriteTask(ctx, tx, audit.TaskInput{ + EventID: audit.TaskEventID(constants.AuditResourceShopBusinessOwnerImportTask, taskRecord.ID, "completed"), + ActionCode: constants.AuditActionShopBusinessOwnerImportTaskCompleted, + Summary: "完成店铺负责人导入任务", TaskID: taskRecord.ID, TaskNo: taskRecord.TaskNo, + Result: result, CorrelationID: taskRecord.TaskNo, + ParentEventID: audit.TaskEventID(constants.AuditResourceShopBusinessOwnerImportTask, taskRecord.ID, "created"), + BatchTotal: len(items), SuccessCount: successCount, FailCount: failCount, + IdentitySnapshot: map[string]any{ + "id": taskRecord.ID, "task_no": taskRecord.TaskNo, "file_name": taskRecord.FileName, + }, + BeforeData: map[string]any{"status": model.ImportTaskStatusProcessing}, + AfterData: afterData, + ErrorSummary: errorMessage, + }) + }) +} + +// downloadAndParse 下载并解析导入 CSV,编码、表头或格式问题一律按任务级失败返回。 +func (h *ShopBusinessOwnerImportHandler) downloadAndParse(ctx context.Context, key string) ([]shopBusinessOwnerImportRow, error) { + if h.storageService == nil { + return nil, shopBusinessOwnerImportError("对象存储服务未配置") + } + if key == "" { + return nil, shopBusinessOwnerImportError("导入文件Key不能为空") + } + localPath, cleanup, err := h.storageService.DownloadToTemp(ctx, key) + if err != nil { + return nil, shopBusinessOwnerImportError("下载导入CSV失败") + } + defer cleanup() + // 不设行数与体积硬上限:体积沿用上传用途的既有校验,此处按文件实际大小读取。 + data, err := os.ReadFile(localPath) + if err != nil { + return nil, shopBusinessOwnerImportError("读取导入CSV失败") + } + decoded, err := utils.DecodeTextToUTF8(data) + if err != nil { + return nil, shopBusinessOwnerImportError(constants.ShopBusinessOwnerImportErrorEncoding) + } + return parseShopBusinessOwnerImportCSV(decoded) +} + +// shopBusinessOwnerImportRow 是导入文件的单行业务事实;行号自数据首行起计,表头不计入。 +type shopBusinessOwnerImportRow struct { + Line int + ColumnCountMatched bool + ShopCode string + OperationType string + OwnerUsername string + Remark string +} + +// parseShopBusinessOwnerImportCSV 解析固定列序的导入 CSV。 +// 表头必须与固定列序完全一致,不一致即任务级失败且不进入逐行阶段; +// 数据行列数不符属行级「行格式错误」,因此必须关闭字段数一致性校验, +// 否则标准库在首条记录定型字段数后会让后续异常行直接返回 ErrFieldCount, +// 把行级问题误升级为任务级失败且不产生行明细。 +func parseShopBusinessOwnerImportCSV(data []byte) ([]shopBusinessOwnerImportRow, error) { + reader := csv.NewReader(bytes.NewReader(data)) + reader.TrimLeadingSpace = true + reader.FieldsPerRecord = -1 + rows := make([]shopBusinessOwnerImportRow, 0) + line := 0 + for { + record, err := reader.Read() + if err == io.EOF { + break + } + if err != nil { + // 关闭字段数校验后仍报错,说明是引号未闭合等真实 CSV 语法错误,属任务级失败。 + return nil, shopBusinessOwnerImportError(constants.ShopBusinessOwnerImportErrorFileFormat) + } + if line == 0 { + if !matchShopBusinessOwnerImportHeader(record) { + return nil, shopBusinessOwnerImportError(constants.ShopBusinessOwnerImportErrorFileFormat) + } + line++ + continue + } + line++ + row := shopBusinessOwnerImportRow{Line: line - 1} + if len(record) != len(constants.ShopBusinessOwnerImportColumns) { + // 列数不符的行不参与业务校验,直接以行格式错误记录并保留原值。 + rows = append(rows, row) + continue + } + row.ColumnCountMatched = true + row.ShopCode = strings.TrimSpace(record[0]) + row.OperationType = strings.TrimSpace(record[1]) + row.OwnerUsername = strings.TrimSpace(record[2]) + row.Remark = strings.TrimSpace(record[3]) + rows = append(rows, row) + } + if len(rows) == 0 { + return nil, shopBusinessOwnerImportError(constants.ShopBusinessOwnerImportErrorNoDataRow) + } + return rows, nil +} + +// matchShopBusinessOwnerImportHeader 逐列比较表头与固定列序,仅容忍列内两侧空白差异。 +func matchShopBusinessOwnerImportHeader(record []string) bool { + columns := constants.ShopBusinessOwnerImportColumns + if len(record) != len(columns) { + return false + } + for index, column := range columns { + if strings.TrimSpace(record[index]) != column { + return false + } + } + return true +} + +// processRows 逐行独立执行并按批更新进度计数;进度写失败不回滚已提交行。 +// 返回错误表示行执行遇到基础设施故障,由调用方按任务级失败收尾。 +func (h *ShopBusinessOwnerImportHandler) processRows(ctx context.Context, taskRecord *model.ShopBusinessOwnerImportTask, rows []shopBusinessOwnerImportRow) (model.ShopBusinessOwnerImportResults, int, error) { + items := make(model.ShopBusinessOwnerImportResults, 0, len(rows)) + successCount, failCount := 0, 0 + for index, row := range rows { + item, err := h.processRow(ctx, taskRecord, row) + if err != nil { + return nil, 0, err + } + items = append(items, item) + if item.Status == constants.ShopBusinessOwnerImportItemStatusSuccess { + successCount++ + } else { + failCount++ + } + if (index+1)%constants.ShopBusinessOwnerImportProgressBatchSize == 0 { + if err := h.taskStore.UpdateProgress(ctx, taskRecord.ID, len(rows), successCount, failCount); err != nil { + h.logger.Warn("更新店铺负责人导入进度失败", zap.Uint("task_id", taskRecord.ID), zap.Error(err)) + } + } + } + return items, successCount, nil +} + +// processRow 校验并执行单行;失败行只记录固定枚举原因,不改动店铺负责人原值。 +func (h *ShopBusinessOwnerImportHandler) processRow(ctx context.Context, taskRecord *model.ShopBusinessOwnerImportTask, row shopBusinessOwnerImportRow) (model.ShopBusinessOwnerImportResultItem, error) { + item := model.ShopBusinessOwnerImportResultItem{Line: row.Line, ShopCode: row.ShopCode, OperationType: row.OperationType} + if !row.ColumnCountMatched { + return failedShopBusinessOwnerImportItem(item, constants.ShopBusinessOwnerImportRowErrorFormat), nil + } + var shop model.Shop + err := h.db.WithContext(ctx).Select("id", "shop_code", "shop_name", "parent_id", "level", "business_owner_account_id"). + Where("shop_code = ?", row.ShopCode).First(&shop).Error + if err != nil { + if err != gorm.ErrRecordNotFound { + return item, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, err, "查询导入目标店铺失败") + } + return failedShopBusinessOwnerImportItem(item, constants.ShopBusinessOwnerImportRowErrorShopMissing), nil + } + clear := false + switch row.OperationType { + case constants.ShopBusinessOwnerImportOperationRebind: + if row.OwnerUsername == "" { + return failedShopBusinessOwnerImportItem(item, constants.ShopBusinessOwnerImportRowErrorOwnerRequired), nil + } + case constants.ShopBusinessOwnerImportOperationClear: + if row.OwnerUsername != "" { + return failedShopBusinessOwnerImportItem(item, constants.ShopBusinessOwnerImportRowErrorOwnerForbidden), nil + } + clear = true + default: + return failedShopBusinessOwnerImportItem(item, constants.ShopBusinessOwnerImportRowErrorOperation), nil + } + var ownerID *uint + if !clear { + var account model.Account + if err := h.db.WithContext(ctx). + Where("username = ? AND user_type = ? AND status = ?", row.OwnerUsername, constants.UserTypePlatform, constants.StatusEnabled). + First(&account).Error; err != nil { + if err != gorm.ErrRecordNotFound { + return item, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, err, "查询导入目标业务员失败") + } + return failedShopBusinessOwnerImportItem(item, constants.ShopBusinessOwnerImportRowErrorOwnerInvalid), nil + } + value := account.ID + ownerID = &value + } + // 每行独立事务:成功行提交,失败行回滚并保留原值。 + rowErr := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var locked model.Shop + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Select("id", "shop_code", "shop_name", "parent_id", "level", "business_owner_account_id"). + Where("id = ?", shop.ID).First(&locked).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return pkgerrors.New(pkgerrors.CodeNotFound, constants.ShopBusinessOwnerImportRowErrorShopMissing) + } + return pkgerrors.Wrap(pkgerrors.CodeDatabaseError, err, "锁定导入目标店铺失败") + } + before := locked.BusinessOwnerAccountID + if err := tx.Model(&model.Shop{}).Where("id = ?", locked.ID). + Updates(map[string]any{"business_owner_account_id": ownerID, "updater": taskRecord.Creator}).Error; err != nil { + return pkgerrors.Wrap(pkgerrors.CodeDatabaseError, err, "更新店铺负责人失败") + } + return h.appendRowAudit(ctx, tx, taskRecord, &locked, before, ownerID, row) + }) + if rowErr != nil { + var appErr *pkgerrors.AppError + if stderrors.As(rowErr, &appErr) && appErr.Code == pkgerrors.CodeNotFound { + return failedShopBusinessOwnerImportItem(item, appErr.Message), nil + } + return item, rowErr + } + item.Status = constants.ShopBusinessOwnerImportItemStatusSuccess + return item, nil +} + +// appendRowAudit 在行事务内写实际变更审计,含负责人前后值与行备注。 +// 行备注写入事件 Metadata,不进入资源前后值字段。 +func (h *ShopBusinessOwnerImportHandler) appendRowAudit(ctx context.Context, tx *gorm.DB, taskRecord *model.ShopBusinessOwnerImportTask, shop *model.Shop, before, after *uint, row shopBusinessOwnerImportRow) error { + if h.auditWriter == nil { + return pkgerrors.New(pkgerrors.CodeInvalidStatus, "店铺负责人导入统一审计接缝未配置") + } + metadata := map[string]any{ + "import_task_id": taskRecord.ID, "import_task_no": taskRecord.TaskNo, + "line": row.Line, "operation_type": row.OperationType, + } + if row.Remark != "" { + metadata["remark"] = row.Remark + } + shopID := strconv.FormatUint(uint64(shop.ID), 10) + // 稳定事件 ID 由任务与行号决定,任务重复消费时同行为幂等重放。 + return h.auditWriter.Append(ctx, tx, audit.AppendInput{ + EventID: audit.TaskEventID(constants.AuditResourceShopBusinessOwnerImportTask, taskRecord.ID, fmt.Sprintf("item:%d", row.Line)), + ActionCode: constants.AuditActionShopBusinessOwnerImported, Summary: "导入更新店铺负责人归属", + ScopeType: constants.AuditScopeShop, ScopeID: shopID, + Result: constants.AuditResultSuccess, Metadata: metadata, + Resources: []audit.ResourceInput{{ + Type: constants.AuditResourceShop, ID: &shopID, + Key: shop.ShopCode, DisplayName: shop.ShopName, + Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleShopTarget, + IdentitySnapshot: map[string]any{ + "id": shop.ID, "shop_code": shop.ShopCode, "shop_name": shop.ShopName, + "parent_id": shop.ParentID, "level": shop.Level, + }, + BeforeData: map[string]any{"business_owner_account_id": before}, + AfterData: map[string]any{"business_owner_account_id": after}, + }}, + }) +} + +func failedShopBusinessOwnerImportItem(item model.ShopBusinessOwnerImportResultItem, reason string) model.ShopBusinessOwnerImportResultItem { + item.Status, item.Reason = constants.ShopBusinessOwnerImportItemStatusFailed, reason + return item +} + +// shopBusinessOwnerImportError 是任务级失败原因,与行级失败原因分开记录。 +type shopBusinessOwnerImportError string + +// Error 返回任务级失败原因原文。 +func (e shopBusinessOwnerImportError) Error() string { return string(e) } diff --git a/migrations/000221_add_business_user_group_and_shop_owner_import.down.sql b/migrations/000221_add_business_user_group_and_shop_owner_import.down.sql new file mode 100644 index 0000000..e54aa6d --- /dev/null +++ b/migrations/000221_add_business_user_group_and_shop_owner_import.down.sql @@ -0,0 +1,46 @@ +-- 回滚业务用户组、成员归属与店铺负责人导入任务 Schema。 +-- 三张表都承载不可由 Schema 重建的业务事实:用户组与成员关系决定店铺展示口径, +-- 导入任务保存逐行结果。存在任一记录时禁止破坏性回滚,需人工先核对并清理后再评估。 +-- 注意:成员关系清空走软删,软删行同样计入本守卫,因此解除守卫需删除成员关系记录本身。 + +BEGIN; + +-- 同一事务内先取排他锁再检查并删表,避免检查与删表之间写入新事实。 +DO $$ +BEGIN + IF to_regclass('tb_shop_business_owner_import_task') IS NOT NULL THEN + LOCK TABLE tb_shop_business_owner_import_task IN ACCESS EXCLUSIVE MODE; + IF EXISTS (SELECT 1 FROM tb_shop_business_owner_import_task LIMIT 1) THEN + RAISE EXCEPTION 'tb_shop_business_owner_import_task 已存在导入任务事实,禁止删表回滚,请停止导入后向前修复'; + END IF; + END IF; + + IF to_regclass('tb_business_user_group_member') IS NOT NULL THEN + LOCK TABLE tb_business_user_group_member IN ACCESS EXCLUSIVE MODE; + IF EXISTS (SELECT 1 FROM tb_business_user_group_member LIMIT 1) THEN + RAISE EXCEPTION 'tb_business_user_group_member 已存在平台用户分组事实(含软删行,清空归属走软删),禁止删表回滚,请删除成员关系记录后向前修复'; + END IF; + END IF; + + IF to_regclass('tb_business_user_group') IS NOT NULL THEN + LOCK TABLE tb_business_user_group IN ACCESS EXCLUSIVE MODE; + IF EXISTS (SELECT 1 FROM tb_business_user_group LIMIT 1) THEN + RAISE EXCEPTION 'tb_business_user_group 已存在业务用户组事实(含软删行),禁止删表回滚,请删除业务用户组记录后向前修复'; + END IF; + END IF; +END +$$; + +DROP INDEX IF EXISTS idx_shop_business_owner_import_task_status_created; +DROP INDEX IF EXISTS uq_shop_business_owner_import_task_no; +DROP TABLE IF EXISTS tb_shop_business_owner_import_task; + +DROP INDEX IF EXISTS idx_business_user_group_member_group; +DROP INDEX IF EXISTS uk_business_user_group_member_account; +DROP TABLE IF EXISTS tb_business_user_group_member; + +DROP INDEX IF EXISTS idx_business_user_group_status; +DROP INDEX IF EXISTS uk_business_user_group_code; +DROP TABLE IF EXISTS tb_business_user_group; + +COMMIT; diff --git a/migrations/000221_add_business_user_group_and_shop_owner_import.up.sql b/migrations/000221_add_business_user_group_and_shop_owner_import.up.sql new file mode 100644 index 0000000..4cf0986 --- /dev/null +++ b/migrations/000221_add_business_user_group_and_shop_owner_import.up.sql @@ -0,0 +1,134 @@ +-- 业务用户组:平台用户的业务分类、成员归属与店铺负责人 CSV 导入任务。 +-- 用户组只描述业务分类,不进入登录、角色权限或数据范围;店铺所属组始终由当前负责人实时推导, +-- 因此本迁移不给 tb_shop 增加任何组字段,也不回填历史分组。 +-- 成员关系一账号一行,改组走 UPDATE 组 ID;清空归属走软删(与仓库统一 gorm.Model 基座一致), +-- 未删除记录的唯一性由部分唯一索引保证,历史归属变更由审计事件承担。 +-- 导入任务独立成表,不复用设备导入任务表(后者的启动补偿按表绑定设备任务类型)。 +-- 不使用数据库外键,关联以 ID 保存并由应用层显式校验。 + +CREATE TABLE tb_business_user_group ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(64) NOT NULL, + name VARCHAR(100) NOT NULL, + business_line VARCHAR(20) NOT NULL DEFAULT '', + sort_order BIGINT NOT NULL DEFAULT 0, + status SMALLINT NOT NULL DEFAULT 1, + remark VARCHAR(500) NOT NULL DEFAULT '', + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + CONSTRAINT chk_business_user_group_code CHECK (code <> ''), + CONSTRAINT chk_business_user_group_name CHECK (name <> ''), + CONSTRAINT chk_business_user_group_business_line CHECK (business_line IN ('', 'standard', 'smart', 'other')), + CONSTRAINT chk_business_user_group_status CHECK (status IN (0, 1)), + CONSTRAINT chk_business_user_group_sort CHECK (sort_order >= 0) +); + +-- 稳定编码在未删除组内唯一;编码创建后不可修改,不存在跨组复用入口。 +CREATE UNIQUE INDEX uk_business_user_group_code + ON tb_business_user_group (code) + WHERE deleted_at IS NULL; +CREATE INDEX idx_business_user_group_status + ON tb_business_user_group (status, sort_order, id) + WHERE deleted_at IS NULL; + +CREATE TABLE tb_business_user_group_member ( + id BIGSERIAL PRIMARY KEY, + business_user_group_id BIGINT NOT NULL, + account_id BIGINT NOT NULL, + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + CONSTRAINT chk_business_user_group_member_group CHECK (business_user_group_id > 0), + CONSTRAINT chk_business_user_group_member_account CHECK (account_id > 0) +); + +-- 一个账号至多一条未删除成员关系,唯一索引是「一账号至多一组」的结构性保证。 +CREATE UNIQUE INDEX uk_business_user_group_member_account + ON tb_business_user_group_member (account_id) + WHERE deleted_at IS NULL; +-- 组维度用于删除前统计成员数。 +CREATE INDEX idx_business_user_group_member_group + ON tb_business_user_group_member (business_user_group_id) + WHERE deleted_at IS NULL; + +CREATE TABLE tb_shop_business_owner_import_task ( + id BIGSERIAL PRIMARY KEY, + task_no VARCHAR(50) NOT NULL, + file_name VARCHAR(255) NOT NULL DEFAULT '', + storage_key VARCHAR(500) NOT NULL, + status INTEGER NOT NULL DEFAULT 1, + total_count INTEGER NOT NULL DEFAULT 0, + success_count INTEGER NOT NULL DEFAULT 0, + fail_count INTEGER NOT NULL DEFAULT 0, + result_items JSONB NOT NULL DEFAULT '[]'::jsonb, + error_message TEXT NOT NULL DEFAULT '', + creator_name VARCHAR(100) NOT NULL DEFAULT '', + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + CONSTRAINT chk_shop_business_owner_import_task_status CHECK (status IN (1, 2, 3, 4)), + CONSTRAINT chk_shop_business_owner_import_task_counts CHECK ( + total_count >= 0 AND success_count >= 0 AND fail_count >= 0 + AND success_count + fail_count <= total_count + ) +); + +CREATE UNIQUE INDEX uq_shop_business_owner_import_task_no + ON tb_shop_business_owner_import_task (task_no) + WHERE deleted_at IS NULL; +CREATE INDEX idx_shop_business_owner_import_task_status_created + ON tb_shop_business_owner_import_task (status, created_at DESC) + WHERE deleted_at IS NULL; + +COMMENT ON TABLE tb_business_user_group IS '业务用户组,平台用户的业务分类,不进入鉴权与数据范围'; +COMMENT ON COLUMN tb_business_user_group.id IS '主键'; +COMMENT ON COLUMN tb_business_user_group.code IS '创建时必填的稳定编码,未删除组内唯一且创建后不可修改'; +COMMENT ON COLUMN tb_business_user_group.name IS '用户组名称'; +COMMENT ON COLUMN tb_business_user_group.business_line IS '所属业务线单值:standard 标品、smart 智能产品、other 其他,空值表示未设置'; +COMMENT ON COLUMN tb_business_user_group.sort_order IS '排序值,非负整数'; +COMMENT ON COLUMN tb_business_user_group.status IS '状态 0-禁用 1-启用,与 pkg/constants.StatusDisabled/StatusEnabled 一致'; +COMMENT ON COLUMN tb_business_user_group.remark IS '备注'; +COMMENT ON COLUMN tb_business_user_group.creator IS '创建人账号ID'; +COMMENT ON COLUMN tb_business_user_group.updater IS '最近更新人账号ID'; +COMMENT ON COLUMN tb_business_user_group.created_at IS '创建时间'; +COMMENT ON COLUMN tb_business_user_group.updated_at IS '最近更新时间'; +COMMENT ON COLUMN tb_business_user_group.deleted_at IS '软删除时间,仅无成员组可删除'; + +COMMENT ON TABLE tb_business_user_group_member IS '平台用户与业务用户组的唯一归属关系'; +COMMENT ON COLUMN tb_business_user_group_member.id IS '主键'; +COMMENT ON COLUMN tb_business_user_group_member.business_user_group_id IS '所属业务用户组ID;改组时直接更新本列'; +COMMENT ON COLUMN tb_business_user_group_member.account_id IS '平台用户账号ID,未删除记录内唯一'; +COMMENT ON COLUMN tb_business_user_group_member.creator IS '创建人账号ID'; +COMMENT ON COLUMN tb_business_user_group_member.updater IS '最近更新人账号ID'; +COMMENT ON COLUMN tb_business_user_group_member.created_at IS '创建时间'; +COMMENT ON COLUMN tb_business_user_group_member.updated_at IS '最近更新时间'; +COMMENT ON COLUMN tb_business_user_group_member.deleted_at IS '软删除时间,清空归属走软删;唯一性由未删除记录的 partial 唯一索引保证'; + +COMMENT ON TABLE tb_shop_business_owner_import_task IS '店铺负责人 CSV 导入任务,逐行独立事务并保留逐行明细'; +COMMENT ON COLUMN tb_shop_business_owner_import_task.id IS '主键'; +COMMENT ON COLUMN tb_shop_business_owner_import_task.task_no IS '导入任务编号,未删除记录内唯一'; +COMMENT ON COLUMN tb_shop_business_owner_import_task.file_name IS '上传的源 CSV 文件名'; +COMMENT ON COLUMN tb_shop_business_owner_import_task.storage_key IS '源 CSV 对象存储Key'; +COMMENT ON COLUMN tb_shop_business_owner_import_task.status IS '任务状态 1-待处理 2-处理中 3-已完成 4-失败'; +COMMENT ON COLUMN tb_shop_business_owner_import_task.total_count IS '任务数据行总数,任务级失败时为 0'; +COMMENT ON COLUMN tb_shop_business_owner_import_task.success_count IS '处理成功行数'; +COMMENT ON COLUMN tb_shop_business_owner_import_task.fail_count IS '处理失败行数'; +COMMENT ON COLUMN tb_shop_business_owner_import_task.result_items IS '逐行结果明细,行号自数据首行起计;任务级失败为空数组'; +COMMENT ON COLUMN tb_shop_business_owner_import_task.error_message IS '任务级失败原因,与行级失败原因分开记录'; +COMMENT ON COLUMN tb_shop_business_owner_import_task.creator_name IS '任务创建人名称快照'; +COMMENT ON COLUMN tb_shop_business_owner_import_task.started_at IS '任务开始处理时间'; +COMMENT ON COLUMN tb_shop_business_owner_import_task.completed_at IS '任务处理完成时间'; +COMMENT ON COLUMN tb_shop_business_owner_import_task.creator IS '创建人账号ID'; +COMMENT ON COLUMN tb_shop_business_owner_import_task.updater IS '最近更新人账号ID'; +COMMENT ON COLUMN tb_shop_business_owner_import_task.created_at IS '创建时间'; +COMMENT ON COLUMN tb_shop_business_owner_import_task.updated_at IS '最近更新时间'; +COMMENT ON COLUMN tb_shop_business_owner_import_task.deleted_at IS '软删除时间'; diff --git a/openspec/changes/add-shop-salesperson-groups/design.md b/openspec/changes/add-shop-salesperson-groups/design.md deleted file mode 100644 index cf9fb29..0000000 --- a/openspec/changes/add-shop-salesperson-groups/design.md +++ /dev/null @@ -1,42 +0,0 @@ -## Context - -现有店铺已存在负责人候选和数据范围能力;用户组是新的业务分类,不能复用 RBAC 角色或代理店铺层级。推导关系必须保持实时,避免负责人改组后大量回写店铺造成不一致。 - -## Decisions - -- 新增业务用户组表和平台用户—组关联(用户唯一)表;组编码唯一且不可改,停用不删除既有成员关联。 -- 店铺不保存组 ID。列表/详情以店铺负责人关联平台用户,再左连接用户组得到组及停用状态;按组筛选同样使用该关系。 -- 勾选批量交接采用一次事务:先按操作者数据范围锁定/校验全量店铺及目标用户,再统一更新和写审计。任何校验失败不写入。 -- Excel 使用既有异步导入模式逐行事务;每行在数据范围内查询,统一拒绝文案不区分无权与不存在,并持久化任务明细。 -- 用户组成员批量设置直接替换关联;不引入组管理员、层级、额外权限或数据范围计算。 - -## 管理动作契约 - -### 用户组及成员 - -- `POST /business-user-groups`:超级管理员、平台用户提交 `code`(1~64 字符,未删除组内唯一)、`name`(1~100 字符)、`sort`(非负整数)、`enabled`、`remark`(最多 500 字符)。成功返回组 ID 与字段;重复编码返回“业务用户组编码已存在”。 -- `PUT /business-user-groups/:id`:允许更新名称、排序、启停、备注;`code` 永不允许修改。不存在/已删除返回既有资源不存在。 -- `DELETE /business-user-groups/:id`:请求须带二次确认;存在成员时返回“用户组仍有成员,只能停用或先移走成员”,不物理删除。 -- `PUT /business-user-groups/:id/members`:请求 `account_ids` 非空数组;所有账号必须是启用平台用户且目标组启用。事务内替换每个账号旧组关系,任一账号无效则全量回滚。`DELETE /business-user-groups/members` 使用同一校验清空指定账号归属。成功操作写成员前后审计。 - -### 店铺负责人批量交接 - -- `PUT /shops/business-owner/batch`:请求 `shop_ids`(非空、去重)及 `business_owner_account_id`(有效平台业务员)或显式 `null`(清空)。先按操作者数据范围锁定并校验所有店铺,再统一更新 `tb_shop.business_owner_account_id` 并逐店写审计;任何目标无权、不存在、已删除或负责人无效时,返回统一失败且整批无写入。 -- Excel 导入使用既有异步导入任务;每行提供店铺标识及负责人账号标识或清空标识。每行独立授权、存在性、负责人有效性校验和事务更新;结果保存行号、成功/失败、失败原因、变更前后负责人及汇总。无权和不存在对调用方使用同一错误文案。 - -### 读侧投影 - -- 扩展既有店铺列表、详情、筛选与导出:返回 `business_owner_account_id`、负责人名称、`business_user_group_id`、组编码、组名称、组启用状态;组字段从当前负责人—成员关系实时左连接。 -- 组筛选只匹配当前负责人所属组;负责人为空或无成员关系时归入“未分组”。历史店铺不回填;负责人改组/停用后下一次读立即反映变化。 - -## Risks / Trade-offs - -- 实时 join 增加列表复杂度 → 为负责人和成员关联建立查询索引,不以冗余字段换一致性风险。 -- 负责人/分组并发更新 → 店铺交接和用户改组均使用事务与受影响行检查;读取接受当前已提交快照。 -- 导入部分成功 → 明确为逐行语义,任务明细是唯一结果来源。 - -## Migration Plan - -1. 新增成对迁移创建用户组、成员关联和导入/查询索引,不回填历史组归属。 -2. 部署读侧空组兼容,再启用维护、批量和导入入口。 -3. 隔离库验证成员唯一、停用保留、实时推导、批量原子失败、导入逐行结果及迁移 up/down/up。 \ No newline at end of file diff --git a/openspec/changes/add-shop-salesperson-groups/proposal.md b/openspec/changes/add-shop-salesperson-groups/proposal.md deleted file mode 100644 index 9285ba4..0000000 --- a/openspec/changes/add-shop-salesperson-groups/proposal.md +++ /dev/null @@ -1,25 +0,0 @@ -## Why - -店铺负责人只能逐店维护,平台用户也没有稳定的业务分类;店铺按组统计、筛选和批量交接缺少统一、可追溯口径。 - -本 Change 落实 AUG26-003:业务用户组只描述平台用户的业务分类,不改变角色权限、数据范围或代理店铺分组;店铺所属组始终由当前负责人实时推导。 - -## What Changes - -- 新增业务用户组(名称、不可变唯一编码、排序、启停、备注)及平台用户单组归属。 -- 新增店铺负责人批量设置/清空和 Excel 导入;勾选操作全量校验且原子,导入逐行独立执行并返回明细。 -- 店铺列表、详情和筛选显示实时推导的负责人业务用户组;停用组保留成员和展示,不影响登录、权限、数据范围或负责人。 - -## Capabilities - -### New Capabilities - -- `business-user-group`: 用户组生命周期、成员归属、店铺负责人交接及推导查询。 - -### Modified Capabilities - -- 无。现有身份权限和数据范围拒绝行为保持不变。 - -## Impact - -影响平台用户、店铺列表/详情、导入任务、数据范围校验、审计、DTO/OpenAPI 和新增 Schema。 \ No newline at end of file diff --git a/openspec/changes/add-shop-salesperson-groups/specs/business-user-group/spec.md b/openspec/changes/add-shop-salesperson-groups/specs/business-user-group/spec.md deleted file mode 100644 index d95dfdd..0000000 --- a/openspec/changes/add-shop-salesperson-groups/specs/business-user-group/spec.md +++ /dev/null @@ -1,38 +0,0 @@ -## Purpose - -以不改变既有角色和数据范围的方式标记平台用户业务分类,并将店铺负责人和业务用户组的批量维护、推导展示与审计定义为一致的可观察行为。 - -## ADDED Requirements - -### Requirement: 业务用户组生命周期与成员归属 -系统 SHALL 允许超级管理员和平台用户维护业务用户组的名称、创建时必填且在未删除组内唯一的稳定编码、排序、启用状态和备注;编码创建后 MUST NOT 修改。用户组不得设置上级、层级或组管理员。仅无成员用户组可由超级管理员或平台用户二次确认删除;有成员时只能停用或先移走成员。 - -每个启用平台用户最多属于一个启用业务用户组。超级管理员和平台用户可选择多个平台用户,批量设置至一个启用组或批量清空归属;设置直接替换原归属,停用组不得作为目标。业务用户组 MUST NOT 改变后台角色、登录、权限、数据范围或店铺具体负责人归属。停用后不得新增成员,已有成员关系保留并显示已停用,管理员仍可将成员改组或清空。 - -#### Scenario: 批量替换平台用户分组 -- **WHEN** 管理员选择多个启用平台用户并指定一个启用业务用户组 -- **THEN** 系统将每个目标用户的原分组直接替换为目标组,不改变其角色、数据范围和登录状态 - -#### Scenario: 停用含成员用户组 -- **WHEN** 管理员停用仍含平台用户成员的业务用户组 -- **THEN** 系统保留成员关系并标记组已停用,拒绝新增成员但允许后续改组或清空成员 - -### Requirement: 店铺负责人和所属组实时推导 -店铺 SHALL 以当前绑定的平台业务员作为负责人。店铺所属业务用户组 MUST 实时由该负责人的当前用户组推导,不得把组 ID 冗余写入店铺;负责人变更、负责人改组或组停用后,店铺列表、详情和筛选的结果立即按新关系变化。店铺无负责人、负责人无分组时所属组为空;负责人所属组停用时仍返回该组并明确其已停用。 - -#### Scenario: 负责人改组改变店铺展示 -- **WHEN** 某平台业务员的业务用户组被替换或清空 -- **THEN** 该业务员当前负责的所有店铺在列表、详情和按组筛选中即时呈现新的组或空组,无需更新店铺记录 - -### Requirement: 店铺负责人批量交接与导入 -超级管理员和平台用户 SHALL 仅在其店铺数据权限内勾选多家店铺,批量设置为一个有效平台业务员或批量清空负责人。勾选批量操作 MUST 在提交前校验全部目标店铺均存在、未删除且可管理,并校验目标业务员有效;任一项失败时整批不修改并返回统一失败结果。成功时必须为每家店铺记录负责人前后值、操作者、时间和入口审计。 - -Excel 导入 MUST 按每行店铺标识独立校验和执行:有效行成功更新,无权限、店铺不存在/已删除或目标业务员无效行失败;任务返回成功数、失败数和逐行失败原因。导入不得因一行失败回滚其他已成功行,并必须记录每行实际变更审计。 - -#### Scenario: 勾选批量包含越权店铺 -- **WHEN** 管理员提交的店铺集合中任一店铺不在其数据范围、已删除或不存在 -- **THEN** 系统不修改集合中任何店铺负责人,并返回统一失败结果且不泄露越权店铺存在性 - -#### Scenario: 导入包含有效和无效行 -- **WHEN** 店铺负责人 Excel 导入同时包含可管理店铺和无权或无效店铺 -- **THEN** 系统更新每个有效行、保留失败行原值,并返回逐行结果及成功/失败汇总 diff --git a/openspec/changes/add-shop-salesperson-groups/tasks.md b/openspec/changes/add-shop-salesperson-groups/tasks.md deleted file mode 100644 index e80c006..0000000 --- a/openspec/changes/add-shop-salesperson-groups/tasks.md +++ /dev/null @@ -1,17 +0,0 @@ -## 1. 用户组与查询 - -- [ ] 1.1 追踪平台用户、店铺负责人、数据范围、现有批量导入和审计调用链,确认负责人字段及“有效平台业务员”的既有判定。 -- [ ] 1.2 新增成对迁移、模型和常量:业务用户组、平台用户唯一组关联、编码唯一/启停约束及负责人—成员推导查询索引。 -- [ ] 1.3 实现用户组 CRUD、启停、无成员二次确认删除、平台用户批量设置/清空及审计;禁止修改编码、向停用组新增成员和改变既有 RBAC。 -- [ ] 1.4 扩展店铺列表、详情与筛选 Query/DTO/OpenAPI,实时返回负责人组、编码和停用标识,不将组写入店铺。 - -## 2. 店铺负责人批量维护 - -- [ ] 2.1 实现勾选店铺批量设置/清空:先以操作者数据范围校验所有店铺和目标业务员,在单事务中更新全部店铺并记录逐店审计;任一项失败整批不改。 -- [ ] 2.2 接入既有异步 Excel 导入任务,逐行校验店铺标识、数据范围和目标业务员,保存成功/失败明细、汇总和实际审计;越权使用统一失败文案。 -- [ ] 2.3 注册后台路由和权限,更新 `cmd/api/docs.go` 与 `cmd/gendocs/main.go`;Handler 使用全局错误处理和 `pkg/response`。 - -## 3. 验证 - -- [ ] 3.1 在隔离数据库验证迁移 up/down/up、编码/成员唯一、组停用、负责人改组实时展示、批量原子失败、导入混合结果和数据范围拒绝。 -- [ ] 3.2 运行 `gofmt -w`(变更 Go 文件)、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`、`openspec validate add-shop-salesperson-groups --strict`、`openspec doctor --json` 和 `./scripts/context-health.sh`;自动化测试按项目决策为 N/A。 \ No newline at end of file diff --git a/openspec/changes/add-shop-salesperson-groups/.openspec.yaml b/openspec/changes/archive/2026-09-14-add-shop-salesperson-groups/.openspec.yaml similarity index 100% rename from openspec/changes/add-shop-salesperson-groups/.openspec.yaml rename to openspec/changes/archive/2026-09-14-add-shop-salesperson-groups/.openspec.yaml diff --git a/openspec/changes/archive/2026-09-14-add-shop-salesperson-groups/design.md b/openspec/changes/archive/2026-09-14-add-shop-salesperson-groups/design.md new file mode 100644 index 0000000..9f63060 --- /dev/null +++ b/openspec/changes/archive/2026-09-14-add-shop-salesperson-groups/design.md @@ -0,0 +1,125 @@ +## Context + +- 店铺已存在负责人字段 `tb_shop.business_owner_account_id`(`migrations/000169_add_shop_business_owner.up.sql`:单列普通索引、无外键、不回填历史)。店铺列表/详情/候选读取收口在 `internal/query/shop/business_owner.go`,**不使用 JOIN**:负责人摘要由 `loadBusinessOwners` 二次批量查询后在 Go 中装配,`List` 在同一查询上先 `Count` 再 `Find`。 +- 数据范围由 `pkg/middleware/data_scope.go` 的 `ApplyShopIDFilter` 承担;`SubordinateShopIDs` 只对代理账号预计算,超管与平台用户恒为不受限 —— 因此「行级无权」在超管/平台入口下不可达。 +- 单店更新已显式拒绝代理设置店铺业务员(`internal/application/shop/update.go`)。 +- 仓库**不存在统一导入任务框架**:既有四个导入场景(IoT 卡、设备、订单套餐失效、资产套餐批量订购)各自独立成表、成 store、成 task type、成队列;集中的只有 `QueueForTaskType` 与 worker 注册表两处。设备导入表的启动补偿按表扫描并以设备任务类型重新入队,因此不能复用其表。 +- 已有可复用先例:`encoding/csv` 解析、UTF-8 BOM 剥离(`internal/task/device_batch_allocation.go`、`internal/task/asset_package_batch_order.go`)、异步导入任务状态机、jsonb 行明细、按批进度更新、启动补偿、逐行事务(`internal/task/device_import.go`)。 +- 仓库唯一 GBK 转换能力位于支付集成包 `pkg/fuiou`(导出函数 `GBKToUTF8`);`pkg/utils` 是既有的中立工具包。 +- 仓库**不存在店铺导出场景**:`internal/exporter` 的注册表与场景白名单、导出 DTO 场景枚举、店铺路由段均无 shop。 +- 用户组是新的业务分类,不能复用 RBAC 角色或代理店铺层级;推导必须实时,避免负责人改组后回写店铺造成不一致。 + +## Goals / Non-Goals + +**Goals:** + +- 用户组只描述平台用户业务分类,不进入鉴权、数据范围或登录链路。 +- 店铺所属组与业务线始终由当前负责人实时推导,店铺不落组字段。 +- 勾选批量全成或全不成;CSV 导入逐行独立、成功行提交、失败行保留原值。 +- 推导、批量与导入复用既有审计、数据范围与统一错误响应口径。 + +**Non-Goals:** + +- 店铺导出场景;导入模板下载端点与服务端模板资源。 +- 用户组层级、上级组、组管理员。 +- 改动既有 RBAC、登录、数据范围与店铺具体负责人归属。 +- 重构既有 5 处内联的「有效平台业务员」判定。 +- 复用 `openspec/specs/shop-bulk-import` 维护者手工执行的离线 SQL 生成器。 + +## Decisions + +### 数据模型 + +- **用户组表**:`code`(1~64 字符,创建时必填,未删除组内唯一,创建后不可修改)、`name`(1~100 字符)、`business_line`(可选单值字符串,取值 `standard` 标品 / `smart` 智能产品 / `other` 其他,允为空)、`sort`(非负整数)、`status`(0 禁用 / 1 启用,遵循 ENG-STATE-001)、`remark`(最多 500 字符)。不设上级、层级与组管理员。 +- **成员表**:一账号一行,`account_id` 唯一。 +- **三处索引**:用户组 `UNIQUE(code) WHERE deleted_at IS NULL`;成员 `UNIQUE(account_id) WHERE deleted_at IS NULL`(结构性保证「一个账号至多一个组」);成员 `(business_user_group_id) WHERE deleted_at IS NULL`。**不给 `business_line` 建索引**:它是 3 值枚举且用户组表体量小,存在性子查询按 `account_id` 唯一索引定位成员后回表,额外索引无收益。 +- 账号改组走 **UPDATE 组 ID**,不做软删加新增,避免唯一索引与垃圾行。账号被软删时成员关系保留并继续参与推导(店铺仍展示已软删负责人,口径一致),账号删除不级联清成员。 +- 全部关联只保存 ID,不新增 GORM 关联标签或数据库外键(ENG-MODEL-001)。 + +### 读侧推导 + +- 店铺**不落**组字段。列表/详情继续沿用「Go 批量装配」:在既有 `loadBusinessOwners` 之上追加一次按 `account_id IN (...)` 的成员批量查询与一次用户组批量查询,无 N+1。 +- 返回字段:`business_user_group_id`、组编码、组名称、组启用状态、组业务线。 +- **筛选必须用 `EXISTS` 子查询,不得改用 JOIN**:`List` 在同一查询上先 `Count` 再 `Find`,JOIN 会使计数行膨胀并引入列歧义。备选方案 JOIN + `DISTINCT` 计数被否决(计数语义脆弱、易误改)。 +- 未分组口径:`business_owner_account_id IS NULL OR NOT EXISTS(该负责人的未删除成员关系)`。**停用组的负责人不计入未分组**,停用组仍需可被筛出并携带已停用标记。 +- 不改动 `Count`/`Find` 的既有分页与排序契约;`idx_shop_business_owner_account_id` 继续支撑关联与批量更新。 + +### 用户组成员维护 + +- 批量设置/清空成员的请求为账号 ID 数组;每个账号必须是启用平台用户,目标组必须是启用组。单事务内替换每个账号的原组关系,任一账号无效则全量回滚。停用组不得作为目标,且不得新增成员;停用后成员可改组或清空。 +- 采用「角色权限批量」先例:全量预读 + 预校验 + 单事务 + 同事务审计 + 业务回滚后独立短事务失败审计。备选「逐条尽力而为」被否决(与 PRD「设置会直接替换原所属组」的确定性语义不符)。 + +### 勾选批量交接 + +- 请求携带店铺 ID 集合与目标业务员 ID。清空表达沿用既有「可空字段 + 是否出现标志」模式(与单店更新的请求契约一致),不引入操作类型字符串。 +- 单事务:`ApplyShopIDFilter` + `id IN (...) FOR UPDATE` 锁定 → 命中数不等于请求数即失败 → 校验目标业务员为启用平台用户 → 统一更新 → 逐店写审计 → 检查受影响行数。任一项失败整批不写入。 +- 失败文案统一为「无权限操作该资源或资源不存在」,不区分无权、不存在与已删除。 + +### CSV 导入 + +- **格式固定为 CSV**,使用标准库 `encoding/csv` 流式解析;不引入 Excel 解析或生成能力,也不改动既有 `pkg/utils/excel.go`。 +- **模板由前端提供**。后端不提供模板下载端点、模板响应 DTO 或静态模板资源;固定列序、操作类型取值与编码要求只写入导入接口的 OpenAPI description。 +- 独立成表、独立 store、独立 task type 与队列,不复用设备导入任务表(其启动补偿按表绑定设备任务类型)。 +- 每行独立事务:成功行提交,失败行不写 `tb_shop` 并保留原值;任何一行失败不回滚其他已成功行。 +- 按批更新进度计数(进度写失败不回滚已提交行);任务收尾一次事务写汇总与行明细。 + +### 编码与解析 + +- 先剥离 UTF-8 BOM(沿用 `bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})` 既有做法)。 +- 剥离后若字节不是合法 UTF-8,则按既有 GBK 转换能力尝试解码;解码仍失败按**任务级失败**处理并给出明确原因。 +- 解码实现落在中立工具包(`pkg/utils`),使用仓库已依赖的 `golang.org/x/text/encoding/simplifiedchinese`。备选「直接调用 `pkg/fuiou.GBKToUTF8`」被否决:会让任务层依赖支付集成包,层级不成立;备选「改造 `pkg/fuiou` 抽出共享工具」被否决:属于需求未触碰模块的重构。 +- 表头必须与固定列序完全一致;不一致即**任务级失败**并给出明确原因,不进入逐行阶段。 +- 行号从**数据首行**起计(表头不计入),写入行明细。 +- **不设行数硬上限**,不新增体积常量:体积沿用既有上传与下载链路的校验,不复制设备/资产场景的 1000 行与 10MB 场景常量。 +- 任务级失败(文件不可下载、格式或表头不符、编码无法解码、无数据行)与行级失败(业务校验不通过)分开记录,任务级失败不产生行明细。 + +### 权限与审计 + +- 用户组维护、成员维护、勾选批量与 CSV 导入入口一律限超级管理员与平台用户(与既有导入入口及「代理不得设置店铺业务员」一致)。代理与企业返回 403。 +- 由于超管/平台数据范围恒为不受限,「行级无权」在现入口下不可达;「无权限与不存在使用同一文案、不泄露存在性」作为勾选批量的强制原则保留,导入侧失败原因枚举按实际可达收敛。 +- 新增一处共享的「有效平台业务员」谓词供导入与批量目标校验复用;**不重构**既有 5 处内联判定(`internal/query/shop/business_owner.go`、`internal/application/shop/create.go`、`internal/application/shop/update.go`、`internal/infrastructure/shop/recipient_resolver.go`、`internal/infrastructure/wallet/debit_event.go`)。 +- 审计:勾选批量用批次根事件(平台作用域,承载批次统计)+ 逐店子事件(店铺作用域);导入新增任务级动作码(创建 / 完成)与独立任务资源,逐行实际变更写店铺资源前后值审计(负责人前后值、操作者、时间、行备注)。审计写入与业务事实同事务,业务回滚后的失败/拒绝审计用独立短事务(ENG-TX-001)。 + +## 管理动作契约 + +### 用户组及成员 + +- `POST /business-user-groups`:提交 `code`、`name`、`business_line`(可选)、`sort`、`enabled`、`remark`。重复编码返回「业务用户组编码已存在」。 +- `GET /business-user-groups`、`GET /business-user-groups/:id`:返回组字段含业务线,供维护与筛选下拉使用。 +- `PUT /business-user-groups/:id`:允许更新名称、业务线、排序、启停、备注;`code` 永不允许修改。不存在或已删除返回既有资源不存在。 +- `DELETE /business-user-groups/:id`:须带二次确认;存在成员时返回「用户组仍有成员,只能停用或先移走成员」,不物理删除。 +- `PUT /business-user-groups/:id/members`:请求 `account_ids` 为非空数组;所有账号必须是启用平台用户且目标组启用。事务内替换每个账号原组关系,任一账号无效则全量回滚。`DELETE /business-user-groups/members` 使用同一校验清空指定账号归属。成功操作写成员前后审计。 + +### 店铺负责人批量交接 + +- `PUT /shops/business-owner/batch`:请求 `shop_ids`(非空、去重)及目标业务员 ID,或显式空值表示清空。先锁定并校验全部目标店铺,再统一更新 `tb_shop.business_owner_account_id` 并逐店写审计;任一目标无权、不存在、已删除或负责人无效时返回统一失败且整批无写入。 + +### 店铺负责人 CSV 导入 + +- `POST /shops/business-owner-imports`:请求携带上传后的 `file_key`。校验 `file_key` 位于 `shop-imports/` 前缀下且扩展名为 `.csv`(沿用既有导入服务的 file_key 前缀校验先例),创建待处理任务并入队。 +- `GET /shops/business-owner-imports`、`GET /shops/business-owner-imports/:id`:任务列表与详情,详情返回成功数、失败数、逐行失败原因与任务级错误原因。 +- **OpenAPI description 必须写明模板要求**:文件格式 CSV;固定列序「店铺编码、操作类型、业务员登录账号、备注」;操作类型取值「换绑」「清空」;换绑必须填业务员登录账号,清空不得填;备注可选并写入该行审计;编码 UTF-8 且可带 BOM,非 UTF-8 时按既有 GBK 转换能力尝试解码,仍失败则明确报错;店铺以店铺编码唯一定位,业务员以登录账号唯一定位。 + +### 读侧投影 + +- 扩展既有店铺列表、详情与筛选:返回 `business_owner_account_id`、负责人名称、业务用户组 ID、组编码、组名称、组启用状态与组业务线;均从当前负责人—成员关系实时推导。 +- 筛选支持按用户组、按用户组业务线与按未分组。组筛选只匹配当前负责人所属组;负责人为空或无成员关系时归入未分组;停用组可被筛出且带停用标记。历史店铺不回填,负责人改组或组停用后下一次读立即反映变化。**不含导出**。 + +## Risks / Trade-offs + +- 实时推导增加读侧查询次数 → 以 `account_id` 唯一索引做批量装配,不引入冗余字段换一致性风险;明确不改成 JOIN 以免破坏既有 `Count` 语义。 +- 「有效平台业务员」判定将有第 6 处实现 → 新增共享谓词并只在新链路使用;既有 5 处保留为 As-Is,避免顺手重构。 +- 逐行事务遇大文件时任务时长与数据库往返增加 → 明确不设行数上限、按批更新进度;不引入批量合并事务(会破坏「一行失败不影响其他行」的语义)。 +- GBK 解码是启发式(GBK 解码器对多数字节对不报错)→ 不静默改写内容:仅在 UTF-8 校验失败时尝试解码,解码报错即任务级失败并给出原因。 +- 编排可产生重复投递 → worker 依状态机幂等(待处理首跑,处理中被视为中断重跑,终态直接跳过),沿用既有导入场景语义。 +- 批量与导入并发 → 均以行锁 + 单事务保证原子性;读取接受当前已提交快照。 + +## Migration Plan + +1. 新增成对迁移创建用户组表、成员表与三处索引,不回填历史组归属与历史分组快照;`down` 带守卫,存在数据时拒绝静默回滚。 +2. 先部署读侧空组兼容(新字段为空、新筛选不命中),再启用用户组维护、成员维护、勾选批量与 CSV 导入入口。 +3. 在 ENG-TEST-001 指定的唯一测试面验证:迁移 up/down/up、编码唯一、账号唯一、停用组保留成员、负责人改组实时推导、未分组与业务线筛选、批量原子失败、导入混合结果与任务级/行级失败分离、无行数上限。 + +## Open Questions + +无。GBK 解码落点、批量审计作用域、上传用途命名与入口角色范围已在本设计内定稿。 diff --git a/openspec/changes/archive/2026-09-14-add-shop-salesperson-groups/proposal.md b/openspec/changes/archive/2026-09-14-add-shop-salesperson-groups/proposal.md new file mode 100644 index 0000000..2ba23fd --- /dev/null +++ b/openspec/changes/archive/2026-09-14-add-shop-salesperson-groups/proposal.md @@ -0,0 +1,28 @@ +## Why + +店铺负责人只能逐店维护,平台用户也没有稳定的业务分类;店铺按组统计、筛选和批量交接缺少统一、可追溯口径。 + +本 Change 落实 AUG26-003:业务用户组只描述平台用户的业务分类,不改变角色权限、数据范围或代理店铺分组;店铺所属组始终由当前负责人实时推导。 + +## What Changes + +- 新增业务用户组(名称、创建后不可修改的稳定编码、所属业务线、排序、启停、备注)及平台用户单组归属。 +- 新增店铺负责人勾选批量设置/清空和 CSV 导入:勾选操作全量预校验且整批原子;导入逐行独立执行并返回逐行明细。 +- 店铺列表、详情与筛选显示实时推导的负责人业务用户组及其业务线,并支持按用户组、按用户组业务线和未分组筛选;停用组保留成员与展示,不影响登录、权限、数据范围或负责人。 +- 新增 `shop_import` 上传用途。导入模板由前端提供,后端只在导入接口文档中描述固定列序与编码要求,不提供模板下载端点,不引入 Excel 解析或生成能力。 + +## Capabilities + +### New Capabilities + +- `business-user-group`: 用户组生命周期、成员归属、店铺负责人交接与导入及推导查询。 + +### Modified Capabilities + +- 无。现有身份权限和数据范围拒绝行为保持不变。 + +## Impact + +影响平台用户、店铺列表/详情与筛选、上传用途、导入任务、数据范围校验、审计、DTO/OpenAPI 和新增 Schema。 + +非目标:店铺导出场景(`internal/exporter` 无店铺场景,列为后续独立需求)、用户组层级与组管理员、`openspec/specs/shop-bulk-import` 维护者手工执行的离线 SQL 生成器。 diff --git a/openspec/changes/archive/2026-09-14-add-shop-salesperson-groups/specs/business-user-group/spec.md b/openspec/changes/archive/2026-09-14-add-shop-salesperson-groups/specs/business-user-group/spec.md new file mode 100644 index 0000000..d4b4e10 --- /dev/null +++ b/openspec/changes/archive/2026-09-14-add-shop-salesperson-groups/specs/business-user-group/spec.md @@ -0,0 +1,71 @@ +## Purpose + +以不改变既有角色和数据范围的方式标记平台用户业务分类,并将店铺负责人和业务用户组的批量维护、推导展示与审计定义为一致的可观察行为。 + +## ADDED Requirements + +### Requirement: 业务用户组生命周期与成员归属 +系统 SHALL 允许超级管理员和平台用户维护业务用户组的名称、创建时必填且在未删除组内唯一的稳定编码、可选所属业务线、排序、启用状态和备注;编码创建后 MUST NOT 修改。所属业务线取值范围 MUST 为标品、智能产品、其他三项单值,且 MAY 为空。用户组不得设置上级、层级或组管理员。仅无成员用户组可由超级管理员或平台用户二次确认删除;有成员时只能停用或先移走成员。 + +每个启用平台用户最多属于一个启用业务用户组。超级管理员和平台用户可选择多个平台用户,批量设置至一个启用组或批量清空归属;设置直接替换原归属,停用组不得作为目标。业务用户组 MUST NOT 改变后台角色、登录、权限、数据范围或店铺具体负责人归属。停用后不得新增成员,已有成员关系保留并显示已停用,管理员仍可将成员改组或清空。平台账号被删除后其成员关系 MUST 保留并继续参与店铺推导。 + +#### Scenario: 批量替换平台用户分组 +- **WHEN** 管理员选择多个启用平台用户并指定一个启用业务用户组 +- **THEN** 系统将每个目标用户的原分组直接替换为目标组,不改变其角色、数据范围和登录状态,且每个用户至多保留一个分组 + +#### Scenario: 停用含成员用户组 +- **WHEN** 管理员停用仍含平台用户成员的业务用户组 +- **THEN** 系统保留成员关系并标记组已停用,拒绝新增成员和把该组作为批量目标,但允许后续改组或清空成员 + +#### Scenario: 维护用户组所属业务线 +- **WHEN** 管理员创建或更新业务用户组的所属业务线 +- **THEN** 系统按标品、智能产品、其他三项单值保存该字段,并允许清空为空值 + +### Requirement: 店铺负责人和所属组实时推导 +店铺 SHALL 以当前绑定的平台业务员作为负责人。店铺所属业务用户组及其业务线 MUST 实时由该负责人的当前用户组推导,不得把组 ID 或业务线冗余写入店铺;负责人变更、负责人改组或组停用后,店铺列表、详情和筛选的结果立即按新关系变化。店铺无负责人、负责人无分组时所属组为空;负责人所属组停用时仍返回该组并明确其已停用。 + +店铺列表与详情 SHALL 返回负责人账号标识、负责人名称、业务用户组标识、组编码、组名称、组启用状态与组业务线。店铺列表 SHALL 支持按业务用户组、按用户组业务线与按未分组筛选;未分组 MUST 包含无负责人与负责人无成员关系两种情形,停用组的负责人 MUST NOT 归入未分组,且停用组 MUST 可被筛出并携带已停用标记。 + +#### Scenario: 负责人改组改变店铺展示 +- **WHEN** 某平台业务员的业务用户组被替换或清空 +- **THEN** 该业务员当前负责的所有店铺在列表、详情和按组筛选中即时呈现新的组或空组,无需更新店铺记录 + +#### Scenario: 按未分组与业务线筛选店铺 +- **WHEN** 管理员按未分组或按某一用户组业务线筛选店铺列表 +- **THEN** 未分组结果包含无负责人与负责人无成员关系的店铺、且不含负责人属于停用组的店铺;业务线结果只包含负责人当前所属组业务线匹配的店铺 + +#### Scenario: 停用组仍可筛出 +- **WHEN** 某业务用户组被停用且其成员仍为部分店铺的负责人 +- **THEN** 系统仍按该组筛选出这些店铺,并在结果中标记该组已停用 + +### Requirement: 店铺负责人批量交接 +超级管理员和平台用户 SHALL 勾选多家店铺,批量设置为一个有效平台业务员或批量清空负责人。批量操作 MUST 在提交前校验全部目标店铺均存在、未删除且可管理,并校验目标业务员为启用平台用户;任一项失败时整批不修改并返回统一失败结果,且 MUST NOT 形成无权、不存在与已删除之间的可枚举差异。成功时必须为每家店铺记录负责人前后值、操作者、时间和入口审计,并同时保留批次汇总结果。 + +#### Scenario: 勾选批量包含越权或不存在店铺 +- **WHEN** 管理员提交的店铺集合中任一店铺不在其可管理范围、已删除或不存在 +- **THEN** 系统不修改集合中任何店铺负责人,并返回统一失败结果且不泄露该店铺存在性 + +#### Scenario: 勾选批量清空负责人 +- **WHEN** 管理员对多家店铺提交清空负责人的批量请求且全部校验通过 +- **THEN** 系统在同一事务内清空全部目标店铺负责人,为每家店铺记录负责人前后值审计,店铺所属组随之推导为空 + +### Requirement: 店铺负责人 CSV 导入 +超级管理员和平台用户 SHALL 通过 CSV 导入批量设置或清空店铺负责人;导入 MUST 异步执行并返回成功数、失败数与逐行明细。导入入口仅限超级管理员与平台用户,故导入失败原因不含数据范围无权。 + +导入文件 MUST 为 CSV,编码 MUST 为 UTF-8 且 MAY 带 BOM;非 UTF-8 时系统 SHALL 按既有 GBK 转换能力尝试解码,仍失败时按任务级失败并给出明确原因。文件首行表头 MUST 与固定列序「店铺编码、操作类型、业务员登录账号、备注」完全一致,不一致时为任务级失败。操作类型 MUST 为「换绑」或「清空」;换绑 MUST 填写业务员登录账号,清空 MUST NOT 填写业务员登录账号。备注可选,填写时 MUST 写入该行审计。店铺 MUST 以店铺编码唯一定位,业务员 MUST 以登录账号唯一定位。 + +每行 MUST 独立校验与执行:有效行成功更新,失败行 MUST 保留原值且不影响其他已成功行;结果 MUST 保存行号(自数据首行起计,表头不计入)、成功或失败状态与失败原因。任务级失败与行级失败 MUST 分开记录,任务级失败不产生行明细。导入 MUST NOT 设置行数硬上限。所有实际变更 MUST 记录店铺负责人前后值与行备注审计。 + +行级失败原因 MUST 限定为:店铺编码不存在或已删除、操作类型非法、换绑未填写业务员登录账号、清空却填写了业务员登录账号、业务员登录账号不存在或非启用平台用户、行格式错误。任务级失败原因 MUST 覆盖:文件格式或表头不符、编码无法解码、文件无数据行。 + +#### Scenario: 导入包含有效和无效行 +- **WHEN** 店铺负责人 CSV 导入同时包含可成功换绑或清空的行与校验失败的行 +- **THEN** 系统更新每个有效行、保留失败行原值,并返回逐行行号、失败原因及成功与失败汇总 + +#### Scenario: 表头或编码不符整批失败 +- **WHEN** 上传文件的表头与固定列序不一致,或文件既非合法 UTF-8 又无法按既有 GBK 转换能力解码 +- **THEN** 系统不处理任何行、不产生行明细,并返回明确的任务级失败原因 + +#### Scenario: 换绑与清空字段互斥 +- **WHEN** 某行操作类型为换绑但业务员登录账号为空,或操作类型为清空但填写了业务员登录账号 +- **THEN** 该行失败并保留店铺原负责人,其他行照常处理 diff --git a/openspec/changes/archive/2026-09-14-add-shop-salesperson-groups/tasks.md b/openspec/changes/archive/2026-09-14-add-shop-salesperson-groups/tasks.md new file mode 100644 index 0000000..95eac8f --- /dev/null +++ b/openspec/changes/archive/2026-09-14-add-shop-salesperson-groups/tasks.md @@ -0,0 +1,30 @@ +## 1. 用户组与读侧推导 + +- [x] 1.1 追踪平台用户、店铺负责人、数据范围、既有批量操作、CSV 解析与 BOM/GBK 处理、审计调用链,确认负责人字段与「有效平台业务员」的既有判定分布。证据:侦查结论落在本 Change design.md;既有 6 处内联判定已定位(`internal/application/shop/create.go`、`update.go`、`internal/query/shop/business_owner.go`、`internal/infrastructure/shop/recipient_resolver.go`、`internal/infrastructure/wallet/debit_event.go`),按 As-Is 未重构。 +- [x] 1.2 新增成对迁移:业务用户组表(名称、稳定编码、所属业务线、排序、状态、备注)、平台用户—组关联表,以及编码唯一、账号唯一、组维度三处索引;`down` 带守卫,不回填历史组归属,不建外键。证据:`migrations/000221_add_business_user_group_and_shop_owner_import.{up,down}.sql`;隔离库 `./scripts/migrate.sh up`(221/u 332ms)→ `down`(221/d 252ms)→ `up` 全部成功;有数据时 `down` 被守卫拒绝(`tb_shop_business_owner_import_task 已存在导入任务事实,禁止删表回滚`)。 +- [x] 1.3 新增模型与常量:用户组、成员关联、业务线单值与启停常量、导入任务状态复用既有导入状态机。证据:`internal/model/business_user_group.go`、`internal/model/shop_business_owner_import_task.go`、`pkg/constants/business_user_group.go`、`pkg/constants/shop_business_owner_import.go`;状态机复用 `model.ImportTaskStatus*`。 +- [x] 1.4 实现用户组 CRUD、启停、无成员二次确认删除与所属业务线维护,写审计;禁止修改编码,不引入层级与组管理员。证据:`internal/application/businessusergroup/service.go`;实测创建成功、重复编码 400「业务用户组编码已存在」、停用成功、含成员删除 1050「用户组仍有成员,只能停用或先移走成员」。 +- [x] 1.5 实现平台用户批量设置/清空归属:启用平台用户与启用组校验、单事务替换原归属、停用组拒绝作为目标、同事务成员前后审计与业务回滚后的独立失败审计;新增共享「有效平台业务员」谓词供本 Change 复用,不重构既有内联判定。并发正确性以**账号行锁**(`tb_account` `FOR UPDATE` + `ORDER BY id ASC`)实现逐账号串行化,同时消除「清空时锁不到成员行」的幻读与「多账号相反顺序」的死锁;唯一索引冲突仍映射为稳定业务错误(幂等冲突兜底)。证据:`pkg/constants.IsAvailablePlatformBusinessOwner`;实测批量设置成功(group_id=1,账号 142/149)、含无效账号整批未修改(1001)、停用组拒绝(1050「目标用户组已停用,不能作为成员归属目标」)、清空归属成功(`DELETE /business-user-groups/members` → group_id=0 且账号回到未分组)。清空端点初次注册被 `DELETE /:id` 吞掉(返回「无效的路径ID」),已按「静态路径先于动态参数」修正 `internal/routes/business_user_group.go` 注册顺序并复测通过。 +- [x] 1.6 扩展店铺列表/详情 DTO 与 Query:实时返回负责人账号、负责人名称、组标识、组编码、组名称、组启用状态与组业务线;新增按用户组、按用户组业务线、按未分组三个筛选,使用存在性子查询而非 JOIN,不将组或业务线写入店铺。证据:`internal/query/shop/business_owner.go`(新增 `loadBusinessUserGroups` 批量装配 + 三个 EXISTS 条件,Count/Find 未改 JOIN);实测 `business_user_group_id=1` 命中 13 家且逐店返回组编码/名称/启用标记,`ungrouped=true` 命中 11 家且停用组负责人未计入未分组。 + +## 2. 店铺负责人批量交接 + +- [x] 2.1 实现勾选店铺批量设置/清空:全量预校验(存在性、未删除、可管理、目标业务员有效)+ 行锁 + 单事务更新 + 逐店前后值审计 + 批次根事件(平台作用域)与批次汇总;任一项失败整批不改,失败文案不区分无权与不存在。证据:`internal/application/shop/batch_business_owner.go` + `internal/infrastructure/audit/business_user_group.go`;实测成功批次返回 batch_key/shop_count、清空成功、含不存在店铺批次返回 1005 统一文案且 549 负责人保持 142(无部分写入)、缺失 `business_owner_account_id` 被拒绝。 + +## 3. 店铺负责人 CSV 导入 + +- [x] 3.1 搭建导入场景骨架:新任务表与模型(任务号、文件键、状态、成功/失败计数、行明细、任务级错误、操作者快照)、store、任务类型与队列常量、`QueueForTaskType` 分支与队列权重、运行时与 worker 的 store 装配、worker 处理器注册与启动补偿、发布门禁的未完成异步任务表清单。证据:`internal/store/postgres/shop_business_owner_import_task_store.go`、`pkg/constants/constants.go`(`TaskTypeShopBusinessOwnerImport` / `QueueShopBusinessOwnerImport` / `QueueForTaskType` 分支 / `DefaultTaskQueueWeights` 权重 4)、`pkg/queue/handler.go`、`internal/bootstrap/{stores,worker_stores,services,handlers,types}.go`、`cmd/worker/main.go`(`rescuePendingShopBusinessOwnerImportTasks`)、`internal/infrastructure/releasegate/checker.go`。 +- [x] 3.2 新增 `shop_import` 上传用途:前缀 `shop-imports/`、CSV 内容类型,同步上传请求的用途枚举与中文描述、对象存储用途映射、上传接口文档的用途表;建导入任务时校验 `file_key` 前缀与 `.csv` 扩展名。证据:`pkg/constants/shop_business_owner_import.go`、`pkg/storage/types.go`、`pkg/storage/service.go`、`internal/model/dto/storage_dto.go`、`internal/routes/storage.go`;实测 upload-url 返回 `shop-imports/2026/09/14/.csv`,建任务校验拒绝非本目录/非 csv。入队失败必须落库:`MarkFailed` 不带状态守卫(入队失败时任务仍为待处理)并回填 `error_message`;实测真实队列不可用条件下响应与库内 `status=4 error_message="导入任务入队失败"` 一致,启动补偿只扫待处理状态故不会重跑已失败任务。 +- [x] 3.3 实现 CSV 解析:剥离 UTF-8 BOM,UTF-8 校验失败时按既有 GBK 转换能力回退解码(解码实现置于中立工具包,不依赖支付集成包),仍失败按任务级失败报错;表头必须与固定列序完全一致,不符即任务级失败且不进入逐行阶段。证据:`pkg/utils/encoding.go`(`simplifiedchinese`,任务层不 import `pkg/fuiou`);实测 BOM 与 GBK 文件均处理为 4 成功 2 失败,表头不符任务级失败且 items=0,不可解码文件任务级失败。列数异常的坏行必须走**行级**路径,故解析器设 `FieldsPerRecord = -1`(否则标准库在首条记录定型字段数后直接返回 `ErrFieldCount`,会把行级问题误升级为任务级失败);实测 3 列/5 列坏行 CSV:`status=3 total=5 ok=3 fail=2`,坏行记「行格式错误」(行号 3、4),其余行照常成功。 +- [x] 3.4 实现逐行执行:每行独立事务,成功行提交、失败行不写店铺并保留原值;行号自数据首行起计并写入明细;按批更新进度计数;任务级失败与行级失败分开记录,任务级失败不产生行明细;不设行数硬上限,不新增体积常量。证据:`internal/task/shop_business_owner_import.go`(`processRow` 独立事务、`constants.ShopBusinessOwnerImportProgressBatchSize=100`);实测 UTF-8 混合行 4/2 且失败行保留原值;无行数上限用 1200 行任务验证。按批进度计数必须同时写入已知行总数(否则处理中中间态违反 `chk_shop_business_owner_import_task_counts` 而静默丢失进度):实测任务处理中 `status=2 total=1200 success=99 fail=1` 正常落库。大文件成功路径:1200 行(30 家自建店铺循环)实测 `total=1200 ok=1195 fail=5`,5 条失败覆盖全部 5 类行级原因,且审计子事件 1195 条与成功数一致。 +- [x] 3.5 实现导入路由、Handler 与 DTO:任务创建、列表、详情;入口限超级管理员与平台用户;在导入接口的 OpenAPI description 写明 CSV 模板要求;不新增模板下载端点、模板响应 DTO 或模板资源。证据:`internal/routes/business_user_group.go`(`shopOwnerImportDoc` 含列序/互斥/备注/编码/唯一定位要求)、`internal/handler/admin/shop_business_owner_import.go`;实测创建/列表/详情成功,代理账号 8 个入口全部 403;生成文档中无模板端点。 +- [x] 3.6 注册审计动作码与资源:导入任务的创建与完成动作、独立任务资源定义,以及逐行实际变更的店铺负责人前后值与行备注审计;按 ENG-ROUTE-001 同步文档占位装配。证据:`pkg/constants/audit.go`、`internal/infrastructure/audit/registry.go`、`internal/query/audit/timeline.go`、`pkg/openapi/handlers.go`、`cmd/api/docs.go`、`cmd/gendocs/main.go`;实测审计事件查询返回创建与完成事件;行备注写入事件 Metadata。 + +## 4. 验证 + +- [x] 4.1 在 ENG-TEST-001 指定的唯一测试面验证:迁移 up/down/up、编码唯一与账号唯一、停用组保留成员且拒绝作为目标、负责人改组实时推导、未分组与业务线筛选、停用组可筛出、批量原子失败、导入混合结果、表头与编码任务级失败、无行数上限;fixture 仅增删本 Change 自己的记录。证据:迁移 up(221/u)/down/down 被守卫拒绝/up(221);编码唯一 400;停用组 1050 拒绝作为目标且保留成员;未分组与业务线筛选、停用组可筛出且带停用标记;批量交接原子失败返回 1005 且无部分写入。**负责人改组实时推导逐店比对**:对业务员 142 名下全部 11 家店铺(id 1、5、535、536、540、541、542、543、549、1464、1467)逐一比对接组前/改组后/清空后三次列表与详情返回的组字段——改组后 11/11 全部由 `AUG26-GA(standard)` 变为 `AUG26-GB(smart)`,清空后 11/11 组字段全空;库端证明 `tb_shop` 无任何组列(`information_schema` 查 `%group%`/`%business_line%` 返回 0 列)且 11 家店铺行 `business_owner_account_id` 前后全等、`updated_at` 未被组变更改写。**企业账号 403**:用既有启用企业账号(id 150、`user_type=4`、`enterprise_id=2`)经既有 TokenManager 铸造会话,逐个打 11 个新入口全部 HTTP 403 + `1005 无权限操作该资源或资源不存在`。fixture 清理已执行(见 4.1 清理记录)。 +- [x] 4.2 运行 `gofmt -w`(变更 Go 文件)、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`、`openspec validate add-shop-salesperson-groups --strict`、`openspec doctor --json` 和 `./scripts/context-health.sh`;自动化测试按项目决策为 N/A。 + 已完成:`gofmt -w`(变更/新增文件 `gofmt -l` 为空)、`go build ./cmd/api ./cmd/worker`(exit 0/0)、`go run cmd/gendocs/main.go`(成功生成且连续两次输出一致)、`openspec validate add-shop-salesperson-groups --strict`(Change is valid)、`openspec validate --all`(36 passed / 0 failed)。 + 历史失败(已修复):`./scripts/context-health.sh` 曾输出「Requirement 证据链与 Specs 不一致」,差异集合为 `agent-funds-commission::回溯明细关联查询与导出`、`agent-funds-commission::套餐退款佣金回溯` 两条主 spec Requirement 缺少 `requirement-evidence.json` 证据行(证据侧无多余项)。归因为既有漂移:承载方 `openspec/specs/agent-funds-commission/spec.md` 最后由 `18796b16 归档`(14:25)、`requirement-evidence.json` 最后由 `67893617 feat(退款): AUG26-006…`(12:11)修改,均早于本 Change 起始基点 `957a235`,本 Change 未改动这两个文件;`business-user-group` 当时只有 delta spec(context-health 只扫 `openspec/specs/**`),故归档前还需为该 capability 补证据行与入口矩阵行。 + 本轮补齐与复验(2026-09-14 归档轮,真实执行原文):①既有漂移修复——在 `docs/verification/context-reset/requirement-evidence.json` 追加上述 2 条证据行(真实只读命令:`grep -nE 'uk_commission_clawback_refund_original|chk_commission_clawback_amount|chk_commission_clawback_withdrawable|chk_commission_clawback_status' migrations/000220_add_commission_clawback_record.up.sql`,命中 23/25/26/27 行;`grep -n 'ledger_clawback' internal/store/postgres/commission_record_store.go internal/exporter/commission_record_scene.go`,命中两处 UNION ALL 合并查询),并在 `entry-capability-requirement-matrix.json` 补 `GET /api/admin/shops/{shop_id}/commission-records/{id}` http 行、为 `constants.TaskTypeRefundCommissionRecovery` 行补 `agent-funds-commission::套餐退款佣金回溯`。②本 Change 归档同步——新建主 spec `openspec/specs/business-user-group/spec.md`(Purpose 取 delta 原文,4 条 ADDED Requirement 与 11 个 Scenario 全量落地,末尾按既有惯例补「## 可达操作索引」列 11 个新端点),`requirement-evidence.json` 追加 4 条 `business-user-group` 证据行(真实只读 `grep -nE` 命中 service/store/query/constants 原文),矩阵补 11 条 http 行并为 `GET /api/admin/shops`、`GET /api/admin/shops/{id}`、`constants.TaskTypeShopBusinessOwnerImport` 补 Requirement 覆盖(`linked` 双向相等)。 + 结果:`./scripts/context-health.sh` → 「Context 健康检查通过」(exit 0,含 `openspec doctor --json` `"healthy": true`、`openspec validate --all` 与 gendocs 连续两次产物一致);`openspec validate --all` → `Totals: 37 passed, 0 failed (37 items)`;`openspec doctor --json` → `"healthy": true`、`"status": []`。 diff --git a/openspec/specs/business-user-group/spec.md b/openspec/specs/business-user-group/spec.md new file mode 100644 index 0000000..f5b3408 --- /dev/null +++ b/openspec/specs/business-user-group/spec.md @@ -0,0 +1,93 @@ +# 业务用户组与店铺负责人当前行为 + +## Purpose + +以不改变既有角色和数据范围的方式标记平台用户业务分类,并将店铺负责人和业务用户组的批量维护、推导展示与审计定义为一致的可观察行为。 + +## Requirements + +### Requirement: 业务用户组生命周期与成员归属 +系统 SHALL 允许超级管理员和平台用户维护业务用户组的名称、创建时必填且在未删除组内唯一的稳定编码、可选所属业务线、排序、启用状态和备注;编码创建后 MUST NOT 修改。所属业务线取值范围 MUST 为标品、智能产品、其他三项单值,且 MAY 为空。用户组不得设置上级、层级或组管理员。仅无成员用户组可由超级管理员或平台用户二次确认删除;有成员时只能停用或先移走成员。 + +每个启用平台用户最多属于一个启用业务用户组。超级管理员和平台用户可选择多个平台用户,批量设置至一个启用组或批量清空归属;设置直接替换原归属,停用组不得作为目标。业务用户组 MUST NOT 改变后台角色、登录、权限、数据范围或店铺具体负责人归属。停用后不得新增成员,已有成员关系保留并显示已停用,管理员仍可将成员改组或清空。平台账号被删除后其成员关系 MUST 保留并继续参与店铺推导。 + +#### Scenario: 批量替换平台用户分组 +- **WHEN** 管理员选择多个启用平台用户并指定一个启用业务用户组 +- **THEN** 系统将每个目标用户的原分组直接替换为目标组,不改变其角色、数据范围和登录状态,且每个用户至多保留一个分组 + +#### Scenario: 停用含成员用户组 +- **WHEN** 管理员停用仍含平台用户成员的业务用户组 +- **THEN** 系统保留成员关系并标记组已停用,拒绝新增成员和把该组作为批量目标,但允许后续改组或清空成员 + +#### Scenario: 维护用户组所属业务线 +- **WHEN** 管理员创建或更新业务用户组的所属业务线 +- **THEN** 系统按标品、智能产品、其他三项单值保存该字段,并允许清空为空值 + +### Requirement: 店铺负责人和所属组实时推导 +店铺 SHALL 以当前绑定的平台业务员作为负责人。店铺所属业务用户组及其业务线 MUST 实时由该负责人的当前用户组推导,不得把组 ID 或业务线冗余写入店铺;负责人变更、负责人改组或组停用后,店铺列表、详情和筛选的结果立即按新关系变化。店铺无负责人、负责人无分组时所属组为空;负责人所属组停用时仍返回该组并明确其已停用。 + +店铺列表与详情 SHALL 返回负责人账号标识、负责人名称、业务用户组标识、组编码、组名称、组启用状态与组业务线。店铺列表 SHALL 支持按业务用户组、按用户组业务线与按未分组筛选;未分组 MUST 包含无负责人与负责人无成员关系两种情形,停用组的负责人 MUST NOT 归入未分组,且停用组 MUST 可被筛出并携带已停用标记。 + +#### Scenario: 负责人改组改变店铺展示 +- **WHEN** 某平台业务员的业务用户组被替换或清空 +- **THEN** 该业务员当前负责的所有店铺在列表、详情和按组筛选中即时呈现新的组或空组,无需更新店铺记录 + +#### Scenario: 按未分组与业务线筛选店铺 +- **WHEN** 管理员按未分组或按某一用户组业务线筛选店铺列表 +- **THEN** 未分组结果包含无负责人与负责人无成员关系的店铺、且不含负责人属于停用组的店铺;业务线结果只包含负责人当前所属组业务线匹配的店铺 + +#### Scenario: 停用组仍可筛出 +- **WHEN** 某业务用户组被停用且其成员仍为部分店铺的负责人 +- **THEN** 系统仍按该组筛选出这些店铺,并在结果中标记该组已停用 + +### Requirement: 店铺负责人批量交接 +超级管理员和平台用户 SHALL 勾选多家店铺,批量设置为一个有效平台业务员或批量清空负责人。批量操作 MUST 在提交前校验全部目标店铺均存在、未删除且可管理,并校验目标业务员为启用平台用户;任一项失败时整批不修改并返回统一失败结果,且 MUST NOT 形成无权、不存在与已删除之间的可枚举差异。成功时必须为每家店铺记录负责人前后值、操作者、时间和入口审计,并同时保留批次汇总结果。 + +#### Scenario: 勾选批量包含越权或不存在店铺 +- **WHEN** 管理员提交的店铺集合中任一店铺不在其可管理范围、已删除或不存在 +- **THEN** 系统不修改集合中任何店铺负责人,并返回统一失败结果且不泄露该店铺存在性 + +#### Scenario: 勾选批量清空负责人 +- **WHEN** 管理员对多家店铺提交清空负责人的批量请求且全部校验通过 +- **THEN** 系统在同一事务内清空全部目标店铺负责人,为每家店铺记录负责人前后值审计,店铺所属组随之推导为空 + +### Requirement: 店铺负责人 CSV 导入 +超级管理员和平台用户 SHALL 通过 CSV 导入批量设置或清空店铺负责人;导入 MUST 异步执行并返回成功数、失败数与逐行明细。导入入口仅限超级管理员与平台用户,故导入失败原因不含数据范围无权。 + +导入文件 MUST 为 CSV,编码 MUST 为 UTF-8 且 MAY 带 BOM;非 UTF-8 时系统 SHALL 按既有 GBK 转换能力尝试解码,仍失败时按任务级失败并给出明确原因。文件首行表头 MUST 与固定列序「店铺编码、操作类型、业务员登录账号、备注」完全一致,不一致时为任务级失败。操作类型 MUST 为「换绑」或「清空」;换绑 MUST 填写业务员登录账号,清空 MUST NOT 填写业务员登录账号。备注可选,填写时 MUST 写入该行审计。店铺 MUST 以店铺编码唯一定位,业务员 MUST 以登录账号唯一定位。 + +每行 MUST 独立校验与执行:有效行成功更新,失败行 MUST 保留原值且不影响其他已成功行;结果 MUST 保存行号(自数据首行起计,表头不计入)、成功或失败状态与失败原因。任务级失败与行级失败 MUST 分开记录,任务级失败不产生行明细。导入 MUST NOT 设置行数硬上限。所有实际变更 MUST 记录店铺负责人前后值与行备注审计。 + +行级失败原因 MUST 限定为:店铺编码不存在或已删除、操作类型非法、换绑未填写业务员登录账号、清空却填写了业务员登录账号、业务员登录账号不存在或非启用平台用户、行格式错误。任务级失败原因 MUST 覆盖:文件格式或表头不符、编码无法解码、文件无数据行。 + +#### Scenario: 导入包含有效和无效行 +- **WHEN** 店铺负责人 CSV 导入同时包含可成功换绑或清空的行与校验失败的行 +- **THEN** 系统更新每个有效行、保留失败行原值,并返回逐行行号、失败原因及成功与失败汇总 + +#### Scenario: 表头或编码不符整批失败 +- **WHEN** 上传文件的表头与固定列序不一致,或文件既非合法 UTF-8 又无法按既有 GBK 转换能力解码 +- **THEN** 系统不处理任何行、不产生行明细,并返回明确的任务级失败原因 + +#### Scenario: 换绑与清空字段互斥 +- **WHEN** 某行操作类型为换绑但业务员登录账号为空,或操作类型为清空但填写了业务员登录账号 +- **THEN** 该行失败并保留店铺原负责人,其他行照常处理 + +## 可达操作索引 + +本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。 + +### 业务用户组维护 + +`POST /api/admin/business-user-groups`(新增业务用户组);`GET /api/admin/business-user-groups`(业务用户组列表);`GET /api/admin/business-user-groups/{id}`(业务用户组详情);`PUT /api/admin/business-user-groups/{id}`(修改业务用户组);`DELETE /api/admin/business-user-groups/{id}`(删除无成员业务用户组)。 + +### 业务用户组成员归属 + +`PUT /api/admin/business-user-groups/{id}/members`(批量设置平台用户业务用户组归属);`DELETE /api/admin/business-user-groups/members`(批量清空平台用户业务用户组归属)。 + +### 店铺负责人批量交接 + +`PUT /api/admin/shops/business-owner/batch`(批量设置或清空店铺负责人)。 + +### 店铺负责人 CSV 导入 + +`POST /api/admin/shops/business-owner-imports`(创建店铺负责人 CSV 导入任务);`GET /api/admin/shops/business-owner-imports`(查询店铺负责人导入任务列表);`GET /api/admin/shops/business-owner-imports/{id}`(查询店铺负责人导入任务详情)。 diff --git a/pkg/constants/audit.go b/pkg/constants/audit.go index d4ad33f..acbaef9 100644 --- a/pkg/constants/audit.go +++ b/pkg/constants/audit.go @@ -482,6 +482,20 @@ const ( AuditActionIntegrationAttemptStarted = "integration.attempt_started" // AuditActionIntegrationInboundReceived 表示入站回调处理前已记录的审计事实。 AuditActionIntegrationInboundReceived = "integration.inbound_received" + // AuditActionBusinessUserGroupCreated 表示创建业务用户组。 + AuditActionBusinessUserGroupCreated = "business_user_group.create" + // AuditActionBusinessUserGroupUpdated 表示更新业务用户组名称、业务线、排序或备注。 + AuditActionBusinessUserGroupUpdated = "business_user_group.update" + // AuditActionBusinessUserGroupEnabled 表示启用业务用户组。 + AuditActionBusinessUserGroupEnabled = "business_user_group.enable" + // AuditActionBusinessUserGroupDisabled 表示停用业务用户组。 + AuditActionBusinessUserGroupDisabled = "business_user_group.disable" + // AuditActionBusinessUserGroupDeleted 表示删除无成员业务用户组。 + AuditActionBusinessUserGroupDeleted = "business_user_group.delete" + // AuditActionBusinessUserGroupMembersUpdated 表示批量设置或清空平台用户的业务用户组归属。 + AuditActionBusinessUserGroupMembersUpdated = "business_user_group.update_members" + // AuditActionShopBusinessOwnerBatchUpdated 表示勾选店铺批量设置或清空平台业务员归属。 + AuditActionShopBusinessOwnerBatchUpdated = "shop.batch_update_business_owner" // AuditActionIotCardImportTaskCreated 表示创建 IoT 卡导入任务。 AuditActionIotCardImportTaskCreated = "iot_card_import_task.create" // AuditActionIotCardImportTaskCompleted 表示 IoT 卡导入任务完成。 @@ -494,6 +508,12 @@ const ( AuditActionAssetPackageBatchOrderTaskCreated = "asset_package_batch_order_task.create" // AuditActionAssetPackageBatchOrderTaskCompleted 表示资产套餐批量订购任务完成。 AuditActionAssetPackageBatchOrderTaskCompleted = "asset_package_batch_order_task.complete" + // AuditActionShopBusinessOwnerImportTaskCreated 表示创建店铺负责人 CSV 导入任务。 + AuditActionShopBusinessOwnerImportTaskCreated = "shop_business_owner_import_task.create" + // AuditActionShopBusinessOwnerImportTaskCompleted 表示店铺负责人 CSV 导入任务完成。 + AuditActionShopBusinessOwnerImportTaskCompleted = "shop_business_owner_import_task.complete" + // AuditActionShopBusinessOwnerImported 表示导入单行实际变更了店铺负责人归属。 + AuditActionShopBusinessOwnerImported = "shop.import_business_owner" // AuditActionOrderPackageInvalidateTaskCreated 表示创建订单套餐批量失效任务。 AuditActionOrderPackageInvalidateTaskCreated = "order_package_invalidate_task.create" // AuditActionOrderPackageInvalidateTaskCompleted 表示订单套餐批量失效任务完成。 @@ -637,6 +657,12 @@ const ( AuditResourceDeviceImportTask = "device_import_task" // AuditResourceAssetPackageBatchOrderTask 表示资产套餐批量订购任务资源。 AuditResourceAssetPackageBatchOrderTask = "asset_package_batch_order_task" + // AuditResourceShopBusinessOwnerImportTask 表示店铺负责人 CSV 导入任务资源。 + AuditResourceShopBusinessOwnerImportTask = "shop_business_owner_import_task" + // AuditResourceBusinessUserGroup 表示业务用户组资源。 + AuditResourceBusinessUserGroup = "business_user_group" + // AuditResourceShopBusinessOwnerBatch 表示店铺负责人批量交接批次根资源。 + AuditResourceShopBusinessOwnerBatch = "shop_business_owner_batch" // AuditResourceOrderPackageInvalidateTask 表示订单套餐批量失效任务资源。 AuditResourceOrderPackageInvalidateTask = "order_package_invalidate_task" // AuditResourceExportTask 表示业务导出任务资源。 @@ -779,6 +805,10 @@ const ( AuditResourceRoleApprovalOutbox = "approval_outbox" // AuditResourceRoleBatchTask 表示批量根事件的任务资源。 AuditResourceRoleBatchTask = "batch_task" + // AuditResourceRoleBusinessUserGroupTarget 表示业务用户组维护的主要目标。 + AuditResourceRoleBusinessUserGroupTarget = "business_user_group_target" + // AuditResourceRoleShopBusinessOwnerBatch 表示店铺负责人批量交接的批次根资源。 + AuditResourceRoleShopBusinessOwnerBatch = "shop_business_owner_batch" // AuditResourceRoleRetentionMonth 表示留存清理目标自然月。 AuditResourceRoleRetentionMonth = "retention_month" // AuditResourceRoleNotificationTarget 表示本次写操作的通知资源。 diff --git a/pkg/constants/business_user_group.go b/pkg/constants/business_user_group.go new file mode 100644 index 0000000..1bedb63 --- /dev/null +++ b/pkg/constants/business_user_group.go @@ -0,0 +1,51 @@ +package constants + +// 业务用户组是平台用户的业务分类,只用于店铺负责人推导、筛选与批量维护, +// 不进入登录、角色权限或数据范围判定。 + +// 业务用户组所属业务线取值,来源于 AUG26-003 的三枚举,允许为空表示未分类。 +const ( + // BusinessLineStandard 表示标品业务线。 + BusinessLineStandard = "standard" + // BusinessLineSmart 表示智能产品业务线。 + BusinessLineSmart = "smart" + // BusinessLineOther 表示其他业务线。 + BusinessLineOther = "other" +) + +// GetBusinessLineName 返回业务线单值的中文名称,空值表示未设置业务线。 +func GetBusinessLineName(businessLine string) string { + switch businessLine { + case BusinessLineStandard: + return "标品" + case BusinessLineSmart: + return "智能产品" + case BusinessLineOther: + return "其他" + default: + return "" + } +} + +// IsValidBusinessLine 判断业务线单值是否合法;允许为空表示未设置业务线。 +func IsValidBusinessLine(businessLine string) bool { + switch businessLine { + case "", BusinessLineStandard, BusinessLineSmart, BusinessLineOther: + return true + default: + return false + } +} + +// 业务用户组维护入口的角色约束与统一失败文案。 +const ( + // PlatformManagementForbiddenMessage 表示非超级管理员/平台账号访问平台维护入口的统一失败文案。 + PlatformManagementForbiddenMessage = "无权限操作该资源或资源不存在" +) + +// IsAvailablePlatformBusinessOwner 判定账号是否为当前有效的平台业务员。 +// 语义为「平台用户 + 启用 + 未软删」,供本 Change 的成员维护、批量交接与导入共用; +// 既有内联判定按 As-Is 保留,不回改。需要数据库条件的调用方自行在查询中表达同一条件。 +func IsAvailablePlatformBusinessOwner(userType, status int, deleted bool) bool { + return userType == UserTypePlatform && status == StatusEnabled && !deleted +} diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 3579804..cfb4f92 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -75,6 +75,8 @@ const ( // 订单套餐失效任务类型 TaskTypeOrderPackageInvalidate = "order:package:invalidate" // 批量失效订单套餐 TaskTypeAssetPackageBatchOrder = "asset:package:batch_order" // 资产套餐批量订购 + // 店铺负责人 CSV 导入任务类型,独立成队列以隔离逐行执行的长时任务。 + TaskTypeShopBusinessOwnerImport = "shop_business_owner:import" // 订单超时任务类型 TaskTypeOrderExpire = "order:expire" // 订单超时自动取消 @@ -209,34 +211,35 @@ const ( QueueDefault = "default" // 旧版默认队列(兼容历史任务) QueueLow = "low" // 旧版低优先级队列(兼容历史任务) - QueueEmailSend = TaskTypeEmailSend // 发送邮件任务队列 - QueueCommission = TaskTypeCommission // 分佣计算任务队列 - QueueIotCardImport = TaskTypeIotCardImport // IoT 卡导入任务队列 - QueueDeviceImport = TaskTypeDeviceImport // 设备导入任务队列 - QueueExportDispatch = TaskTypeExportDispatch // 导出分发任务队列 - QueueExportShard = TaskTypeExportShard // 导出分片任务队列 - QueueExportFinalize = TaskTypeExportFinalize // 导出收尾任务队列 - QueueCommissionStatsUpdate = TaskTypeCommissionStatsUpdate // 佣金统计更新任务队列 - QueueCommissionStatsSync = TaskTypeCommissionStatsSync // 佣金统计同步任务队列 - QueueCommissionStatsArchive = TaskTypeCommissionStatsArchive // 佣金统计归档任务队列 - QueuePollingRealname = TaskTypePollingRealname // 实名轮询任务队列 - QueuePollingCarddata = TaskTypePollingCarddata // 流量轮询任务队列 - QueuePollingPackage = TaskTypePollingPackage // 套餐轮询任务队列 - QueuePollingProtect = TaskTypePollingProtect // 保护期轮询任务队列 - QueuePollingCardStatus = TaskTypePollingCardStatus // 卡状态轮询任务队列 - QueuePackageFirstActivation = TaskTypePackageFirstActivation // 首次实名激活任务队列 - QueuePackageQueueActivation = TaskTypePackageQueueActivation // 主套餐排队激活任务队列 - QueuePackageDataReset = TaskTypePackageDataReset // 套餐流量重置任务队列 - QueueOrderPackageInvalidate = TaskTypeOrderPackageInvalidate // 批量失效订单套餐任务队列 - QueueAssetPackageBatchOrder = TaskTypeAssetPackageBatchOrder // 资产套餐批量订购任务队列 - QueueOrderExpire = TaskTypeOrderExpire // 订单超时取消任务队列 - QueueAutoPurchase = TaskTypeAutoPurchaseAfterRecharge // 充值后自动购包任务队列 - QueueAlertCheck = TaskTypeAlertCheck // 告警检查任务队列 - QueueDataCleanup = TaskTypeDataCleanup // 数据清理任务队列 - QueueDailyTrafficFlush = TaskTypeDailyTrafficFlush // 每日流量落盘任务队列 - QueueOutboxDeliver = TaskTypeOutboxDeliver // 公共 Outbox 投递队列 - QueueCardObservationSeries = TaskTypeCardObservationSeries // 卡观测事件序列队列 - QueueWeComApproval = "wecom:approval" // 企业微信审批同步与恢复队列 + QueueEmailSend = TaskTypeEmailSend // 发送邮件任务队列 + QueueCommission = TaskTypeCommission // 分佣计算任务队列 + QueueIotCardImport = TaskTypeIotCardImport // IoT 卡导入任务队列 + QueueDeviceImport = TaskTypeDeviceImport // 设备导入任务队列 + QueueExportDispatch = TaskTypeExportDispatch // 导出分发任务队列 + QueueExportShard = TaskTypeExportShard // 导出分片任务队列 + QueueExportFinalize = TaskTypeExportFinalize // 导出收尾任务队列 + QueueCommissionStatsUpdate = TaskTypeCommissionStatsUpdate // 佣金统计更新任务队列 + QueueCommissionStatsSync = TaskTypeCommissionStatsSync // 佣金统计同步任务队列 + QueueCommissionStatsArchive = TaskTypeCommissionStatsArchive // 佣金统计归档任务队列 + QueuePollingRealname = TaskTypePollingRealname // 实名轮询任务队列 + QueuePollingCarddata = TaskTypePollingCarddata // 流量轮询任务队列 + QueuePollingPackage = TaskTypePollingPackage // 套餐轮询任务队列 + QueuePollingProtect = TaskTypePollingProtect // 保护期轮询任务队列 + QueuePollingCardStatus = TaskTypePollingCardStatus // 卡状态轮询任务队列 + QueuePackageFirstActivation = TaskTypePackageFirstActivation // 首次实名激活任务队列 + QueuePackageQueueActivation = TaskTypePackageQueueActivation // 主套餐排队激活任务队列 + QueuePackageDataReset = TaskTypePackageDataReset // 套餐流量重置任务队列 + QueueOrderPackageInvalidate = TaskTypeOrderPackageInvalidate // 批量失效订单套餐任务队列 + QueueAssetPackageBatchOrder = TaskTypeAssetPackageBatchOrder // 资产套餐批量订购任务队列 + QueueShopBusinessOwnerImport = TaskTypeShopBusinessOwnerImport // 店铺负责人 CSV 导入任务队列 + QueueOrderExpire = TaskTypeOrderExpire // 订单超时取消任务队列 + QueueAutoPurchase = TaskTypeAutoPurchaseAfterRecharge // 充值后自动购包任务队列 + QueueAlertCheck = TaskTypeAlertCheck // 告警检查任务队列 + QueueDataCleanup = TaskTypeDataCleanup // 数据清理任务队列 + QueueDailyTrafficFlush = TaskTypeDailyTrafficFlush // 每日流量落盘任务队列 + QueueOutboxDeliver = TaskTypeOutboxDeliver // 公共 Outbox 投递队列 + QueueCardObservationSeries = TaskTypeCardObservationSeries // 卡观测事件序列队列 + QueueWeComApproval = "wecom:approval" // 企业微信审批同步与恢复队列 DefaultRetryMax = 5 // 默认任务最大重试次数 DefaultTimeout = 10 * time.Minute // 默认任务超时时间 @@ -286,6 +289,8 @@ func QueueForTaskType(taskType string) string { return QueueOrderPackageInvalidate case TaskTypeAssetPackageBatchOrder: return QueueAssetPackageBatchOrder + case TaskTypeShopBusinessOwnerImport: + return QueueShopBusinessOwnerImport case TaskTypeOrderExpire: return QueueOrderExpire case TaskTypeAutoPurchaseAfterRecharge: @@ -318,37 +323,38 @@ func QueueForTaskType(taskType string) string { // DefaultTaskQueueWeights 返回 Worker 默认监听的全部任务队列及其权重。 func DefaultTaskQueueWeights() map[string]int { return map[string]int{ - QueueCritical: 6, - QueueIotCardImport: 6, - QueueDeviceImport: 6, - QueueAutoPurchase: 5, - QueuePackageFirstActivation: 5, - QueuePackageQueueActivation: 5, - QueueOrderPackageInvalidate: 5, - QueueAssetPackageBatchOrder: 5, - QueueOrderExpire: 4, - QueueEmailSend: 4, - QueueExportDispatch: 4, - QueueExportFinalize: 4, - QueueCommission: 3, - QueueAlertCheck: 3, - QueueExportShard: 2, - QueueCommissionStatsUpdate: 2, - QueueCommissionStatsSync: 2, - QueueCommissionStatsArchive: 2, - QueuePollingRealname: 1, - QueuePollingCarddata: 1, - QueuePollingPackage: 1, - QueuePollingProtect: 1, - QueuePollingCardStatus: 1, - QueuePackageDataReset: 1, - QueueDataCleanup: 1, - QueueDailyTrafficFlush: 1, - QueueOutboxDeliver: 4, - QueueCardObservationSeries: 2, - QueueWeComApproval: 2, - QueueDefault: 1, - QueueLow: 1, + QueueCritical: 6, + QueueIotCardImport: 6, + QueueDeviceImport: 6, + QueueAutoPurchase: 5, + QueuePackageFirstActivation: 5, + QueuePackageQueueActivation: 5, + QueueOrderPackageInvalidate: 5, + QueueAssetPackageBatchOrder: 5, + QueueShopBusinessOwnerImport: 4, + QueueOrderExpire: 4, + QueueEmailSend: 4, + QueueExportDispatch: 4, + QueueExportFinalize: 4, + QueueCommission: 3, + QueueAlertCheck: 3, + QueueExportShard: 2, + QueueCommissionStatsUpdate: 2, + QueueCommissionStatsSync: 2, + QueueCommissionStatsArchive: 2, + QueuePollingRealname: 1, + QueuePollingCarddata: 1, + QueuePollingPackage: 1, + QueuePollingProtect: 1, + QueuePollingCardStatus: 1, + QueuePackageDataReset: 1, + QueueDataCleanup: 1, + QueueDailyTrafficFlush: 1, + QueueOutboxDeliver: 4, + QueueCardObservationSeries: 2, + QueueWeComApproval: 2, + QueueDefault: 1, + QueueLow: 1, } } diff --git a/pkg/constants/shop_business_owner_import.go b/pkg/constants/shop_business_owner_import.go new file mode 100644 index 0000000..76d1e69 --- /dev/null +++ b/pkg/constants/shop_business_owner_import.go @@ -0,0 +1,73 @@ +package constants + +import "time" + +// 店铺负责人 CSV 导入的场景常量。 +const ( + // StoragePurposeShopBusinessOwnerImport 表示店铺负责人 CSV 导入上传用途。 + StoragePurposeShopBusinessOwnerImport = "shop_import" + // ShopBusinessOwnerImportStoragePrefix 表示店铺负责人导入文件的对象存储目录。 + ShopBusinessOwnerImportStoragePrefix = "shop-imports" + // ShopBusinessOwnerImportTaskTimeout 表示单个店铺负责人导入任务的最长执行时间。 + ShopBusinessOwnerImportTaskTimeout = 2 * time.Hour + // ShopBusinessOwnerImportProgressBatchSize 表示导入按批更新进度计数的批大小。 + ShopBusinessOwnerImportProgressBatchSize = 100 + // ShopBusinessOwnerImportItemStatusSuccess 表示导入单行已成功变更店铺负责人。 + ShopBusinessOwnerImportItemStatusSuccess = 3 + // ShopBusinessOwnerImportItemStatusFailed 表示导入单行校验或执行失败并保留原值。 + ShopBusinessOwnerImportItemStatusFailed = 4 +) + +// 店铺负责人 CSV 导入的操作类型取值,行内互相排斥:换绑必须填业务员登录账号,清空不得填写。 +const ( + // ShopBusinessOwnerImportOperationRebind 表示把店铺负责人换绑到指定平台业务员。 + ShopBusinessOwnerImportOperationRebind = "换绑" + // ShopBusinessOwnerImportOperationClear 表示清空店铺负责人。 + ShopBusinessOwnerImportOperationClear = "清空" +) + +// ShopBusinessOwnerImportColumns 表示导入文件首行必须完全一致的固定列序。 +var ShopBusinessOwnerImportColumns = []string{"店铺编码", "操作类型", "业务员登录账号", "备注"} + +// 店铺负责人 CSV 导入的任务级失败原因,与行级失败原因分开记录。 +const ( + // ShopBusinessOwnerImportErrorFileFormat 表示文件格式或表头与固定列序不符。 + ShopBusinessOwnerImportErrorFileFormat = "文件格式或表头与固定列序不符" + // ShopBusinessOwnerImportErrorEncoding 表示文件既非合法 UTF-8 也无法按 GBK 解码。 + ShopBusinessOwnerImportErrorEncoding = "文件编码无法按 UTF-8 或 GBK 解码" + // ShopBusinessOwnerImportErrorNoDataRow 表示文件没有数据行。 + ShopBusinessOwnerImportErrorNoDataRow = "文件没有数据行" +) + +// 店铺负责人 CSV 导入的行级失败原因,取值集合固定,不包含数据范围无权。 +const ( + // ShopBusinessOwnerImportRowErrorShopMissing 表示店铺编码不存在或已删除。 + ShopBusinessOwnerImportRowErrorShopMissing = "店铺编码不存在或已删除" + // ShopBusinessOwnerImportRowErrorOperation 表示操作类型不是换绑或清空。 + ShopBusinessOwnerImportRowErrorOperation = "操作类型非法" + // ShopBusinessOwnerImportRowErrorOwnerRequired 表示换绑未填写业务员登录账号。 + ShopBusinessOwnerImportRowErrorOwnerRequired = "换绑必须填写业务员登录账号" + // ShopBusinessOwnerImportRowErrorOwnerForbidden 表示清空却填写了业务员登录账号。 + ShopBusinessOwnerImportRowErrorOwnerForbidden = "清空不得填写业务员登录账号" + // ShopBusinessOwnerImportRowErrorOwnerInvalid 表示业务员登录账号不存在或非启用平台用户。 + ShopBusinessOwnerImportRowErrorOwnerInvalid = "业务员登录账号不存在或非启用平台用户" + // ShopBusinessOwnerImportRowErrorFormat 表示该行列数与固定列序不一致。 + ShopBusinessOwnerImportRowErrorFormat = "行格式错误" +) + +const ( + // ShopOwnerImportAccessDescription 表示店铺负责人导入入口的角色说明。 + ShopOwnerImportAccessDescription = "仅超级管理员和平台账号可访问,代理与企业账号返回 403。" +) + +// GetShopBusinessOwnerImportItemStatusName 返回导入行状态中文名称。 +func GetShopBusinessOwnerImportItemStatusName(status int) string { + switch status { + case ShopBusinessOwnerImportItemStatusSuccess: + return "成功" + case ShopBusinessOwnerImportItemStatusFailed: + return "失败" + default: + return "未知" + } +} diff --git a/pkg/openapi/handlers.go b/pkg/openapi/handlers.go index 5f1729b..c0f0efa 100644 --- a/pkg/openapi/handlers.go +++ b/pkg/openapi/handlers.go @@ -76,6 +76,8 @@ func BuildDocHandlers() *bootstrap.Handlers { Refund: admin.NewRefundHandler(nil), OrderPackageInvalidate: admin.NewOrderPackageInvalidateHandler(nil), AssetPackageBatchOrder: admin.NewAssetPackageBatchOrderHandler(nil, nil), + BusinessUserGroup: admin.NewBusinessUserGroupHandler(nil, nil), + ShopBusinessOwnerImport: admin.NewShopBusinessOwnerImportHandler(nil), ClientWechat: app.NewClientWechatHandler(nil, nil, nil), SuperAdmin: admin.NewSuperAdminHandler(nil), SystemConfig: admin.NewSystemConfigHandler(nil, nil), diff --git a/pkg/queue/handler.go b/pkg/queue/handler.go index 0f9a638..bb5fefc 100644 --- a/pkg/queue/handler.go +++ b/pkg/queue/handler.go @@ -74,6 +74,7 @@ func (h *Handler) RegisterHandlers() *asynq.ServeMux { h.registerDeviceImportHandler() h.registerOrderPackageInvalidateHandler() h.registerAssetPackageBatchOrderHandler() + h.registerShopBusinessOwnerImportHandler() h.registerExportHandlers() h.registerCommissionStatsHandlers() h.registerCommissionCalculationHandler() @@ -141,6 +142,18 @@ func (h *Handler) registerAssetPackageBatchOrderHandler() { h.logger.Info("注册资产套餐批量订购任务处理器", zap.String("task_type", constants.TaskTypeAssetPackageBatchOrder)) } +func (h *Handler) registerShopBusinessOwnerImportHandler() { + handler := task.NewShopBusinessOwnerImportHandler( + h.db, + h.workerResult.Stores.ShopBusinessOwnerImportTask, + h.storage, + h.logger, + audit.NewWriter(audit.NewRegistry(), nil), + ) + h.mux.HandleFunc(constants.TaskTypeShopBusinessOwnerImport, handler.Handle) + h.logger.Info("注册店铺负责人导入任务处理器", zap.String("task_type", constants.TaskTypeShopBusinessOwnerImport)) +} + func (h *Handler) registerDeviceImportHandler() { deviceImportHandler := task.NewDeviceImportHandler( h.db, diff --git a/pkg/queue/types.go b/pkg/queue/types.go index e77dd6c..18da499 100644 --- a/pkg/queue/types.go +++ b/pkg/queue/types.go @@ -22,37 +22,38 @@ type OrderExpirer interface { // WorkerStores Worker 侧所有 Store 的集合 type WorkerStores struct { - AssetAllocationRecord *postgres.AssetAllocationRecordStore - IotCardImportTask *postgres.IotCardImportTaskStore - IotCard *postgres.IotCardStore - DeviceImportTask *postgres.DeviceImportTaskStore - ExportTask *postgres.ExportTaskStore - ExportShardTask *postgres.ExportShardTaskStore - Device *postgres.DeviceStore - DeviceSimBinding *postgres.DeviceSimBindingStore - ShopSeriesCommissionStats *postgres.ShopSeriesCommissionStatsStore - ShopPackageAllocation *postgres.ShopPackageAllocationStore - CommissionRecord *postgres.CommissionRecordStore - Shop *postgres.ShopStore - ShopSeriesAllocation *postgres.ShopSeriesAllocationStore - PackageSeries *postgres.PackageSeriesStore - Order *postgres.OrderStore - OrderItem *postgres.OrderItemStore - Package *postgres.PackageStore - PackageUsage *postgres.PackageUsageStore - PackageUsageDailyRecord *postgres.PackageUsageDailyRecordStore - PollingAlertRule *postgres.PollingAlertRuleStore - PollingAlertHistory *postgres.PollingAlertHistoryStore - DataCleanupConfig *postgres.DataCleanupConfigStore - DataCleanupLog *postgres.DataCleanupLogStore - AgentWallet *postgres.AgentWalletStore - AgentWalletTransaction *postgres.AgentWalletTransactionStore - AssetWallet *postgres.AssetWalletStore - AssetIdentifier *postgres.AssetIdentifierStore - PersonalCustomer *postgres.PersonalCustomerStore - PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore - OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore - AssetPackageBatchOrderTask *postgres.AssetPackageBatchOrderTaskStore + AssetAllocationRecord *postgres.AssetAllocationRecordStore + IotCardImportTask *postgres.IotCardImportTaskStore + IotCard *postgres.IotCardStore + DeviceImportTask *postgres.DeviceImportTaskStore + ExportTask *postgres.ExportTaskStore + ExportShardTask *postgres.ExportShardTaskStore + Device *postgres.DeviceStore + DeviceSimBinding *postgres.DeviceSimBindingStore + ShopSeriesCommissionStats *postgres.ShopSeriesCommissionStatsStore + ShopPackageAllocation *postgres.ShopPackageAllocationStore + CommissionRecord *postgres.CommissionRecordStore + Shop *postgres.ShopStore + ShopSeriesAllocation *postgres.ShopSeriesAllocationStore + PackageSeries *postgres.PackageSeriesStore + Order *postgres.OrderStore + OrderItem *postgres.OrderItemStore + Package *postgres.PackageStore + PackageUsage *postgres.PackageUsageStore + PackageUsageDailyRecord *postgres.PackageUsageDailyRecordStore + PollingAlertRule *postgres.PollingAlertRuleStore + PollingAlertHistory *postgres.PollingAlertHistoryStore + DataCleanupConfig *postgres.DataCleanupConfigStore + DataCleanupLog *postgres.DataCleanupLogStore + AgentWallet *postgres.AgentWalletStore + AgentWalletTransaction *postgres.AgentWalletTransactionStore + AssetWallet *postgres.AssetWalletStore + AssetIdentifier *postgres.AssetIdentifierStore + PersonalCustomer *postgres.PersonalCustomerStore + PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore + OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore + AssetPackageBatchOrderTask *postgres.AssetPackageBatchOrderTaskStore + ShopBusinessOwnerImportTask *postgres.ShopBusinessOwnerImportTaskStore } // WorkerServices Worker 侧所有 Service 的集合 diff --git a/pkg/storage/service.go b/pkg/storage/service.go index 6ac4448..7f8fa29 100644 --- a/pkg/storage/service.go +++ b/pkg/storage/service.go @@ -36,7 +36,7 @@ func (s *Service) GenerateFileKey(purpose, fileName string) (string, error) { if ext == "" { ext = ".bin" } - if (purpose == constants.StoragePurposeAssetPackageBatchOrder || purpose == constants.StoragePurposeDeviceBatchAllocation) && !strings.EqualFold(ext, ".csv") { + if (purpose == constants.StoragePurposeAssetPackageBatchOrder || purpose == constants.StoragePurposeDeviceBatchAllocation || purpose == constants.StoragePurposeShopBusinessOwnerImport) && !strings.EqualFold(ext, ".csv") { return "", errors.New(errors.CodeInvalidParam, "批量业务文件必须为CSV格式") } diff --git a/pkg/storage/types.go b/pkg/storage/types.go index e1b59a0..8f96a59 100644 --- a/pkg/storage/types.go +++ b/pkg/storage/types.go @@ -19,4 +19,7 @@ var PurposeMappings = map[string]PurposeMapping{ "attachment": {Prefix: "attachments", ContentType: ""}, constants.StoragePurposeAssetPackageBatchOrder: {Prefix: constants.AssetPackageBatchOrderStoragePrefix, ContentType: "text/csv"}, constants.StoragePurposeDeviceBatchAllocation: {Prefix: constants.DeviceBatchAllocationStoragePrefix, ContentType: "text/csv"}, + constants.StoragePurposeShopBusinessOwnerImport: { + Prefix: constants.ShopBusinessOwnerImportStoragePrefix, ContentType: "text/csv", + }, } diff --git a/pkg/utils/encoding.go b/pkg/utils/encoding.go new file mode 100644 index 0000000..ab0c385 --- /dev/null +++ b/pkg/utils/encoding.go @@ -0,0 +1,40 @@ +package utils + +import ( + "fmt" + "strings" + "unicode/utf8" + + "golang.org/x/text/encoding/simplifiedchinese" +) + +// utf8BOM 是 UTF-8 字节顺序标记,上传的 CSV 常带该前缀。 +var utf8BOM = []byte{0xEF, 0xBB, 0xBF} + +// replacementChar 是解码器无法映射字节时产生的替换字符。 +const replacementChar = "\uFFFD" + +// DecodeTextToUTF8 把上传文本解码为 UTF-8 字节。 +// 先剥离 UTF-8 BOM;剥离后已是合法 UTF-8 时原样返回,否则按 GBK 回退解码。 +// 仅在 UTF-8 校验失败时才尝试 GBK,避免对合法 UTF-8 内容做启发式改写; +// GBK 解码失败或仍残留无法映射的字节时返回错误,由调用方按任务级失败处理。 +func DecodeTextToUTF8(data []byte) ([]byte, error) { + trimmed := data + if len(trimmed) >= len(utf8BOM) && string(trimmed[:len(utf8BOM)]) == string(utf8BOM) { + trimmed = trimmed[len(utf8BOM):] + } + if utf8.Valid(trimmed) { + return trimmed, nil + } + decoded, err := simplifiedchinese.GBK.NewDecoder().Bytes(trimmed) + if err != nil { + return nil, fmt.Errorf("GBK 解码失败: %w", err) + } + if !utf8.Valid(decoded) { + return nil, fmt.Errorf("GBK 解码结果不是合法 UTF-8") + } + if strings.Contains(string(decoded), replacementChar) { + return nil, fmt.Errorf("GBK 解码存在无法映射的字节") + } + return decoded, nil +}