- 录单列表新增「平台」「操作人」两列,录单表单与编辑弹窗新增同名字段下拉 - 新增 cs_manual_entry_options 表与 4 个接口(读取/新增/重命名/删除),页面右上角弹窗维护平台与操作人 - 迁移 000087:cs_manual_entries 增加 platform/operator 列与索引,注册 cs_manual_entry:option_manage 权限并授予 ops/super_admin - 记录保存名称快照,删除选项不影响历史记录;同类型重名返回 409,跨类型同名允许 - 顶部关键词查询支持命中平台/操作人 - 新增单测:选项 CRUD 边界与录单平台/操作人读写
176 lines
4.6 KiB
Go
176 lines
4.6 KiB
Go
package csmanualentry
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"hfb_sys/backend/internal/timeutil"
|
|
)
|
|
|
|
const (
|
|
maxEntryNoLength = 64
|
|
maxOptionNameLength = 64
|
|
)
|
|
|
|
type Service struct {
|
|
repo *Repository
|
|
}
|
|
|
|
func NewService(repo *Repository) *Service {
|
|
return &Service{repo: repo}
|
|
}
|
|
|
|
// Create 校验编号并落库。客服身份由 handler 从登录上下文注入。
|
|
func (s *Service) Create(ctx context.Context, adminID uint64, req CreateRequest) (*CreateResult, error) {
|
|
if s.repo == nil {
|
|
return nil, ErrDependencyUnavailable
|
|
}
|
|
entryNo, err := normalizeEntryNo(req.EntryNo)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
platform, err := normalizeOptionName(req.Platform)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
operator, err := normalizeOptionName(req.Operator)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
adminName, adminUsername, err := s.repo.FindAdminSnapshot(ctx, adminID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if adminName == "" {
|
|
adminName = adminUsername
|
|
}
|
|
entry, err := s.repo.Create(ctx, adminID, adminName, adminUsername, entryNo, platform, operator, strings.TrimSpace(req.Remark))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &CreateResult{Entry: *entry}, nil
|
|
}
|
|
|
|
// Update 管理员修正录单记录。
|
|
func (s *Service) Update(ctx context.Context, id uint64, req UpdateRequest) (*EntryDTO, error) {
|
|
if s.repo == nil {
|
|
return nil, ErrDependencyUnavailable
|
|
}
|
|
entryNo, err := normalizeEntryNo(req.EntryNo)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
platform, err := normalizeOptionName(req.Platform)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
operator, err := normalizeOptionName(req.Operator)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.repo.Update(ctx, id, entryNo, platform, operator, strings.TrimSpace(req.Remark))
|
|
}
|
|
|
|
// Delete 管理员删除录单记录。
|
|
func (s *Service) Delete(ctx context.Context, id uint64) error {
|
|
if s.repo == nil {
|
|
return ErrDependencyUnavailable
|
|
}
|
|
return s.repo.Delete(ctx, id)
|
|
}
|
|
|
|
// List 分页查询录单记录。
|
|
func (s *Service) List(ctx context.Context, query ListQuery) (*PaginatedResult, error) {
|
|
if s.repo == nil {
|
|
return nil, ErrDependencyUnavailable
|
|
}
|
|
return s.repo.List(ctx, query)
|
|
}
|
|
|
|
// TodayAgentStats 返回今日(北京时间)各客服录单统计。
|
|
func (s *Service) TodayAgentStats(ctx context.Context) ([]AgentDayStatsDTO, error) {
|
|
if s.repo == nil {
|
|
return nil, ErrDependencyUnavailable
|
|
}
|
|
return s.repo.TodayAgentStats(ctx, timeutil.ShanghaiNow())
|
|
}
|
|
|
|
// ListOptions 返回平台与操作人两组可选项。
|
|
func (s *Service) ListOptions(ctx context.Context) (*OptionListDTO, error) {
|
|
if s.repo == nil {
|
|
return nil, ErrDependencyUnavailable
|
|
}
|
|
return s.repo.ListAllOptions(ctx)
|
|
}
|
|
|
|
// CreateOption 新增平台 / 操作人选项。
|
|
func (s *Service) CreateOption(ctx context.Context, req CreateOptionRequest) (*OptionDTO, error) {
|
|
if s.repo == nil {
|
|
return nil, ErrDependencyUnavailable
|
|
}
|
|
optionType := strings.TrimSpace(req.Type)
|
|
if !IsValidOptionType(optionType) {
|
|
return nil, ErrOptionTypeInvalid
|
|
}
|
|
name, err := normalizeOptionName(req.Name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if name == "" {
|
|
return nil, ErrOptionNameInvalid
|
|
}
|
|
return s.repo.CreateOption(ctx, optionType, name)
|
|
}
|
|
|
|
// UpdateOption 重命名平台 / 操作人选项。
|
|
func (s *Service) UpdateOption(ctx context.Context, id uint64, req UpdateOptionRequest) (*OptionDTO, error) {
|
|
if s.repo == nil {
|
|
return nil, ErrDependencyUnavailable
|
|
}
|
|
name, err := normalizeOptionName(req.Name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if name == "" {
|
|
return nil, ErrOptionNameInvalid
|
|
}
|
|
return s.repo.UpdateOption(ctx, id, name)
|
|
}
|
|
|
|
// DeleteOption 删除平台 / 操作人选项。
|
|
func (s *Service) DeleteOption(ctx context.Context, id uint64) error {
|
|
if s.repo == nil {
|
|
return ErrDependencyUnavailable
|
|
}
|
|
return s.repo.DeleteOption(ctx, id)
|
|
}
|
|
|
|
// normalizeEntryNo 去除首尾空白并做基础校验。
|
|
func normalizeEntryNo(raw string) (string, error) {
|
|
value := strings.TrimSpace(raw)
|
|
if value == "" {
|
|
return "", ErrEntryNoInvalid
|
|
}
|
|
if len(value) > maxEntryNoLength {
|
|
return "", ErrEntryNoInvalid
|
|
}
|
|
if strings.ContainsAny(value, " \t\r\n") {
|
|
return "", ErrEntryNoInvalid
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
// normalizeOptionName 规整平台 / 操作人名称:去首尾空白、按字符数限长。
|
|
// 允许为空(录单时平台与操作人选填),但内部不允许换行等控制字符。
|
|
func normalizeOptionName(raw string) (string, error) {
|
|
value := strings.TrimSpace(raw)
|
|
if utf8.RuneCountInString(value) > maxOptionNameLength {
|
|
return "", ErrOptionNameInvalid
|
|
}
|
|
if strings.ContainsAny(value, "\t\r\n") {
|
|
return "", ErrOptionNameInvalid
|
|
}
|
|
return value, nil
|
|
}
|