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:
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)
|
||||
}
|
||||
Reference in New Issue
Block a user