实现租户系统设置落库:欢迎语、工作时间与通知开关
- 新增 TenantSetting 模型与 /api/settings 读写接口 - 设置页接通基本资料、自动回复、工作时间、通知偏好 - Widget 初始化使用欢迎语/离线提示,非工作时间不自动分配 - 补充设置持久化与 Widget 提示集成测试
This commit is contained in:
@@ -13,6 +13,7 @@ func SetupRoutes(r *gin.Engine) {
|
||||
stats := NewStatisticsHandler()
|
||||
admin := NewAdminHandler()
|
||||
channel := NewChannelHandler()
|
||||
settings := NewSettingsHandler()
|
||||
ws := NewWsHandler()
|
||||
widget := NewWidgetHandler()
|
||||
|
||||
@@ -75,6 +76,11 @@ func SetupRoutes(r *gin.Engine) {
|
||||
channels.POST("", channel.Create)
|
||||
channels.PUT("/:id", channel.Update)
|
||||
|
||||
// 租户系统设置
|
||||
settingsGroup := authRequired.Group("/settings")
|
||||
settingsGroup.GET("", settings.Get)
|
||||
settingsGroup.PUT("", settings.Update)
|
||||
|
||||
// 统计
|
||||
statistics := authRequired.Group("/statistics")
|
||||
statistics.GET("/kpi", stats.KPIs)
|
||||
|
||||
@@ -682,3 +682,67 @@ func TestAdminTenantLifecycleAndPlanToggle(t *testing.T) {
|
||||
t.Fatalf("套餐状态未更新: %+v", updatedPlan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantSettingsPersistAndAffectWidgetPrompt(t *testing.T) {
|
||||
router := setupRouter(t)
|
||||
tenant := createTenant(t, "设置租户", "normal")
|
||||
admin := createUser(t, tenant.ID, "settings-admin", "admin")
|
||||
channel := model.Channel{
|
||||
TenantID: tenant.ID, Type: "web", Name: "网页", Status: "enabled",
|
||||
ScriptCode: `<script data-id="WK_set_001"></script>`,
|
||||
}
|
||||
if err := model.DB.Create(&channel).Error; err != nil {
|
||||
t.Fatalf("创建渠道失败: %v", err)
|
||||
}
|
||||
|
||||
// 保存欢迎语与离线提示
|
||||
saveRec := httptest.NewRecorder()
|
||||
saveBody := `{"welcome_message":"定制欢迎语ABC","offline_prompt":"定制离线提示XYZ","work_hours":{"monday":"全天","tuesday":"全天","wednesday":"全天","thursday":"全天","friday":"全天","saturday":"全天","sunday":"全天"}}`
|
||||
router.ServeHTTP(saveRec, bearerRequest(t, http.MethodPut, "/api/settings", []byte(saveBody), admin))
|
||||
if saveRec.Code != http.StatusOK {
|
||||
t.Fatalf("保存设置失败: %s", saveRec.Body.String())
|
||||
}
|
||||
|
||||
getRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(getRec, bearerRequest(t, http.MethodGet, "/api/settings", nil, admin))
|
||||
if getRec.Code != http.StatusOK {
|
||||
t.Fatalf("读取设置失败: %s", getRec.Body.String())
|
||||
}
|
||||
var getResp struct {
|
||||
Data struct {
|
||||
WelcomeMessage string `json:"welcome_message"`
|
||||
OfflinePrompt string `json:"offline_prompt"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(getRec.Body.Bytes(), &getResp); err != nil {
|
||||
t.Fatalf("解析设置失败: %v", err)
|
||||
}
|
||||
if getResp.Data.WelcomeMessage != "定制欢迎语ABC" || getResp.Data.OfflinePrompt != "定制离线提示XYZ" {
|
||||
t.Fatalf("设置未持久化: %+v", getResp.Data)
|
||||
}
|
||||
|
||||
// 无在线客服时 init 应返回定制离线提示
|
||||
initRec := httptest.NewRecorder()
|
||||
initReq := httptest.NewRequest(http.MethodPost, "/api/widget/init", bytes.NewBufferString(`{"channel_key":"WK_set_001"}`))
|
||||
initReq.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(initRec, initReq)
|
||||
if initRec.Code != http.StatusOK {
|
||||
t.Fatalf("widget init 失败: %s", initRec.Body.String())
|
||||
}
|
||||
var initResp struct {
|
||||
Data struct {
|
||||
WelcomeMessage string `json:"welcome_message"`
|
||||
OfflinePrompt string `json:"offline_prompt"`
|
||||
AgentsOnline bool `json:"agents_online"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(initRec.Body.Bytes(), &initResp); err != nil {
|
||||
t.Fatalf("解析 init 失败: %v", err)
|
||||
}
|
||||
if initResp.Data.AgentsOnline {
|
||||
t.Fatalf("无客服时应离线")
|
||||
}
|
||||
if initResp.Data.WelcomeMessage != "定制欢迎语ABC" || initResp.Data.OfflinePrompt != "定制离线提示XYZ" {
|
||||
t.Fatalf("widget 未使用租户设置: %+v", initResp.Data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"kefu-sys/server/internal/middleware"
|
||||
"kefu-sys/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,
|
||||
}
|
||||
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 {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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 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))
|
||||
}
|
||||
@@ -125,8 +125,13 @@ func (h *WidgetHandler) Init(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
setting, _ := getOrCreateTenantSettings(channel.TenantID)
|
||||
withinWorkHours := isWithinWorkHours(setting, time.Now())
|
||||
// 非工作时间不自动分配,走留言/排队提示
|
||||
canServeNow := onlineCount > 0 && withinWorkHours
|
||||
|
||||
var assignedAgent *model.User
|
||||
if onlineCount > 0 {
|
||||
if canServeNow {
|
||||
assignedAgent, err = tryAutoAssign(&session)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "自动分配失败"})
|
||||
@@ -138,22 +143,55 @@ func (h *WidgetHandler) Init(c *gin.Context) {
|
||||
ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload)
|
||||
}
|
||||
|
||||
agentsOnline := onlineCount > 0
|
||||
resp := gin.H{
|
||||
"session_id": session.ID,
|
||||
"customer_id": customer.ID,
|
||||
"channel_id": channel.ID,
|
||||
"tenant_id": channel.TenantID,
|
||||
"visitor_token": visitorToken,
|
||||
"agents_online": agentsOnline,
|
||||
"session_status": session.Status,
|
||||
welcome := defaultWelcomeMessage
|
||||
offlinePrompt := offlineLeavePrompt
|
||||
agentNickname := defaultAgentNickname
|
||||
displayName := "在线客服"
|
||||
worktimePrompt := defaultWorktimePrompt
|
||||
if setting != nil {
|
||||
if setting.WelcomeMessage != "" {
|
||||
welcome = setting.WelcomeMessage
|
||||
}
|
||||
if setting.OfflinePrompt != "" {
|
||||
offlinePrompt = setting.OfflinePrompt
|
||||
}
|
||||
if setting.AgentNickname != "" {
|
||||
agentNickname = setting.AgentNickname
|
||||
}
|
||||
if setting.DisplayName != "" {
|
||||
displayName = setting.DisplayName
|
||||
}
|
||||
if setting.WorktimePrompt != "" {
|
||||
worktimePrompt = setting.WorktimePrompt
|
||||
}
|
||||
}
|
||||
if !agentsOnline {
|
||||
resp["offline_prompt"] = offlineLeavePrompt
|
||||
// 非工作时间优先展示非工作时间提示
|
||||
if !withinWorkHours {
|
||||
offlinePrompt = worktimePrompt
|
||||
}
|
||||
|
||||
resp := gin.H{
|
||||
"session_id": session.ID,
|
||||
"customer_id": customer.ID,
|
||||
"channel_id": channel.ID,
|
||||
"tenant_id": channel.TenantID,
|
||||
"visitor_token": visitorToken,
|
||||
"agents_online": canServeNow,
|
||||
"online_agent_count": onlineCount,
|
||||
"within_work_hours": withinWorkHours,
|
||||
"session_status": session.Status,
|
||||
"welcome_message": welcome,
|
||||
"display_name": displayName,
|
||||
"agent_nickname": agentNickname,
|
||||
}
|
||||
if !canServeNow {
|
||||
resp["offline_prompt"] = offlinePrompt
|
||||
}
|
||||
if assignedAgent != nil {
|
||||
resp["agent_id"] = assignedAgent.ID
|
||||
resp["agent_name"] = assignedAgent.Nickname
|
||||
} else if canServeNow {
|
||||
resp["agent_name"] = agentNickname
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "data": resp})
|
||||
|
||||
Reference in New Issue
Block a user