package service import ( "encoding/json" "fmt" "strings" "time" "unicode/utf8" "kefu-cloud/server/internal/model" ) const ( DefaultWelcomeMessage = "您好!欢迎咨询,请问有什么可以帮您?" DefaultOfflinePrompt = "当前无客服在线,请留言并留下联系方式,我们上线后会尽快回复您。" DefaultWorktimePrompt = "当前为非工作时间,我们会在工作时段尽快回复您。" DefaultAgentNickname = "在线客服" defaultTimezone = "Asia/Shanghai" MaxWelcomeSegments = 10 maxWelcomeTextLen = 1000 // 纯文字上限(HTML 标签不计) maxWelcomeHTMLLen = 8000 // 富文本原始 HTML 上限 ) // stripHTMLTags 粗暴去标签,用于字数与摘要(欢迎语 HTML)。 func stripHTMLTags(s string) string { var b strings.Builder inTag := false for _, r := range s { switch { case r == '<': inTag = true case r == '>': inTag = false case !inTag: b.WriteRune(r) } } // 简单实体 out := b.String() out = strings.ReplaceAll(out, " ", " ") out = strings.ReplaceAll(out, "&", "&") out = strings.ReplaceAll(out, "<", "<") out = strings.ReplaceAll(out, ">", ">") return strings.TrimSpace(out) } // WelcomeSegment 单段欢迎语:文本或图片(content 为正文或对象存储 URL)。 type WelcomeSegment struct { Type string `json:"type"` // text | image 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 { return []WelcomeSegment{{Type: "text", Content: DefaultWelcomeMessage}} } func defaultWelcomeMessagesJSON() string { b, _ := json.Marshal(defaultWelcomeSegments()) return string(b) } // FirstWelcomeText 取第一段可用文本欢迎语(剥离 HTML),用于旧字段兼容展示。 func FirstWelcomeText(segs []WelcomeSegment) string { for _, s := range segs { if s.Type != "text" { continue } plain := stripHTMLTags(s.Content) if plain == "" { plain = strings.TrimSpace(s.Content) } if plain == "" { continue } // welcome_message 列 size:500 if utf8.RuneCountInString(plain) > 500 { return string([]rune(plain)[:500]) } return plain } 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() } // 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 { 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": // 允许 HTML 富文本;禁止明显脚本 lower := strings.ToLower(c) if strings.Contains(lower, " maxWelcomeTextLen { return nil, "", fmt.Errorf("第 %d 段文字不能超过 %d 字", i+1, maxWelcomeTextLen) } if utf8.RuneCountInString(c) > maxWelcomeHTMLLen { return nil, "", fmt.Errorf("第 %d 段内容过长", i+1) } 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) } // GetOrCreateTenantSettings 读取租户设置;首次访问时以默认值落库。 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:00(24 小时制)") } 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) } // SettingsResponse 组装设置响应体。 func SettingsResponse(setting *model.TenantSetting) map[string]interface{} { strategy := NormalizeAssignStrategy(setting.AssignStrategy) maxActive := setting.MaxActivePerAgent if maxActive < 0 { maxActive = 0 } welcomeSegs := ResolveWelcomeSegments(setting) return map[string]interface{}{ "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, } } // SettingsUpdateReq 设置更新请求(pointer 字段表示是否提交该字段)。 type SettingsUpdateReq 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"` } // 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 } func (r *SettingsUpdateReq) TouchesAutoReply() bool { return r.WelcomeMessage != nil || r.WelcomeMessages != nil || r.OfflinePrompt != nil } func (r *SettingsUpdateReq) TouchesWorktime() bool { return r.WorkHours != nil || r.WorktimePrompt != nil } func (r *SettingsUpdateReq) TouchesNotification() bool { return r.NotifyNewSession != nil || r.NotifyOfflineLeave != nil || r.NotifyDailyReport != nil } // UpdateTenantSettings 校验并持久化租户设置,返回刷新后的响应体。 func UpdateTenantSettings(tenantID uint, req *SettingsUpdateReq) (map[string]interface{}, error) { setting, err := GetOrCreateTenantSettings(tenantID) if err != nil { return nil, Internal("加载设置失败") } updates := map[string]interface{}{} if req.DisplayName != nil { name := strings.TrimSpace(*req.DisplayName) if name == "" || len([]rune(name)) > 100 { return nil, BadRequest("租户显示名称无效") } 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 { return nil, BadRequest("客服昵称过长") } updates["agent_nickname"] = nick } if req.Timezone != nil { tz := strings.TrimSpace(*req.Timezone) if tz == "" { tz = defaultTimezone } if _, err := time.LoadLocation(tz); err != nil { return nil, BadRequest("时区无效") } updates["timezone"] = tz } // 优先多段欢迎语;若仅传旧 welcome_message 则同步为单段文本 if req.WelcomeMessages != nil { segs, firstText, err := NormalizeWelcomeSegments(*req.WelcomeMessages) if err != nil { return nil, BadRequest(err.Error()) } b, err := json.Marshal(segs) if err != nil { return nil, BadRequest("欢迎语序列化失败") } 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 { return nil, BadRequest("欢迎语不能超过 500 字") } 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 { return nil, BadRequest("离线提示不能超过 500 字") } updates["offline_prompt"] = msg } if req.WorktimePrompt != nil { msg := strings.TrimSpace(*req.WorktimePrompt) if len([]rune(msg)) > 500 { return nil, BadRequest("非工作时间提示不能超过 500 字") } 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 { return nil, BadRequest(err.Error()) } normalized[key] = slot } b, err := json.Marshal(normalized) if err != nil { return nil, BadRequest("工作时间序列化失败") } 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 { return nil, BadRequest("分配策略无效,可选 least_load / round_robin") } updates["assign_strategy"] = raw } if req.MaxActivePerAgent != nil { if *req.MaxActivePerAgent < 0 || *req.MaxActivePerAgent > 200 { return nil, BadRequest("最大并发需为 0–200(0 表示不限制)") } updates["max_active_per_agent"] = *req.MaxActivePerAgent } if len(updates) == 0 { return nil, BadRequest("没有可更新字段") } if err := model.DB.Model(setting).Updates(updates).Error; err != nil { return nil, Internal("保存失败") } _ = model.DB.Where("tenant_id = ?", tenantID).First(setting).Error return SettingsResponse(setting), nil }