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

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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 卡导入任务。

View File

@@ -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"
}
]

View File

@@ -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
}
}
]

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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))

View File

@@ -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,

View File

@@ -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),
}
}

View File

@@ -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

View File

@@ -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,
}
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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})
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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"
}

View File

@@ -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:"逐行结果明细;任务级失败时为空数组"`
}

View File

@@ -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:"负责人当前所属业务用户组IDnull 表示未分组"`
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 店铺分页响应

View File

@@ -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 {

View File

@@ -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 "未知"
}
}

View File

@@ -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)
}

View File

@@ -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:

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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,
})
}

View File

@@ -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 |

View File

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

View File

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

View File

@@ -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")
}

View File

@@ -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)
}

View File

@@ -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) }

View File

@@ -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;

View File

@@ -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 '软删除时间';

View File

@@ -1,42 +0,0 @@
## Context
现有店铺已存在负责人候选和数据范围能力;用户组是新的业务分类,不能复用 RBAC 角色或代理店铺层级。推导关系必须保持实时,避免负责人改组后大量回写店铺造成不一致。
## Decisions
- 新增业务用户组表和平台用户—组关联(用户唯一)表;组编码唯一且不可改,停用不删除既有成员关联。
- 店铺不保存组 ID。列表/详情以店铺负责人关联平台用户,再左连接用户组得到组及停用状态;按组筛选同样使用该关系。
- 勾选批量交接采用一次事务:先按操作者数据范围锁定/校验全量店铺及目标用户,再统一更新和写审计。任何校验失败不写入。
- Excel 使用既有异步导入模式逐行事务;每行在数据范围内查询,统一拒绝文案不区分无权与不存在,并持久化任务明细。
- 用户组成员批量设置直接替换关联;不引入组管理员、层级、额外权限或数据范围计算。
## 管理动作契约
### 用户组及成员
- `POST /business-user-groups`:超级管理员、平台用户提交 `code`164 字符,未删除组内唯一)、`name`1100 字符)、`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。

View File

@@ -1,25 +0,0 @@
## Why
店铺负责人只能逐店维护,平台用户也没有稳定的业务分类;店铺按组统计、筛选和批量交接缺少统一、可追溯口径。
本 Change 落实 AUG26-003业务用户组只描述平台用户的业务分类不改变角色权限、数据范围或代理店铺分组店铺所属组始终由当前负责人实时推导。
## What Changes
- 新增业务用户组(名称、不可变唯一编码、排序、启停、备注)及平台用户单组归属。
- 新增店铺负责人批量设置/清空和 Excel 导入;勾选操作全量校验且原子,导入逐行独立执行并返回明细。
- 店铺列表、详情和筛选显示实时推导的负责人业务用户组;停用组保留成员和展示,不影响登录、权限、数据范围或负责人。
## Capabilities
### New Capabilities
- `business-user-group`: 用户组生命周期、成员归属、店铺负责人交接及推导查询。
### Modified Capabilities
- 无。现有身份权限和数据范围拒绝行为保持不变。
## Impact
影响平台用户、店铺列表/详情、导入任务、数据范围校验、审计、DTO/OpenAPI 和新增 Schema。

View File

@@ -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** 系统更新每个有效行、保留失败行原值,并返回逐行结果及成功/失败汇总

View File

@@ -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。

View File

@@ -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`164 字符,创建时必填,未删除组内唯一,创建后不可修改)、`name`1100 字符)、`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 解码落点、批量审计作用域、上传用途命名与入口角色范围已在本设计内定稿。

View File

@@ -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 生成器。

View File

@@ -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** 该行失败并保留店铺原负责人,其他行照常处理

View File

@@ -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/<uuid>.csv`,建任务校验拒绝非本目录/非 csv。入队失败必须落库`MarkFailed` 不带状态守卫(入队失败时任务仍为待处理)并回填 `error_message`;实测真实队列不可用条件下响应与库内 `status=4 error_message="导入任务入队失败"` 一致,启动补偿只扫待处理状态故不会重跑已失败任务。
- [x] 3.3 实现 CSV 解析:剥离 UTF-8 BOMUTF-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 speccontext-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": []`

View File

@@ -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}`(查询店铺负责人导入任务详情)。

View File

@@ -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 表示本次写操作的通知资源。

View File

@@ -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
}

View File

@@ -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,
}
}

View File

@@ -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 "未知"
}
}

View File

@@ -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),

View File

@@ -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,

View File

@@ -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 的集合

View File

@@ -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格式")
}

View File

@@ -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",
},
}

40
pkg/utils/encoding.go Normal file
View File

@@ -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
}