Files
hfb_sys/backend/internal/modules/csmanualentry/service.go
T
yml2213 e027f88fbb feat(csmanualentry): 手动录单支持收号出号分组
- 录单记录新增组别(收号/出号),唯一约束放宽为编号+组别,同一编号两组各可登记一次
- 操作人选项支持组别标记,录单选中后自动带出组别
- 列表新增组别列与筛选,今日统计按收号/出号分列
2026-11-06 14:02:28 +08:00

210 lines
5.5 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
}
entryGroup, err := normalizeEntryGroup(req.EntryGroup)
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, entryGroup, 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
}
entryGroup, err := normalizeEntryGroup(req.EntryGroup)
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, entryGroup, 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
}
groupTag, err := normalizeGroupTag(req.GroupTag)
if err != nil {
return nil, err
}
return s.repo.CreateOption(ctx, optionType, name, groupTag)
}
// 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
}
groupTag, err := normalizeGroupTag(req.GroupTag)
if err != nil {
return nil, err
}
return s.repo.UpdateOption(ctx, id, name, groupTag)
}
// 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
}
// normalizeEntryGroup 规整录单组别:必填且仅允许 collect / sell。
func normalizeEntryGroup(raw string) (string, error) {
value := strings.TrimSpace(raw)
if !IsValidEntryGroup(value) {
return "", ErrEntryGroupInvalid
}
return value, nil
}
// normalizeGroupTag 规整选项组别标记:允许为空(未分组),否则仅允许 collect / sell。
func normalizeGroupTag(raw string) (string, error) {
value := strings.TrimSpace(raw)
if !IsValidGroupTag(value) {
return "", ErrEntryGroupInvalid
}
return value, nil
}