package handler import ( "encoding/json" "fmt" "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 } // 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) } 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 { slot, err := normalizeWorkHourSlot(req.WorkHours[key]) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()}) return } normalized[key] = slot } 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": "最大并发需为 0–200(0 表示不限制)"}) 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)) }