feat(admin): 客服手动录单支持平台与操作人,并可后台配置可选项

- 录单列表新增「平台」「操作人」两列,录单表单与编辑弹窗新增同名字段下拉
- 新增 cs_manual_entry_options 表与 4 个接口(读取/新增/重命名/删除),页面右上角弹窗维护平台与操作人
- 迁移 000087:cs_manual_entries 增加 platform/operator 列与索引,注册 cs_manual_entry:option_manage 权限并授予 ops/super_admin
- 记录保存名称快照,删除选项不影响历史记录;同类型重名返回 409,跨类型同名允许
- 顶部关键词查询支持命中平台/操作人
- 新增单测:选项 CRUD 边界与录单平台/操作人读写
This commit is contained in:
yml2213
2026-10-13 16:22:57 +08:00
parent 9a52dbdb7b
commit f33dc97f6f
12 changed files with 1017 additions and 30 deletions
@@ -34,9 +34,11 @@ func (r *Repository) FindAdminSnapshot(ctx context.Context, adminID uint64) (nam
}
// Create 写入一条录单记录。
func (r *Repository) Create(ctx context.Context, adminID uint64, adminName, adminUsername, entryNo, remark string) (*EntryDTO, error) {
func (r *Repository) Create(ctx context.Context, adminID uint64, adminName, adminUsername, entryNo, platform, operator, remark string) (*EntryDTO, error) {
row := model.CSManualEntry{
EntryNo: entryNo,
Platform: platform,
Operator: operator,
AdminID: adminID,
AdminName: adminName,
AdminUsername: adminUsername,
@@ -65,12 +67,14 @@ func (r *Repository) FindByID(ctx context.Context, id uint64) (*EntryDTO, error)
return &dto, nil
}
// Update 修正录单的编号与备注。编号唯一冲突返回 ErrEntryNoExists。
func (r *Repository) Update(ctx context.Context, id uint64, entryNo, remark string) (*EntryDTO, error) {
// Update 修正录单的编号、平台、操作人与备注。编号唯一冲突返回 ErrEntryNoExists。
func (r *Repository) Update(ctx context.Context, id uint64, entryNo, platform, operator, remark string) (*EntryDTO, error) {
result := r.db.WithContext(ctx).Model(&model.CSManualEntry{}).
Where("id = ?", id).
Updates(map[string]any{
"entry_no": entryNo,
"platform": platform,
"operator": operator,
"remark": remark,
})
if result.Error != nil {
@@ -118,7 +122,10 @@ func (r *Repository) List(ctx context.Context, query ListQuery) (*PaginatedResul
}
if kw := strings.TrimSpace(query.Keyword); kw != "" {
like := "%" + kw + "%"
cond := r.db.Where("entry_no LIKE ? OR admin_name LIKE ? OR admin_username LIKE ?", like, like, like)
cond := r.db.Where(
"entry_no LIKE ? OR admin_name LIKE ? OR admin_username LIKE ? OR platform LIKE ? OR operator LIKE ?",
like, like, like, like, like,
)
db = db.Where(cond)
countDB = countDB.Where(cond)
}
@@ -192,11 +199,99 @@ func (r *Repository) TodayAgentStats(ctx context.Context, day time.Time) ([]Agen
return items, nil
}
// ListOptions 返回某类型(平台 / 操作人)的全部可选项。
func (r *Repository) ListOptions(ctx context.Context, optionType string) ([]OptionDTO, error) {
if !IsValidOptionType(optionType) {
return nil, ErrOptionTypeInvalid
}
rows := make([]model.CSManualEntryOption, 0)
if err := r.db.WithContext(ctx).
Where("type = ?", optionType).
Order("sort_order ASC, id ASC").
Find(&rows).Error; err != nil {
return nil, err
}
items := make([]OptionDTO, 0, len(rows))
for _, row := range rows {
items = append(items, toOptionDTO(row))
}
return items, nil
}
// ListAllOptions 返回平台与操作人两组可选项,供录单表单直接使用。
func (r *Repository) ListAllOptions(ctx context.Context) (*OptionListDTO, error) {
platforms, err := r.ListOptions(ctx, OptionTypePlatform)
if err != nil {
return nil, err
}
operators, err := r.ListOptions(ctx, OptionTypeOperator)
if err != nil {
return nil, err
}
return &OptionListDTO{Platforms: platforms, Operators: operators}, nil
}
// CreateOption 新增一个平台 / 操作人选项,重名返回 ErrOptionExists。
func (r *Repository) CreateOption(ctx context.Context, optionType, name string) (*OptionDTO, error) {
if !IsValidOptionType(optionType) {
return nil, ErrOptionTypeInvalid
}
row := model.CSManualEntryOption{Type: optionType, Name: name}
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
if isDuplicateEntry(err) {
return nil, ErrOptionExists
}
return nil, err
}
dto := toOptionDTO(row)
return &dto, nil
}
// UpdateOption 重命名一个选项,重名返回 ErrOptionExists。
func (r *Repository) UpdateOption(ctx context.Context, id uint64, name string) (*OptionDTO, error) {
var row model.CSManualEntryOption
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrOptionNotFound
}
return nil, err
}
result := r.db.WithContext(ctx).Model(&model.CSManualEntryOption{}).
Where("id = ?", id).
Update("name", name)
if result.Error != nil {
if isDuplicateEntry(result.Error) {
return nil, ErrOptionExists
}
return nil, result.Error
}
row.Name = name
dto := toOptionDTO(row)
return &dto, nil
}
// DeleteOption 删除一个选项(历史录单里的名称快照不受影响)。
func (r *Repository) DeleteOption(ctx context.Context, id uint64) error {
result := r.db.WithContext(ctx).Delete(&model.CSManualEntryOption{}, id)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrOptionNotFound
}
return nil
}
// IsValidOptionType 判断选项类型是否合法。
func IsValidOptionType(optionType string) bool {
return optionType == OptionTypePlatform || optionType == OptionTypeOperator
}
func parseDay(value string, loc *time.Location) (time.Time, error) {
return time.ParseInLocation("2006-01-02", value, loc)
}
// isDuplicateEntry 判断错误是否为 entry_no 唯一键冲突。
// isDuplicateEntry 判断错误是否为唯一键冲突。
func isDuplicateEntry(err error) bool {
if err == nil {
return false
@@ -211,6 +306,8 @@ func toDTO(row model.CSManualEntry) EntryDTO {
return EntryDTO{
ID: row.ID,
EntryNo: row.EntryNo,
Platform: row.Platform,
Operator: row.Operator,
AdminID: row.AdminID,
AdminName: row.AdminName,
AdminUsername: row.AdminUsername,
@@ -218,3 +315,13 @@ func toDTO(row model.CSManualEntry) EntryDTO {
CreatedAt: row.CreatedAt,
}
}
func toOptionDTO(row model.CSManualEntryOption) OptionDTO {
return OptionDTO{
ID: row.ID,
Type: row.Type,
Name: row.Name,
SortOrder: row.SortOrder,
CreatedAt: row.CreatedAt,
}
}