feat(业务用户组): AUG26-003 业务用户组与店铺负责人分组导入
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 1h43m42s
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:
493
internal/application/businessusergroup/service.go
Normal file
493
internal/application/businessusergroup/service.go
Normal 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
|
||||
}
|
||||
252
internal/application/shop/batch_business_owner.go
Normal file
252
internal/application/shop/batch_business_owner.go
Normal 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
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
231
internal/handler/admin/business_user_group.go
Normal file
231
internal/handler/admin/business_user_group.go
Normal 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
|
||||
}
|
||||
71
internal/handler/admin/shop_business_owner_import.go
Normal file
71
internal/handler/admin/shop_business_owner_import.go
Normal 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)
|
||||
}
|
||||
88
internal/infrastructure/audit/business_user_group.go
Normal file
88
internal/infrastructure/audit/business_user_group.go
Normal 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})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
40
internal/model/business_user_group.go
Normal file
40
internal/model/business_user_group.go
Normal 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"
|
||||
}
|
||||
196
internal/model/dto/business_user_group_dto.go
Normal file
196
internal/model/dto/business_user_group_dto.go
Normal 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:"逐行结果明细;任务级失败时为空数组"`
|
||||
}
|
||||
@@ -3,15 +3,18 @@ package dto
|
||||
import "github.com/bytedance/sonic"
|
||||
|
||||
type ShopListRequest struct {
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
|
||||
ShopName string `json:"shop_name" query:"shop_name" validate:"omitempty,max=100" maxLength:"100" description:"店铺名称模糊查询"`
|
||||
ShopCode string `json:"shop_code" query:"shop_code" validate:"omitempty,max=50" maxLength:"50" description:"店铺编号精确查询"`
|
||||
ContactPhone string `json:"contact_phone" query:"contact_phone" validate:"omitempty,len=11,numeric,ascii" minLength:"11" maxLength:"11" pattern:"^[0-9]{11}$" description:"联系电话精确查询(11位 ASCII 数字;空值不启用筛选;与其他条件按 AND 组合)"`
|
||||
BusinessOwnerAccountID *uint `json:"business_owner_account_id" query:"business_owner_account_id" validate:"omitempty,min=1" minimum:"1" description:"平台业务员账号ID精确筛选"`
|
||||
ParentID *uint `json:"parent_id" query:"parent_id" validate:"omitempty,min=1" minimum:"1" description:"上级店铺ID"`
|
||||
Level *int `json:"level" query:"level" validate:"omitempty,min=1,max=7" minimum:"1" maximum:"7" description:"店铺层级 (1-7级)"`
|
||||
Status *int `json:"status" query:"status" validate:"omitempty,oneof=0 1" description:"状态 (0:禁用, 1:启用)"`
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
|
||||
ShopName string `json:"shop_name" query:"shop_name" validate:"omitempty,max=100" maxLength:"100" description:"店铺名称模糊查询"`
|
||||
ShopCode string `json:"shop_code" query:"shop_code" validate:"omitempty,max=50" maxLength:"50" description:"店铺编号精确查询"`
|
||||
ContactPhone string `json:"contact_phone" query:"contact_phone" validate:"omitempty,len=11,numeric,ascii" minLength:"11" maxLength:"11" pattern:"^[0-9]{11}$" description:"联系电话精确查询(11位 ASCII 数字;空值不启用筛选;与其他条件按 AND 组合)"`
|
||||
BusinessOwnerAccountID *uint `json:"business_owner_account_id" query:"business_owner_account_id" validate:"omitempty,min=1" minimum:"1" description:"平台业务员账号ID精确筛选"`
|
||||
ParentID *uint `json:"parent_id" query:"parent_id" validate:"omitempty,min=1" minimum:"1" description:"上级店铺ID"`
|
||||
Level *int `json:"level" query:"level" validate:"omitempty,min=1,max=7" minimum:"1" maximum:"7" description:"店铺层级 (1-7级)"`
|
||||
Status *int `json:"status" query:"status" validate:"omitempty,oneof=0 1" description:"状态 (0:禁用, 1:启用)"`
|
||||
BusinessUserGroupID *uint `json:"business_user_group_id" query:"business_user_group_id" validate:"omitempty,min=1" minimum:"1" description:"按业务用户组筛选,只匹配店铺当前负责人所属组;停用组同样可被筛出并携带已停用标记"`
|
||||
BusinessLine *string `json:"business_line" query:"business_line" validate:"omitempty,oneof=standard smart other" enum:"standard,smart,other" description:"按负责人所属业务用户组的业务线筛选 (standard:标品, smart:智能产品, other:其他)"`
|
||||
Ungrouped *bool `json:"ungrouped" query:"ungrouped" description:"按未分组筛选:true 只返回无负责人或负责人无成员关系的店铺;负责人属于停用组的店铺不计入未分组"`
|
||||
}
|
||||
|
||||
type CreateShopRequest struct {
|
||||
@@ -98,18 +101,24 @@ type ShopResponse struct {
|
||||
BusinessOwnerUsername string `json:"business_owner_username" description:"平台业务员账号名"`
|
||||
BusinessOwnerPhoneSummary string `json:"business_owner_phone_summary" description:"平台业务员手机号摘要(前三后四)"`
|
||||
BusinessOwnerAvailable bool `json:"business_owner_available" description:"平台业务员当前是否可用于通知接收"`
|
||||
Level int `json:"level" description:"店铺层级 (1-7级)"`
|
||||
ContactName string `json:"contact_name" description:"联系人姓名"`
|
||||
ContactPhone string `json:"contact_phone" description:"联系人电话"`
|
||||
Province string `json:"province" description:"省份"`
|
||||
City string `json:"city" description:"城市"`
|
||||
District string `json:"district" description:"区县"`
|
||||
Address string `json:"address" description:"详细地址"`
|
||||
Status int `json:"status" description:"状态 (0:禁用, 1:启用)"`
|
||||
StatusName string `json:"status_name" description:"状态名称(中文)"`
|
||||
ClientLoginDisabled bool `json:"client_login_disabled" description:"是否禁止该店铺资产发起新的 C 端登录"`
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||
// 店铺负责人业务用户组由当前负责人实时推导,店铺不保存组字段。
|
||||
BusinessUserGroupID *uint `json:"business_user_group_id" description:"负责人当前所属业务用户组ID,null 表示未分组"`
|
||||
BusinessUserGroupCode string `json:"business_user_group_code" description:"业务用户组稳定编码"`
|
||||
BusinessUserGroupName string `json:"business_user_group_name" description:"业务用户组名称"`
|
||||
BusinessUserGroupEnabled bool `json:"business_user_group_enabled" description:"业务用户组是否启用;false 且组ID非空表示负责人属于已停用组"`
|
||||
BusinessUserGroupBusinessLine string `json:"business_user_group_business_line" description:"业务用户组所属业务线 (standard:标品, smart:智能产品, other:其他),空字符串表示未设置"`
|
||||
Level int `json:"level" description:"店铺层级 (1-7级)"`
|
||||
ContactName string `json:"contact_name" description:"联系人姓名"`
|
||||
ContactPhone string `json:"contact_phone" description:"联系人电话"`
|
||||
Province string `json:"province" description:"省份"`
|
||||
City string `json:"city" description:"城市"`
|
||||
District string `json:"district" description:"区县"`
|
||||
Address string `json:"address" description:"详细地址"`
|
||||
Status int `json:"status" description:"状态 (0:禁用, 1:启用)"`
|
||||
StatusName string `json:"status_name" description:"状态名称(中文)"`
|
||||
ClientLoginDisabled bool `json:"client_login_disabled" description:"是否禁止该店铺资产发起新的 C 端登录"`
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||
}
|
||||
|
||||
// ShopPageResult 店铺分页响应
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 "未知"
|
||||
}
|
||||
}
|
||||
|
||||
67
internal/model/shop_business_owner_import_task.go
Normal file
67
internal/model/shop_business_owner_import_task.go
Normal 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)
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
116
internal/query/businessusergroup/query.go
Normal file
116
internal/query/businessusergroup/query.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
158
internal/routes/business_user_group.go
Normal file
158
internal/routes/business_user_group.go
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
|
||||
59
internal/service/shop_business_owner_import/audit.go
Normal file
59
internal/service/shop_business_owner_import/audit.go
Normal 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,
|
||||
}
|
||||
}
|
||||
176
internal/service/shop_business_owner_import/service.go
Normal file
176
internal/service/shop_business_owner_import/service.go
Normal 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
|
||||
}
|
||||
179
internal/store/postgres/business_user_group_store.go
Normal file
179
internal/store/postgres/business_user_group_store.go
Normal 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")
|
||||
}
|
||||
124
internal/store/postgres/shop_business_owner_import_task_store.go
Normal file
124
internal/store/postgres/shop_business_owner_import_task_store.go
Normal 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)
|
||||
}
|
||||
403
internal/task/shop_business_owner_import.go
Normal file
403
internal/task/shop_business_owner_import.go
Normal 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) }
|
||||
Reference in New Issue
Block a user