516 lines
17 KiB
Go
516 lines
17 KiB
Go
package handler
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"kefu-cloud/server/internal/middleware"
|
||
"kefu-cloud/server/internal/model"
|
||
)
|
||
|
||
type SettingsHandler struct{}
|
||
|
||
func NewSettingsHandler() *SettingsHandler { return &SettingsHandler{} }
|
||
|
||
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, " ", " ")
|
||
out = strings.ReplaceAll(out, "&", "&")
|
||
out = strings.ReplaceAll(out, "<", "<")
|
||
out = strings.ReplaceAll(out, ">", ">")
|
||
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)
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
func settingsResponse(setting *model.TenantSetting) gin.H {
|
||
strategy := normalizeAssignStrategy(setting.AssignStrategy)
|
||
maxActive := setting.MaxActivePerAgent
|
||
if maxActive < 0 {
|
||
maxActive = 0
|
||
}
|
||
welcomeSegs := resolveWelcomeSegments(setting)
|
||
return gin.H{
|
||
"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,
|
||
}
|
||
}
|
||
|
||
func (h *SettingsHandler) Get(c *gin.Context) {
|
||
tenantID := middleware.GetTenantID(c)
|
||
if tenantID == 0 {
|
||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅租户账号可查看设置"})
|
||
return
|
||
}
|
||
setting, err := getOrCreateTenantSettings(tenantID)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "加载设置失败"})
|
||
return
|
||
}
|
||
middleware.JSON(c, settingsResponse(setting))
|
||
}
|
||
|
||
type updateSettingsReq 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"`
|
||
}
|
||
|
||
func (h *SettingsHandler) Update(c *gin.Context) {
|
||
if !middleware.HasPermission(c, "settings.basic") {
|
||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅租户管理员可修改设置"})
|
||
return
|
||
}
|
||
tenantID := middleware.GetTenantID(c)
|
||
if tenantID == 0 {
|
||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅租户账号可修改设置"})
|
||
return
|
||
}
|
||
setting, err := getOrCreateTenantSettings(tenantID)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "加载设置失败"})
|
||
return
|
||
}
|
||
|
||
var req updateSettingsReq
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||
return
|
||
}
|
||
|
||
updates := map[string]interface{}{}
|
||
if req.DisplayName != nil {
|
||
name := strings.TrimSpace(*req.DisplayName)
|
||
if name == "" || len([]rune(name)) > 100 {
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "租户显示名称无效"})
|
||
return
|
||
}
|
||
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 {
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "客服昵称过长"})
|
||
return
|
||
}
|
||
updates["agent_nickname"] = nick
|
||
}
|
||
if req.Timezone != nil {
|
||
tz := strings.TrimSpace(*req.Timezone)
|
||
if tz == "" {
|
||
tz = defaultTimezone
|
||
}
|
||
if _, err := time.LoadLocation(tz); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "时区无效"})
|
||
return
|
||
}
|
||
updates["timezone"] = tz
|
||
}
|
||
// 优先多段欢迎语;若仅传旧 welcome_message 则同步为单段文本
|
||
if req.WelcomeMessages != nil {
|
||
segs, firstText, err := normalizeWelcomeSegments(*req.WelcomeMessages)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||
return
|
||
}
|
||
b, err := json.Marshal(segs)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "欢迎语序列化失败"})
|
||
return
|
||
}
|
||
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 {
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "欢迎语不能超过 500 字"})
|
||
return
|
||
}
|
||
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 {
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "离线提示不能超过 500 字"})
|
||
return
|
||
}
|
||
updates["offline_prompt"] = msg
|
||
}
|
||
if req.WorktimePrompt != nil {
|
||
msg := strings.TrimSpace(*req.WorktimePrompt)
|
||
if len([]rune(msg)) > 500 {
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "非工作时间提示不能超过 500 字"})
|
||
return
|
||
}
|
||
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 {
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||
return
|
||
}
|
||
normalized[key] = slot
|
||
}
|
||
b, err := json.Marshal(normalized)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "工作时间序列化失败"})
|
||
return
|
||
}
|
||
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 {
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "分配策略无效,可选 least_load / round_robin"})
|
||
return
|
||
}
|
||
updates["assign_strategy"] = raw
|
||
}
|
||
if req.MaxActivePerAgent != nil {
|
||
if *req.MaxActivePerAgent < 0 || *req.MaxActivePerAgent > 200 {
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "最大并发需为 0–200(0 表示不限制)"})
|
||
return
|
||
}
|
||
updates["max_active_per_agent"] = *req.MaxActivePerAgent
|
||
}
|
||
if len(updates) == 0 {
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"})
|
||
return
|
||
}
|
||
|
||
if err := model.DB.Model(setting).Updates(updates).Error; err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "保存失败"})
|
||
return
|
||
}
|
||
_ = model.DB.Where("tenant_id = ?", tenantID).First(setting).Error
|
||
middleware.JSON(c, settingsResponse(setting))
|
||
}
|