Files
kefu_cloud/server/internal/handler/settings.go
T
yml2213 54d23bb595 欢迎语支持多段配置与图片
租户可配置有序文本/图片欢迎语;设置页支持增删排序与上传,Widget 空会话按序展示;兼容旧 welcome_message 字段。
2026-07-17 21:47:21 +08:00

471 lines
16 KiB
Go
Raw 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 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 = 500
)
// 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" && strings.TrimSpace(s.Content) != "" {
return s.Content
}
}
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":
if c == "" {
return nil, "", fmt.Errorf("第 %d 段文本不能为空", i+1)
}
if utf8.RuneCountInString(c) > maxWelcomeTextLen {
return nil, "", fmt.Errorf("第 %d 段文本不能超过 %d 字", i+1, maxWelcomeTextLen)
}
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:0024 小时制)")
}
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.HasAnyRole(c, "admin") {
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": "最大并发需为 02000 表示不限制)"})
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))
}