fix(widget): 完善自动回复与新会话进线提醒
This commit is contained in:
@@ -56,12 +56,12 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
|
||||
authRequired := api.Group("")
|
||||
authRequired.Use(middleware.AuthRequired())
|
||||
{
|
||||
// 当前用户
|
||||
authRequired.GET("/me", auth.Me)
|
||||
authRequired.GET("/me/permissions", auth.MePermissions)
|
||||
authRequired.PUT("/me/status", auth.UpdatePresence)
|
||||
// 令牌续期(前端定时调用,防止长挂机后过期掉线)
|
||||
authRequired.POST("/auth/refresh", auth.Refresh)
|
||||
// 当前用户
|
||||
authRequired.GET("/me", auth.Me)
|
||||
authRequired.GET("/me/permissions", auth.MePermissions)
|
||||
authRequired.PUT("/me/status", auth.UpdatePresence)
|
||||
// 令牌续期(前端定时调用,防止长挂机后过期掉线)
|
||||
authRequired.POST("/auth/refresh", auth.Refresh)
|
||||
|
||||
// WebSocket
|
||||
authRequired.GET("/ws", wsHandler.Connect)
|
||||
@@ -143,9 +143,13 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
|
||||
// 租户系统设置
|
||||
settingsGroup := authRequired.Group("/settings")
|
||||
settingsGroup.GET("", middleware.RequirePermission(
|
||||
"settings.basic", "settings.assign_rule", "settings.auto_reply", "settings.worktime", "settings.notification",
|
||||
"settings.basic", "settings.channel", "settings.staff", "settings.assign_rule",
|
||||
"settings.customer_tag", "settings.auto_reply", "settings.worktime", "settings.notification",
|
||||
), settings.Get)
|
||||
settingsGroup.PUT("", middleware.RequirePermission("settings.basic"), settings.Update)
|
||||
settingsGroup.PUT("", middleware.RequirePermission(
|
||||
"settings.basic", "settings.channel", "settings.staff", "settings.assign_rule",
|
||||
"settings.customer_tag", "settings.auto_reply", "settings.worktime", "settings.notification",
|
||||
), settings.Update)
|
||||
|
||||
// 坐席账号(租户内)
|
||||
staffGroup := authRequired.Group("/staff")
|
||||
|
||||
@@ -810,6 +810,33 @@ func TestSettingsAndExportsHonorFineGrainedPermissions(t *testing.T) {
|
||||
t.Fatalf("缺少分配规则权限仍可修改设置: %d %s", settings.Code, settings.Body.String())
|
||||
}
|
||||
|
||||
autoReplyRoleRec := httptest.NewRecorder()
|
||||
autoReplyRoleBody := []byte(`{"name":"自动回复主管","permissions":["settings.auto_reply"],"data_scopes":{}}`)
|
||||
router.ServeHTTP(autoReplyRoleRec, bearerRequest(t, http.MethodPost, "/api/roles", autoReplyRoleBody, admin))
|
||||
if autoReplyRoleRec.Code != http.StatusOK {
|
||||
t.Fatalf("创建自动回复角色失败: %d %s", autoReplyRoleRec.Code, autoReplyRoleRec.Body.String())
|
||||
}
|
||||
var autoReplyRoleResp struct {
|
||||
Data model.Role `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(autoReplyRoleRec.Body.Bytes(), &autoReplyRoleResp); err != nil {
|
||||
t.Fatalf("解析自动回复角色失败: %v", err)
|
||||
}
|
||||
autoReplyUser := createUser(t, tenant.ID, "auto-reply-supervisor", autoReplyRoleResp.Data.Code)
|
||||
|
||||
autoReplySave := httptest.NewRecorder()
|
||||
autoReplyBody := []byte(`{"welcome_messages":[{"type":"text","content":"主管配置的欢迎语"}],"offline_prompt":"主管配置的离线提示"}`)
|
||||
router.ServeHTTP(autoReplySave, bearerRequest(t, http.MethodPut, "/api/settings", autoReplyBody, autoReplyUser))
|
||||
if autoReplySave.Code != http.StatusOK {
|
||||
t.Fatalf("自动回复权限应允许保存: %d %s", autoReplySave.Code, autoReplySave.Body.String())
|
||||
}
|
||||
|
||||
basicSettingsSave := httptest.NewRecorder()
|
||||
router.ServeHTTP(basicSettingsSave, bearerRequest(t, http.MethodPut, "/api/settings", []byte(`{"display_name":"越权修改"}`), autoReplyUser))
|
||||
if basicSettingsSave.Code != http.StatusForbidden {
|
||||
t.Fatalf("自动回复权限不应允许修改基本设置: %d %s", basicSettingsSave.Code, basicSettingsSave.Body.String())
|
||||
}
|
||||
|
||||
customerOne := model.Customer{TenantID: tenant.ID, Name: "客户一"}
|
||||
customerTwo := model.Customer{TenantID: tenant.ID, Name: "客户二"}
|
||||
if err := model.DB.Create(&customerOne).Error; err != nil {
|
||||
@@ -1037,6 +1064,8 @@ func TestTenantSettingsPersistAndAffectWidgetPrompt(t *testing.T) {
|
||||
}
|
||||
var initResp struct {
|
||||
Data struct {
|
||||
SessionID uint `json:"session_id"`
|
||||
VisitorToken string `json:"visitor_token"`
|
||||
WelcomeMessage string `json:"welcome_message"`
|
||||
WelcomeMessages []struct {
|
||||
Type string `json:"type"`
|
||||
@@ -1058,4 +1087,40 @@ func TestTenantSettingsPersistAndAffectWidgetPrompt(t *testing.T) {
|
||||
if len(initResp.Data.WelcomeMessages) != 2 {
|
||||
t.Fatalf("widget 未返回多段欢迎语: %+v", initResp.Data.WelcomeMessages)
|
||||
}
|
||||
|
||||
// 已存在的会话重新拉消息时,应同步最新的自动回复配置。
|
||||
updateRec := httptest.NewRecorder()
|
||||
updateBody := `{"welcome_messages":[{"type":"text","content":"刷新后的欢迎语"}],"offline_prompt":"刷新后的离线提示"}`
|
||||
router.ServeHTTP(updateRec, bearerRequest(t, http.MethodPut, "/api/settings", []byte(updateBody), admin))
|
||||
if updateRec.Code != http.StatusOK {
|
||||
t.Fatalf("更新自动回复失败: %s", updateRec.Body.String())
|
||||
}
|
||||
|
||||
messageReq := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/widget/messages?session_id=%d", initResp.Data.SessionID), nil)
|
||||
messageReq.Header.Set("X-Visitor-Token", initResp.Data.VisitorToken)
|
||||
messageRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(messageRec, messageReq)
|
||||
if messageRec.Code != http.StatusOK {
|
||||
t.Fatalf("读取已有会话消息失败: %s", messageRec.Body.String())
|
||||
}
|
||||
var messageResp struct {
|
||||
Data struct {
|
||||
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(messageRec.Body.Bytes(), &messageResp); err != nil {
|
||||
t.Fatalf("解析已有会话消息响应失败: %v", err)
|
||||
}
|
||||
if messageResp.Data.WelcomeMessage != "刷新后的欢迎语" ||
|
||||
len(messageResp.Data.WelcomeMessages) != 1 ||
|
||||
messageResp.Data.OfflinePrompt != "刷新后的离线提示" ||
|
||||
messageResp.Data.AgentsOnline {
|
||||
t.Fatalf("已有会话未同步最新自动回复: %+v", messageResp.Data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,6 @@ func (h *SettingsHandler) Get(c *gin.Context) {
|
||||
}
|
||||
|
||||
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": "仅租户账号可修改设置"})
|
||||
@@ -47,6 +43,10 @@ func (h *SettingsHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
// 设置权限按字段分区校验,避免仅拥有基本设置权限即可修改分配、工作时间或通知策略。
|
||||
if req.TouchesBasic() && !middleware.HasPermission(c, "settings.basic") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权修改基本设置"})
|
||||
return
|
||||
}
|
||||
if req.TouchesAssignRule() && !middleware.HasPermission(c, "settings.assign_rule") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权修改客服分配规则"})
|
||||
return
|
||||
|
||||
@@ -51,6 +51,15 @@ type WelcomeSegment struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// WidgetReplyConfig 是访客端展示自动回复所需的租户配置。
|
||||
type WidgetReplyConfig struct {
|
||||
WelcomeMessage string
|
||||
WelcomeMessages []WelcomeSegment
|
||||
OfflinePrompt string
|
||||
AgentNickname string
|
||||
DisplayName string
|
||||
}
|
||||
|
||||
var weekdayKeys = []string{"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"}
|
||||
|
||||
func defaultWelcomeSegments() []WelcomeSegment {
|
||||
@@ -112,6 +121,37 @@ func ResolveWelcomeSegments(setting *model.TenantSetting) []WelcomeSegment {
|
||||
return defaultWelcomeSegments()
|
||||
}
|
||||
|
||||
// ResolveWidgetReplyConfig 将租户设置转换为访客端可直接使用的自动回复配置。
|
||||
func ResolveWidgetReplyConfig(setting *model.TenantSetting, withinWorkHours bool) WidgetReplyConfig {
|
||||
config := WidgetReplyConfig{
|
||||
WelcomeMessage: DefaultWelcomeMessage,
|
||||
WelcomeMessages: defaultWelcomeSegments(),
|
||||
OfflinePrompt: DefaultOfflinePrompt,
|
||||
AgentNickname: DefaultAgentNickname,
|
||||
DisplayName: "在线客服",
|
||||
}
|
||||
if setting != nil {
|
||||
config.WelcomeMessages = ResolveWelcomeSegments(setting)
|
||||
config.WelcomeMessage = FirstWelcomeText(config.WelcomeMessages)
|
||||
if setting.OfflinePrompt != "" {
|
||||
config.OfflinePrompt = setting.OfflinePrompt
|
||||
}
|
||||
if setting.AgentNickname != "" {
|
||||
config.AgentNickname = setting.AgentNickname
|
||||
}
|
||||
if setting.DisplayName != "" {
|
||||
config.DisplayName = setting.DisplayName
|
||||
}
|
||||
}
|
||||
if !withinWorkHours {
|
||||
config.OfflinePrompt = DefaultWorktimePrompt
|
||||
if setting != nil && setting.WorktimePrompt != "" {
|
||||
config.OfflinePrompt = setting.WorktimePrompt
|
||||
}
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
// NormalizeWelcomeSegments 校验并规范化欢迎语段落;返回规范化列表与兼容用首段文本。
|
||||
func NormalizeWelcomeSegments(input []WelcomeSegment) ([]WelcomeSegment, string, error) {
|
||||
if len(input) == 0 {
|
||||
@@ -343,6 +383,10 @@ type SettingsUpdateReq struct {
|
||||
|
||||
// TouchesAssignRule / TouchesAutoReply / TouchesWorktime / TouchesNotification
|
||||
// 供 handler 按字段分区校验权限。
|
||||
func (r *SettingsUpdateReq) TouchesBasic() bool {
|
||||
return r.DisplayName != nil || r.AgentNickname != nil || r.Timezone != nil
|
||||
}
|
||||
|
||||
func (r *SettingsUpdateReq) TouchesAssignRule() bool {
|
||||
return r.AssignStrategy != nil || r.MaxActivePerAgent != nil
|
||||
}
|
||||
|
||||
@@ -152,31 +152,14 @@ func WidgetInit(in WidgetInitInput) (map[string]interface{}, error) {
|
||||
if payload, err := ws.NewEvent("session_created", session.ID, session); err == nil {
|
||||
ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload)
|
||||
}
|
||||
if setting == nil || setting.NotifyNewSession {
|
||||
notice := map[string]string{"visitor_name": customer.Name}
|
||||
if payload, err := ws.NewEvent("session_created_notify", session.ID, notice); err == nil {
|
||||
ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload)
|
||||
}
|
||||
}
|
||||
|
||||
welcomeSegs := ResolveWelcomeSegments(setting)
|
||||
welcome := FirstWelcomeText(welcomeSegs)
|
||||
offlinePrompt := DefaultOfflinePrompt
|
||||
agentNickname := DefaultAgentNickname
|
||||
displayName := "在线客服"
|
||||
worktimePrompt := DefaultWorktimePrompt
|
||||
if setting != nil {
|
||||
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 !withinWorkHours {
|
||||
offlinePrompt = worktimePrompt
|
||||
}
|
||||
replyConfig := ResolveWidgetReplyConfig(setting, withinWorkHours)
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"session_id": session.ID,
|
||||
@@ -188,19 +171,19 @@ func WidgetInit(in WidgetInitInput) (map[string]interface{}, error) {
|
||||
"online_agent_count": onlineCount,
|
||||
"within_work_hours": withinWorkHours,
|
||||
"session_status": session.Status,
|
||||
"welcome_message": welcome,
|
||||
"welcome_messages": welcomeSegs,
|
||||
"display_name": displayName,
|
||||
"agent_nickname": agentNickname,
|
||||
"welcome_message": replyConfig.WelcomeMessage,
|
||||
"welcome_messages": replyConfig.WelcomeMessages,
|
||||
"display_name": replyConfig.DisplayName,
|
||||
"agent_nickname": replyConfig.AgentNickname,
|
||||
}
|
||||
if !canServeNow {
|
||||
resp["offline_prompt"] = offlinePrompt
|
||||
resp["offline_prompt"] = replyConfig.OfflinePrompt
|
||||
}
|
||||
if assignedAgent != nil {
|
||||
resp["agent_id"] = assignedAgent.ID
|
||||
resp["agent_name"] = assignedAgent.Nickname
|
||||
} else if canServeNow {
|
||||
resp["agent_name"] = agentNickname
|
||||
resp["agent_name"] = replyConfig.AgentNickname
|
||||
}
|
||||
resp["landing_url"] = session.LandingURL
|
||||
resp["current_url"] = session.CurrentURL
|
||||
@@ -501,12 +484,27 @@ func WidgetMessages(session *model.Session, afterSeq, limit int) (map[string]int
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
setting, _ := GetOrCreateTenantSettings(session.TenantID)
|
||||
withinWorkHours := IsWithinWorkHours(setting, time.Now())
|
||||
onlineCount, _ := CountOnlineAgents(session.TenantID)
|
||||
canServeNow := session.Status == "active" || (onlineCount > 0 && withinWorkHours)
|
||||
replyConfig := ResolveWidgetReplyConfig(setting, withinWorkHours)
|
||||
|
||||
result := map[string]interface{}{
|
||||
"messages": page.Messages,
|
||||
"after_seq": page.AfterSeq,
|
||||
"max_seq": page.MaxSeq,
|
||||
"has_more": page.HasMore,
|
||||
"session_status": session.Status,
|
||||
"satisfaction_score": session.SatisfactionScore,
|
||||
}, nil
|
||||
"welcome_message": replyConfig.WelcomeMessage,
|
||||
"welcome_messages": replyConfig.WelcomeMessages,
|
||||
"display_name": replyConfig.DisplayName,
|
||||
"agent_nickname": replyConfig.AgentNickname,
|
||||
"agents_online": canServeNow,
|
||||
}
|
||||
if !canServeNow {
|
||||
result["offline_prompt"] = replyConfig.OfflinePrompt
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user