Files
yml2213 8b9f3d710f refactor(server): 会话/访客/设置业务逻辑下沉 internal/service 层,handler 瘦身
- handler 只保留请求绑定、权限检查与响应映射;业务校验顺序、状态码与文案逐字保留
- 新增 service.Error 语义错误,由 handler 统一映射 HTTP 响应
- session 875→361 行、widget 745→321 行、settings 540→73 行
- 自动分配/租户设置(工作时间、欢迎语)/消息校验/黑名单检查/联系方式提取随迁 service 包
- 相关单元测试随迁 service 包,go test ./... 全部通过
2026-09-05 14:39:34 +08:00

488 lines
16 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"encoding/json"
"fmt"
"strings"
"time"
"unicode/utf8"
"kefu-cloud/server/internal/model"
)
const (
DefaultWelcomeMessage = "您好!欢迎咨询,请问有什么可以帮您?"
DefaultOfflinePrompt = "当前无客服在线,请留言并留下联系方式,我们上线后会尽快回复您。"
DefaultWorktimePrompt = "当前为非工作时间,我们会在工作时段尽快回复您。"
DefaultAgentNickname = "在线客服"
defaultTimezone = "Asia/Shanghai"
MaxWelcomeSegments = 10
maxWelcomeTextLen = 1000 // 纯文字上限(HTML 标签不计)
maxWelcomeHTMLLen = 8000 // 富文本原始 HTML 上限
)
// stripHTMLTags 粗暴去标签,用于字数与摘要(欢迎语 HTML)。
func stripHTMLTags(s string) string {
var b strings.Builder
inTag := false
for _, r := range s {
switch {
case r == '<':
inTag = true
case r == '>':
inTag = false
case !inTag:
b.WriteRune(r)
}
}
// 简单实体
out := b.String()
out = strings.ReplaceAll(out, "&nbsp;", " ")
out = strings.ReplaceAll(out, "&amp;", "&")
out = strings.ReplaceAll(out, "&lt;", "<")
out = strings.ReplaceAll(out, "&gt;", ">")
return strings.TrimSpace(out)
}
// WelcomeSegment 单段欢迎语:文本或图片(content 为正文或对象存储 URL)。
type WelcomeSegment struct {
Type string `json:"type"` // text | image
Content string `json:"content"`
}
var weekdayKeys = []string{"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"}
func defaultWelcomeSegments() []WelcomeSegment {
return []WelcomeSegment{{Type: "text", Content: DefaultWelcomeMessage}}
}
func defaultWelcomeMessagesJSON() string {
b, _ := json.Marshal(defaultWelcomeSegments())
return string(b)
}
// FirstWelcomeText 取第一段可用文本欢迎语(剥离 HTML),用于旧字段兼容展示。
func FirstWelcomeText(segs []WelcomeSegment) string {
for _, s := range segs {
if s.Type != "text" {
continue
}
plain := stripHTMLTags(s.Content)
if plain == "" {
plain = strings.TrimSpace(s.Content)
}
if plain == "" {
continue
}
// welcome_message 列 size:500
if utf8.RuneCountInString(plain) > 500 {
return string([]rune(plain)[:500])
}
return plain
}
return DefaultWelcomeMessage
}
// ResolveWelcomeSegments 解析多段欢迎语;兼容仅有旧 welcome_message 的数据。
func ResolveWelcomeSegments(setting *model.TenantSetting) []WelcomeSegment {
if setting == nil {
return defaultWelcomeSegments()
}
raw := strings.TrimSpace(setting.WelcomeMessagesJSON)
if raw != "" {
var segs []WelcomeSegment
if err := json.Unmarshal([]byte(raw), &segs); err == nil {
out := make([]WelcomeSegment, 0, len(segs))
for _, s := range segs {
t := strings.TrimSpace(s.Type)
c := strings.TrimSpace(s.Content)
if (t == "text" || t == "image") && c != "" {
out = append(out, WelcomeSegment{Type: t, Content: c})
}
}
if len(out) > 0 {
return out
}
}
}
if msg := strings.TrimSpace(setting.WelcomeMessage); msg != "" {
return []WelcomeSegment{{Type: "text", Content: msg}}
}
return defaultWelcomeSegments()
}
// NormalizeWelcomeSegments 校验并规范化欢迎语段落;返回规范化列表与兼容用首段文本。
func NormalizeWelcomeSegments(input []WelcomeSegment) ([]WelcomeSegment, string, error) {
if len(input) == 0 {
return nil, "", fmt.Errorf("至少保留一段欢迎语")
}
if len(input) > MaxWelcomeSegments {
return nil, "", fmt.Errorf("欢迎语最多 %d 段", MaxWelcomeSegments)
}
out := make([]WelcomeSegment, 0, len(input))
for i, seg := range input {
t := strings.ToLower(strings.TrimSpace(seg.Type))
c := strings.TrimSpace(seg.Content)
switch t {
case "text":
// 允许 HTML 富文本;禁止明显脚本
lower := strings.ToLower(c)
if strings.Contains(lower, "<script") || strings.Contains(lower, "javascript:") {
return nil, "", fmt.Errorf("第 %d 段欢迎语包含不安全内容", i+1)
}
plain := stripHTMLTags(c)
if plain == "" {
return nil, "", fmt.Errorf("第 %d 段文本不能为空", i+1)
}
if utf8.RuneCountInString(plain) > maxWelcomeTextLen {
return nil, "", fmt.Errorf("第 %d 段文字不能超过 %d 字", i+1, maxWelcomeTextLen)
}
if utf8.RuneCountInString(c) > maxWelcomeHTMLLen {
return nil, "", fmt.Errorf("第 %d 段内容过长", i+1)
}
out = append(out, WelcomeSegment{Type: "text", Content: c})
case "image":
validated, err := ValidateMessageContent("image", c)
if err != nil {
return nil, "", fmt.Errorf("第 %d 段图片:%s", i+1, err.Error())
}
out = append(out, WelcomeSegment{Type: "image", Content: validated})
default:
return nil, "", fmt.Errorf("第 %d 段类型无效,请使用 text 或 image", i+1)
}
}
return out, FirstWelcomeText(out), nil
}
func defaultWorkHoursJSON() string {
// 默认全周全天可接待,租户可在设置中收紧为工作日时段
hours := map[string]string{
"monday": "全天", "tuesday": "全天", "wednesday": "全天",
"thursday": "全天", "friday": "全天", "saturday": "全天", "sunday": "全天",
}
b, _ := json.Marshal(hours)
return string(b)
}
// GetOrCreateTenantSettings 读取租户设置;首次访问时以默认值落库。
func GetOrCreateTenantSettings(tenantID uint) (*model.TenantSetting, error) {
var setting model.TenantSetting
err := model.DB.Where("tenant_id = ?", tenantID).First(&setting).Error
if err == nil {
return &setting, nil
}
var tenant model.Tenant
_ = model.DB.First(&tenant, tenantID).Error
displayName := tenant.Name
if displayName == "" {
displayName = "在线客服"
}
setting = model.TenantSetting{
TenantID: tenantID,
DisplayName: displayName,
AgentNickname: DefaultAgentNickname,
Timezone: defaultTimezone,
WelcomeMessage: DefaultWelcomeMessage,
WelcomeMessagesJSON: defaultWelcomeMessagesJSON(),
OfflinePrompt: DefaultOfflinePrompt,
WorkHoursJSON: defaultWorkHoursJSON(),
WorktimePrompt: DefaultWorktimePrompt,
NotifyNewSession: true,
NotifyOfflineLeave: true,
NotifyDailyReport: false,
AssignStrategy: AssignStrategyLeastLoad,
MaxActivePerAgent: 0,
}
if err := model.DB.Create(&setting).Error; err != nil {
return nil, err
}
return &setting, nil
}
func ParseWorkHours(raw string) map[string]string {
result := map[string]string{}
if strings.TrimSpace(raw) == "" {
_ = json.Unmarshal([]byte(defaultWorkHoursJSON()), &result)
return result
}
if err := json.Unmarshal([]byte(raw), &result); err != nil {
_ = json.Unmarshal([]byte(defaultWorkHoursJSON()), &result)
}
return result
}
// NormalizeWorkHourSlot 规范化单日工作时间配置。
// 允许:空/休息(→ 空)、全天、HH:MM-HH:MM(支持跨午夜,如 22:00-06:00)。
func NormalizeWorkHourSlot(val string) (string, error) {
val = strings.TrimSpace(val)
if val == "" || val == "休息" {
return "", nil
}
if val == "全天" {
return "全天", nil
}
// 仅允许一个区间:HH:MM-HH:MM
parts := strings.Split(val, "-")
if len(parts) != 2 {
return "", fmt.Errorf("工作时间格式无效,请使用 09:00-18:00 / 全天 / 空")
}
startS := strings.TrimSpace(parts[0])
endS := strings.TrimSpace(parts[1])
start, err1 := time.Parse("15:04", startS)
end, err2 := time.Parse("15:04", endS)
if err1 != nil || err2 != nil {
return "", fmt.Errorf("工作时间格式无效,请使用 09:00-18:00(24 小时制)")
}
return start.Format("15:04") + "-" + end.Format("15:04"), nil
}
// slotCoversLocal 判断 slot(全天 / HH:MM-HH:MM)是否覆盖 local 时刻。
func slotCoversLocal(slot string, local time.Time, loc *time.Location) bool {
slot = strings.TrimSpace(slot)
if slot == "" || slot == "休息" {
return false
}
if slot == "全天" {
return true
}
parts := strings.Split(slot, "-")
if len(parts) != 2 {
// 非法配置 fail-open,避免因脏数据整站拒接
return true
}
start, err1 := time.ParseInLocation("15:04", strings.TrimSpace(parts[0]), loc)
end, err2 := time.ParseInLocation("15:04", strings.TrimSpace(parts[1]), loc)
if err1 != nil || err2 != nil {
return true
}
startAt := time.Date(local.Year(), local.Month(), local.Day(), start.Hour(), start.Minute(), 0, 0, loc)
endAt := time.Date(local.Year(), local.Month(), local.Day(), end.Hour(), end.Minute(), 0, 0, loc)
if !endAt.After(startAt) {
// 跨午夜:当前 >= start 或 < end
return !local.Before(startAt) || local.Before(endAt)
}
return !local.Before(startAt) && local.Before(endAt)
}
// IsWithinWorkHours 根据租户工作时间配置判断当前是否在工作时段。
// 未配置或某日为空视为休息;时段格式 HH:MM-HH:MM(可跨午夜)。
func IsWithinWorkHours(setting *model.TenantSetting, now time.Time) bool {
if setting == nil {
return true
}
loc, err := time.LoadLocation(setting.Timezone)
if err != nil || setting.Timezone == "" {
loc, _ = time.LoadLocation(defaultTimezone)
}
local := now.In(loc)
hours := ParseWorkHours(setting.WorkHoursJSON)
var key string
switch local.Weekday() {
case time.Monday:
key = "monday"
case time.Tuesday:
key = "tuesday"
case time.Wednesday:
key = "wednesday"
case time.Thursday:
key = "thursday"
case time.Friday:
key = "friday"
case time.Saturday:
key = "saturday"
default:
key = "sunday"
}
return slotCoversLocal(hours[key], local, loc)
}
// SettingsResponse 组装设置响应体。
func SettingsResponse(setting *model.TenantSetting) map[string]interface{} {
strategy := NormalizeAssignStrategy(setting.AssignStrategy)
maxActive := setting.MaxActivePerAgent
if maxActive < 0 {
maxActive = 0
}
welcomeSegs := ResolveWelcomeSegments(setting)
return map[string]interface{}{
"tenant_id": setting.TenantID,
"display_name": setting.DisplayName,
"agent_nickname": setting.AgentNickname,
"timezone": setting.Timezone,
"welcome_message": FirstWelcomeText(welcomeSegs),
"welcome_messages": welcomeSegs,
"offline_prompt": setting.OfflinePrompt,
"work_hours": ParseWorkHours(setting.WorkHoursJSON),
"worktime_prompt": setting.WorktimePrompt,
"notify_new_session": setting.NotifyNewSession,
"notify_offline_leave": setting.NotifyOfflineLeave,
"notify_daily_report": setting.NotifyDailyReport,
"assign_strategy": strategy,
"max_active_per_agent": maxActive,
}
}
// SettingsUpdateReq 设置更新请求(pointer 字段表示是否提交该字段)。
type SettingsUpdateReq struct {
DisplayName *string `json:"display_name"`
AgentNickname *string `json:"agent_nickname"`
Timezone *string `json:"timezone"`
WelcomeMessage *string `json:"welcome_message"`
WelcomeMessages *[]WelcomeSegment `json:"welcome_messages"`
OfflinePrompt *string `json:"offline_prompt"`
WorkHours map[string]string `json:"work_hours"`
WorktimePrompt *string `json:"worktime_prompt"`
NotifyNewSession *bool `json:"notify_new_session"`
NotifyOfflineLeave *bool `json:"notify_offline_leave"`
NotifyDailyReport *bool `json:"notify_daily_report"`
AssignStrategy *string `json:"assign_strategy"`
MaxActivePerAgent *int `json:"max_active_per_agent"`
}
// TouchesAssignRule / TouchesAutoReply / TouchesWorktime / TouchesNotification
// 供 handler 按字段分区校验权限。
func (r *SettingsUpdateReq) TouchesAssignRule() bool {
return r.AssignStrategy != nil || r.MaxActivePerAgent != nil
}
func (r *SettingsUpdateReq) TouchesAutoReply() bool {
return r.WelcomeMessage != nil || r.WelcomeMessages != nil || r.OfflinePrompt != nil
}
func (r *SettingsUpdateReq) TouchesWorktime() bool {
return r.WorkHours != nil || r.WorktimePrompt != nil
}
func (r *SettingsUpdateReq) TouchesNotification() bool {
return r.NotifyNewSession != nil || r.NotifyOfflineLeave != nil || r.NotifyDailyReport != nil
}
// UpdateTenantSettings 校验并持久化租户设置,返回刷新后的响应体。
func UpdateTenantSettings(tenantID uint, req *SettingsUpdateReq) (map[string]interface{}, error) {
setting, err := GetOrCreateTenantSettings(tenantID)
if err != nil {
return nil, Internal("加载设置失败")
}
updates := map[string]interface{}{}
if req.DisplayName != nil {
name := strings.TrimSpace(*req.DisplayName)
if name == "" || len([]rune(name)) > 100 {
return nil, BadRequest("租户显示名称无效")
}
updates["display_name"] = name
// 同步更新租户主表名称(唯一约束下若冲突则仅更新 setting)
var conflict int64
model.DB.Model(&model.Tenant{}).Where("name = ? AND id <> ?", name, tenantID).Count(&conflict)
if conflict == 0 {
model.DB.Model(&model.Tenant{}).Where("id = ?", tenantID).Update("name", name)
}
}
if req.AgentNickname != nil {
nick := strings.TrimSpace(*req.AgentNickname)
if len([]rune(nick)) > 50 {
return nil, BadRequest("客服昵称过长")
}
updates["agent_nickname"] = nick
}
if req.Timezone != nil {
tz := strings.TrimSpace(*req.Timezone)
if tz == "" {
tz = defaultTimezone
}
if _, err := time.LoadLocation(tz); err != nil {
return nil, BadRequest("时区无效")
}
updates["timezone"] = tz
}
// 优先多段欢迎语;若仅传旧 welcome_message 则同步为单段文本
if req.WelcomeMessages != nil {
segs, firstText, err := NormalizeWelcomeSegments(*req.WelcomeMessages)
if err != nil {
return nil, BadRequest(err.Error())
}
b, err := json.Marshal(segs)
if err != nil {
return nil, BadRequest("欢迎语序列化失败")
}
updates["welcome_messages_json"] = string(b)
updates["welcome_message"] = firstText
} else if req.WelcomeMessage != nil {
msg := strings.TrimSpace(*req.WelcomeMessage)
if len([]rune(msg)) > 500 {
return nil, BadRequest("欢迎语不能超过 500 字")
}
updates["welcome_message"] = msg
// 同步为单段,避免旧字段与 JSON 不一致
if msg == "" {
updates["welcome_messages_json"] = defaultWelcomeMessagesJSON()
updates["welcome_message"] = DefaultWelcomeMessage
} else {
b, _ := json.Marshal([]WelcomeSegment{{Type: "text", Content: msg}})
updates["welcome_messages_json"] = string(b)
}
}
if req.OfflinePrompt != nil {
msg := strings.TrimSpace(*req.OfflinePrompt)
if len([]rune(msg)) > 500 {
return nil, BadRequest("离线提示不能超过 500 字")
}
updates["offline_prompt"] = msg
}
if req.WorktimePrompt != nil {
msg := strings.TrimSpace(*req.WorktimePrompt)
if len([]rune(msg)) > 500 {
return nil, BadRequest("非工作时间提示不能超过 500 字")
}
updates["worktime_prompt"] = msg
}
if req.WorkHours != nil {
normalized := map[string]string{}
for _, key := range weekdayKeys {
slot, err := NormalizeWorkHourSlot(req.WorkHours[key])
if err != nil {
return nil, BadRequest(err.Error())
}
normalized[key] = slot
}
b, err := json.Marshal(normalized)
if err != nil {
return nil, BadRequest("工作时间序列化失败")
}
updates["work_hours_json"] = string(b)
}
if req.NotifyNewSession != nil {
updates["notify_new_session"] = *req.NotifyNewSession
}
if req.NotifyOfflineLeave != nil {
updates["notify_offline_leave"] = *req.NotifyOfflineLeave
}
if req.NotifyDailyReport != nil {
updates["notify_daily_report"] = *req.NotifyDailyReport
}
if req.AssignStrategy != nil {
raw := strings.TrimSpace(*req.AssignStrategy)
if raw != AssignStrategyLeastLoad && raw != AssignStrategyRoundRobin {
return nil, BadRequest("分配策略无效,可选 least_load / round_robin")
}
updates["assign_strategy"] = raw
}
if req.MaxActivePerAgent != nil {
if *req.MaxActivePerAgent < 0 || *req.MaxActivePerAgent > 200 {
return nil, BadRequest("最大并发需为 0–200(0 表示不限制)")
}
updates["max_active_per_agent"] = *req.MaxActivePerAgent
}
if len(updates) == 0 {
return nil, BadRequest("没有可更新字段")
}
if err := model.DB.Model(setting).Updates(updates).Error; err != nil {
return nil, Internal("保存失败")
}
_ = model.DB.Where("tenant_id = ?", tenantID).First(setting).Error
return SettingsResponse(setting), nil
}