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

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

View File

@@ -0,0 +1,179 @@
package postgres
import (
"context"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
)
// BusinessUserGroupStore 业务用户组及其成员归属的数据访问层。
// 成员关系属于用户组聚合一账号至多一条未删除记录partial 唯一索引保证),
// 改组更新组 ID清空走软删历史归属变更由审计事件承担。
type BusinessUserGroupStore struct {
db *gorm.DB
}
// NewBusinessUserGroupStore 创建业务用户组 Store。
func NewBusinessUserGroupStore(db *gorm.DB) *BusinessUserGroupStore {
return &BusinessUserGroupStore{db: db}
}
// DB 返回 Store 使用的数据库连接。
func (s *BusinessUserGroupStore) DB() *gorm.DB { return s.db }
// WithTx 返回绑定指定事务的 Store。
func (s *BusinessUserGroupStore) WithTx(tx *gorm.DB) *BusinessUserGroupStore {
return &BusinessUserGroupStore{db: tx}
}
// Create 创建业务用户组。
func (s *BusinessUserGroupStore) Create(ctx context.Context, group *model.BusinessUserGroup) error {
return s.db.WithContext(ctx).Create(group).Error
}
// GetByID 查询未删除的业务用户组。
func (s *BusinessUserGroupStore) GetByID(ctx context.Context, id uint) (*model.BusinessUserGroup, error) {
var group model.BusinessUserGroup
if err := s.db.WithContext(ctx).First(&group, id).Error; err != nil {
return nil, err
}
return &group, nil
}
// LockByID 在事务内按主键加行锁查询未删除的业务用户组。
func (s *BusinessUserGroupStore) LockByID(ctx context.Context, id uint) (*model.BusinessUserGroup, error) {
var group model.BusinessUserGroup
if err := s.db.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&group, id).Error; err != nil {
return nil, err
}
return &group, nil
}
// ExistsCode 判断稳定编码是否已被其他未删除组占用。
func (s *BusinessUserGroupStore) ExistsCode(ctx context.Context, code string, excludeID uint) (bool, error) {
query := s.db.WithContext(ctx).Model(&model.BusinessUserGroup{}).Where("code = ?", code)
if excludeID != 0 {
query = query.Where("id <> ?", excludeID)
}
var count int64
if err := query.Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
// Update 保存业务用户组可变字段;稳定编码不参与更新。
func (s *BusinessUserGroupStore) Update(ctx context.Context, group *model.BusinessUserGroup, operatorID uint) error {
now := time.Now()
return s.db.WithContext(ctx).Model(&model.BusinessUserGroup{}).Where("id = ?", group.ID).Updates(map[string]any{
"name": group.Name,
"business_line": group.BusinessLine,
"sort_order": group.SortOrder,
"status": group.Status,
"remark": group.Remark,
"updater": operatorID,
"updated_at": now,
}).Error
}
// Delete 软删除业务用户组;调用方必须先确认组内已无成员。
func (s *BusinessUserGroupStore) Delete(ctx context.Context, id, operatorID uint) error {
now := time.Now()
return s.db.WithContext(ctx).Model(&model.BusinessUserGroup{}).
Where("id = ? AND deleted_at IS NULL", id).
Updates(map[string]any{"deleted_at": now, "updater": operatorID, "updated_at": now}).Error
}
// CountMembers 统计组内未删除的成员关系数。
func (s *BusinessUserGroupStore) CountMembers(ctx context.Context, groupID uint) (int64, error) {
var count int64
if err := s.db.WithContext(ctx).Model(&model.BusinessUserGroupMember{}).
Where("business_user_group_id = ?", groupID).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
// MembersByAccountIDs 批量读取账号当前的未删除成员关系。
func (s *BusinessUserGroupStore) MembersByAccountIDs(ctx context.Context, accountIDs []uint) (map[uint]model.BusinessUserGroupMember, error) {
result := make(map[uint]model.BusinessUserGroupMember, len(accountIDs))
if len(accountIDs) == 0 {
return result, nil
}
var members []model.BusinessUserGroupMember
if err := s.db.WithContext(ctx).Model(&model.BusinessUserGroupMember{}).
Where("account_id IN ?", accountIDs).Find(&members).Error; err != nil {
return nil, err
}
for _, member := range members {
result[member.AccountID] = member
}
return result, nil
}
// ReplaceMemberGroup 将给定账号的归属直接替换为目标组:已有关系更新组 ID缺失关系新增一行。
func (s *BusinessUserGroupStore) ReplaceMemberGroup(ctx context.Context, accountIDs []uint, groupID, operatorID uint) error {
if len(accountIDs) == 0 {
return nil
}
existing, err := s.MembersByAccountIDs(ctx, accountIDs)
if err != nil {
return err
}
now := time.Now()
missing := make([]model.BusinessUserGroupMember, 0, len(accountIDs))
for _, accountID := range accountIDs {
if member, ok := existing[accountID]; ok {
if member.BusinessUserGroupID == groupID {
continue
}
if err := s.db.WithContext(ctx).Model(&model.BusinessUserGroupMember{}).Where("id = ?", member.ID).
Updates(map[string]any{
"business_user_group_id": groupID, "updater": operatorID, "updated_at": now,
}).Error; err != nil {
return err
}
continue
}
missing = append(missing, model.BusinessUserGroupMember{
BusinessUserGroupID: groupID, AccountID: accountID,
BaseModel: model.BaseModel{Creator: operatorID, Updater: operatorID},
})
}
if len(missing) == 0 {
return nil
}
return s.db.WithContext(ctx).Create(&missing).Error
}
// ClearMembers 删除给定账号的未删除成员关系,使账号回到未分组。
func (s *BusinessUserGroupStore) ClearMembers(ctx context.Context, accountIDs []uint) error {
if len(accountIDs) == 0 {
return nil
}
return s.db.WithContext(ctx).Where("account_id IN ?", accountIDs).Delete(&model.BusinessUserGroupMember{}).Error
}
// LockAccountsByIDs 在事务内对账号行本身按主键升序加行锁。
// 锁账号(而非仅锁已有成员行)同时消除两种并发缺陷:
// 一是清空时账号尚无成员行导致锁不到行(幻读),二是多账号按不同请求顺序插入成员行导致的相反顺序死锁。
// 显式 ORDER BY id ASC 保证所有事务以同一顺序取锁,避免交叉等待。
func (s *BusinessUserGroupStore) LockAccountsByIDs(ctx context.Context, accountIDs []uint) error {
if len(accountIDs) == 0 {
return nil
}
var accounts []model.Account
return s.db.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Select("id").Where("id IN ?", accountIDs).Order("id ASC").Find(&accounts).Error
}
// IsAccountMemberConflict 判断错误是否为成员唯一索引冲突。
// 并发为同一账号新增成员关系时唯一索引是最终裁决,调用方据此返回稳定业务错误。
func IsAccountMemberConflict(err error) bool {
return err != nil && strings.Contains(strings.ToLower(err.Error()), "uk_business_user_group_member_account")
}

View File

@@ -0,0 +1,124 @@
package postgres
import (
"context"
"fmt"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store"
)
// ShopBusinessOwnerImportTaskStore 店铺负责人 CSV 导入任务数据访问层。
type ShopBusinessOwnerImportTaskStore struct {
db *gorm.DB
}
// NewShopBusinessOwnerImportTaskStore 创建店铺负责人导入任务 Store。
func NewShopBusinessOwnerImportTaskStore(db *gorm.DB) *ShopBusinessOwnerImportTaskStore {
return &ShopBusinessOwnerImportTaskStore{db: db}
}
// DB 返回任务 Store 使用的数据库连接。
func (s *ShopBusinessOwnerImportTaskStore) DB() *gorm.DB { return s.db }
// WithTx 返回绑定指定事务的任务 Store。
func (s *ShopBusinessOwnerImportTaskStore) WithTx(tx *gorm.DB) *ShopBusinessOwnerImportTaskStore {
return &ShopBusinessOwnerImportTaskStore{db: tx}
}
// Create 创建店铺负责人导入任务。
func (s *ShopBusinessOwnerImportTaskStore) Create(ctx context.Context, task *model.ShopBusinessOwnerImportTask) error {
return s.db.WithContext(ctx).Create(task).Error
}
// GetByID 按 ID 查询店铺负责人导入任务。
func (s *ShopBusinessOwnerImportTaskStore) GetByID(ctx context.Context, id uint) (*model.ShopBusinessOwnerImportTask, error) {
var task model.ShopBusinessOwnerImportTask
if err := s.db.WithContext(ctx).First(&task, id).Error; err != nil {
return nil, err
}
return &task, nil
}
// List 分页查询店铺负责人导入任务。
func (s *ShopBusinessOwnerImportTaskStore) List(ctx context.Context, opts *store.QueryOptions, status *int) ([]*model.ShopBusinessOwnerImportTask, int64, error) {
query := s.db.WithContext(ctx).Model(&model.ShopBusinessOwnerImportTask{})
if status != nil {
query = query.Where("status = ?", *status)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if opts == nil {
opts = store.DefaultQueryOptions()
}
var tasks []*model.ShopBusinessOwnerImportTask
if err := query.Order("created_at DESC").Offset((opts.Page - 1) * opts.PageSize).Limit(opts.PageSize).Find(&tasks).Error; err != nil {
return nil, 0, err
}
return tasks, total, nil
}
// ResetForProcessing 把待处理任务或上次中断的处理中任务置为处理中,并重置进度计数与行明细。
// 返回 false 表示任务已到达终态,重复消费直接跳过;处理中一律视为中断重跑,
// 重跑前清空计数与明细,避免逐行结果重复追加。
func (s *ShopBusinessOwnerImportTaskStore) ResetForProcessing(ctx context.Context, id uint) (bool, error) {
now := time.Now()
result := s.db.WithContext(ctx).Model(&model.ShopBusinessOwnerImportTask{}).
Where("id = ? AND status IN ?", id, []int{model.ImportTaskStatusPending, model.ImportTaskStatusProcessing}).
Updates(map[string]any{
"status": model.ImportTaskStatusProcessing, "started_at": now, "success_count": 0,
"fail_count": 0, "total_count": 0, "result_items": model.ShopBusinessOwnerImportResults{},
"error_message": "", "updated_at": now,
})
return result.RowsAffected == 1, result.Error
}
// UpdateProgress 按批更新进度计数,不触碰逐行明细,失败不回滚已提交行。
// 必须同时写入本任务已知的行总数:表约束要求 success_count + fail_count <= total_count
// 只写计数会让处理中的中间态违反该约束,导致进度更新静默失败。
func (s *ShopBusinessOwnerImportTaskStore) UpdateProgress(ctx context.Context, id uint, totalCount, successCount, failCount int) error {
return s.db.WithContext(ctx).Model(&model.ShopBusinessOwnerImportTask{}).Where("id = ?", id).
Updates(map[string]any{
"total_count": totalCount, "success_count": successCount, "fail_count": failCount,
"updated_at": time.Now(),
}).Error
}
// MarkFailed 将任务标记为任务级失败,并返回是否确实命中非终态。
// 命中返回 true返回 false 表示任务已到终态(例如 Enqueue 实际已投递成功且 Worker 抢先跑完),
// 此时调用方必须按库内真实状态对外呈现,不得把响应与审计置为失败。
func (s *ShopBusinessOwnerImportTaskStore) MarkFailed(ctx context.Context, id uint, message string) (bool, error) {
now := time.Now()
result := s.db.WithContext(ctx).Model(&model.ShopBusinessOwnerImportTask{}).
Where("id = ? AND status IN ?", id, []int{model.ImportTaskStatusPending, model.ImportTaskStatusProcessing}).
Updates(map[string]any{
"status": model.ImportTaskStatusFailed, "error_message": message,
"total_count": 0, "success_count": 0, "fail_count": 0,
"result_items": model.ShopBusinessOwnerImportResults{},
"completed_at": now, "updated_at": now,
})
return result.RowsAffected == 1, result.Error
}
// Complete 保存逐行结果与汇总并完成任务。
func (s *ShopBusinessOwnerImportTaskStore) Complete(ctx context.Context, id uint, totalCount, successCount, failCount int, items model.ShopBusinessOwnerImportResults) error {
now := time.Now()
return s.db.WithContext(ctx).Model(&model.ShopBusinessOwnerImportTask{}).
Where("id = ? AND status = ?", id, model.ImportTaskStatusProcessing).
Updates(map[string]any{
"status": model.ImportTaskStatusCompleted, "total_count": totalCount,
"success_count": successCount, "fail_count": failCount,
"result_items": items, "completed_at": now, "updated_at": now,
}).Error
}
// GenerateTaskNo 生成店铺负责人导入任务编号。
func (s *ShopBusinessOwnerImportTaskStore) GenerateTaskNo() string {
now := time.Now()
return fmt.Sprintf("SBI-%s-%06d", now.Format("20060102"), now.UnixNano()%1000000)
}