欢迎语支持多段配置与图片

租户可配置有序文本/图片欢迎语;设置页支持增删排序与上传,Widget 空会话按序展示;兼容旧 welcome_message 字段。
This commit is contained in:
yml2213
2026-07-17 21:47:21 +08:00
parent 7319799ec4
commit 54d23bb595
8 changed files with 504 additions and 51 deletions
@@ -748,9 +748,9 @@ func TestTenantSettingsPersistAndAffectWidgetPrompt(t *testing.T) {
t.Fatalf("创建渠道失败: %v", err)
}
// 保存欢迎语与离线提示
// 保存多段欢迎语与离线提示
saveRec := httptest.NewRecorder()
saveBody := `{"welcome_message":"定制欢迎语ABC","offline_prompt":"定制离线提示XYZ","work_hours":{"monday":"全天","tuesday":"全天","wednesday":"全天","thursday":"全天","friday":"全天","saturday":"全天","sunday":"全天"}}`
saveBody := `{"welcome_messages":[{"type":"text","content":"定制欢迎语ABC"},{"type":"text","content":"第二段欢迎"}],"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())
@@ -763,8 +763,12 @@ func TestTenantSettingsPersistAndAffectWidgetPrompt(t *testing.T) {
}
var getResp struct {
Data struct {
WelcomeMessage string `json:"welcome_message"`
OfflinePrompt string `json:"offline_prompt"`
WelcomeMessage string `json:"welcome_message"`
WelcomeMessages []struct {
Type string `json:"type"`
Content string `json:"content"`
} `json:"welcome_messages"`
OfflinePrompt string `json:"offline_prompt"`
} `json:"data"`
}
if err := json.Unmarshal(getRec.Body.Bytes(), &getResp); err != nil {
@@ -773,6 +777,9 @@ func TestTenantSettingsPersistAndAffectWidgetPrompt(t *testing.T) {
if getResp.Data.WelcomeMessage != "定制欢迎语ABC" || getResp.Data.OfflinePrompt != "定制离线提示XYZ" {
t.Fatalf("设置未持久化: %+v", getResp.Data)
}
if len(getResp.Data.WelcomeMessages) != 2 || getResp.Data.WelcomeMessages[1].Content != "第二段欢迎" {
t.Fatalf("多段欢迎语未持久化: %+v", getResp.Data.WelcomeMessages)
}
// 无在线客服时 init 应返回定制离线提示
initRec := httptest.NewRecorder()
@@ -784,9 +791,13 @@ func TestTenantSettingsPersistAndAffectWidgetPrompt(t *testing.T) {
}
var initResp struct {
Data struct {
WelcomeMessage string `json:"welcome_message"`
OfflinePrompt string `json:"offline_prompt"`
AgentsOnline bool `json:"agents_online"`
WelcomeMessage string `json:"welcome_message"`
WelcomeMessages []struct {
Type string `json:"type"`
Content string `json:"content"`
} `json:"welcome_messages"`
OfflinePrompt string `json:"offline_prompt"`
AgentsOnline bool `json:"agents_online"`
} `json:"data"`
}
if err := json.Unmarshal(initRec.Body.Bytes(), &initResp); err != nil {
@@ -798,4 +809,7 @@ func TestTenantSettingsPersistAndAffectWidgetPrompt(t *testing.T) {
if initResp.Data.WelcomeMessage != "定制欢迎语ABC" || initResp.Data.OfflinePrompt != "定制离线提示XYZ" {
t.Fatalf("widget 未使用租户设置: %+v", initResp.Data)
}
if len(initResp.Data.WelcomeMessages) != 2 {
t.Fatalf("widget 未返回多段欢迎语: %+v", initResp.Data.WelcomeMessages)
}
}
+131 -15
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"strings"
"time"
"unicode/utf8"
"github.com/gin-gonic/gin"
"kefu-cloud/server/internal/middleware"
@@ -22,10 +23,99 @@ const (
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{
@@ -50,19 +140,20 @@ func getOrCreateTenantSettings(tenantID uint) (*model.TenantSetting, error) {
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,
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
@@ -173,12 +264,14 @@ func settingsResponse(setting *model.TenantSetting) gin.H {
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": setting.WelcomeMessage,
"welcome_message": firstWelcomeText(welcomeSegs),
"welcome_messages": welcomeSegs,
"offline_prompt": setting.OfflinePrompt,
"work_hours": parseWorkHours(setting.WorkHoursJSON),
"worktime_prompt": setting.WorktimePrompt,
@@ -209,6 +302,7 @@ type updateSettingsReq struct {
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"`
@@ -275,13 +369,35 @@ func (h *SettingsHandler) Update(c *gin.Context) {
}
updates["timezone"] = tz
}
if req.WelcomeMessage != nil {
// 优先多段欢迎语;若仅传旧 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)
@@ -0,0 +1,68 @@
package handler
import (
"testing"
"kefu-cloud/server/internal/model"
)
func TestNormalizeWelcomeSegments(t *testing.T) {
SetImagePublicBase("http://localhost:9100/kefu")
_, _, err := normalizeWelcomeSegments(nil)
if err == nil {
t.Fatal("空列表应失败")
}
ok, first, err := normalizeWelcomeSegments([]WelcomeSegment{
{Type: "text", Content: " 你好 "},
{Type: "image", Content: "http://localhost:9100/kefu/tenants/1/welcome/a.webp"},
{Type: "text", Content: "请描述问题"},
})
if err != nil {
t.Fatalf("合法多段应通过: %v", err)
}
if len(ok) != 3 || ok[0].Content != "你好" || first != "你好" {
t.Fatalf("规范化结果异常: %+v first=%s", ok, first)
}
_, _, err = normalizeWelcomeSegments([]WelcomeSegment{
{Type: "image", Content: "https://evil.example/x.png"},
})
if err == nil {
t.Fatal("非白名单图片 URL 应失败")
}
tooMany := make([]WelcomeSegment, maxWelcomeSegments+1)
for i := range tooMany {
tooMany[i] = WelcomeSegment{Type: "text", Content: "x"}
}
if _, _, err := normalizeWelcomeSegments(tooMany); err == nil {
t.Fatal("超过段数上限应失败")
}
}
func TestResolveWelcomeSegmentsCompat(t *testing.T) {
// 仅有旧字段
s1 := &model.TenantSetting{WelcomeMessage: "旧欢迎语"}
segs := resolveWelcomeSegments(s1)
if len(segs) != 1 || segs[0].Type != "text" || segs[0].Content != "旧欢迎语" {
t.Fatalf("旧字段兼容失败: %+v", segs)
}
// 优先 JSON
s2 := &model.TenantSetting{
WelcomeMessage: "旧",
WelcomeMessagesJSON: `[{"type":"text","content":"新1"},{"type":"image","content":"http://x/a.webp"}]`,
}
segs = resolveWelcomeSegments(s2)
if len(segs) != 2 || segs[0].Content != "新1" {
t.Fatalf("JSON 优先失败: %+v", segs)
}
// 空 → 默认
segs = resolveWelcomeSegments(&model.TenantSetting{})
if len(segs) != 1 || segs[0].Content != defaultWelcomeMessage {
t.Fatalf("默认欢迎语失败: %+v", segs)
}
}
+3 -4
View File
@@ -148,15 +148,13 @@ func (h *WidgetHandler) Init(c *gin.Context) {
ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload)
}
welcome := defaultWelcomeMessage
welcomeSegs := resolveWelcomeSegments(setting)
welcome := firstWelcomeText(welcomeSegs)
offlinePrompt := offlineLeavePrompt
agentNickname := defaultAgentNickname
displayName := "在线客服"
worktimePrompt := defaultWorktimePrompt
if setting != nil {
if setting.WelcomeMessage != "" {
welcome = setting.WelcomeMessage
}
if setting.OfflinePrompt != "" {
offlinePrompt = setting.OfflinePrompt
}
@@ -186,6 +184,7 @@ func (h *WidgetHandler) Init(c *gin.Context) {
"within_work_hours": withinWorkHours,
"session_status": session.Status,
"welcome_message": welcome,
"welcome_messages": welcomeSegs,
"display_name": displayName,
"agent_nickname": agentNickname,
}
+4 -2
View File
@@ -164,8 +164,10 @@ type TenantSetting struct {
AgentNickname string `gorm:"size:50" json:"agent_nickname"`
Timezone string `gorm:"size:50;default:Asia/Shanghai" json:"timezone"`
WelcomeMessage string `gorm:"size:500" json:"welcome_message"`
OfflinePrompt string `gorm:"size:500" json:"offline_prompt"`
WorkHoursJSON string `gorm:"type:text" json:"work_hours_json"`
// WelcomeMessagesJSON 多段欢迎语 JSON[{type:text|image, content:...}, ...]
WelcomeMessagesJSON string `gorm:"type:text" json:"welcome_messages_json"`
OfflinePrompt string `gorm:"size:500" json:"offline_prompt"`
WorkHoursJSON string `gorm:"type:text" json:"work_hours_json"`
WorktimePrompt string `gorm:"size:500" json:"worktime_prompt"`
NotifyNewSession bool `gorm:"default:true" json:"notify_new_session"`
NotifyOfflineLeave bool `gorm:"default:true" json:"notify_offline_leave"`