Files
kefu_cloud/server/internal/handler/settings.go
T
yml2213 d4ba9e7a56 全局重命名:kefu_sys / kefu-sys 改为 kefu_cloud / kefu-cloud
- Go module 与全部 import 路径
- 数据库默认库名、Docker 服务/卷命名与部署文档
2026-07-15 15:23:10 +08:00

325 lines
10 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"
"net/http"
"strings"
"time"
"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"
)
var weekdayKeys = []string{"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"}
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,
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
}
// 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"
}
slot := strings.TrimSpace(hours[key])
if slot == "" || slot == "休息" {
return false
}
if slot == "全天" {
return true
}
parts := strings.Split(slot, "-")
if len(parts) != 2 {
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)
}
func settingsResponse(setting *model.TenantSetting) gin.H {
strategy := normalizeAssignStrategy(setting.AssignStrategy)
maxActive := setting.MaxActivePerAgent
if maxActive < 0 {
maxActive = 0
}
return gin.H{
"tenant_id": setting.TenantID,
"display_name": setting.DisplayName,
"agent_nickname": setting.AgentNickname,
"timezone": setting.Timezone,
"welcome_message": setting.WelcomeMessage,
"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"`
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
}
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
}
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 {
val := strings.TrimSpace(req.WorkHours[key])
if val == "" || val == "休息" || val == "全天" || strings.Contains(val, "-") {
normalized[key] = val
continue
}
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "工作时间格式无效,请使用 09:00-18:00 / 全天 / 空"})
return
}
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))
}