Files
kefu_cloud/server/internal/handler/quick_reply.go
T
yml2213 c5f28dca1c 新增独立快捷回复模块(团队/个人)
从知识库拆出快捷回复:支持团队暂存与发布同步、个人话术、输入码与工作台 / 调用,以及 CSV 导入导出;管理页顶栏与侧栏对齐。
2026-07-17 21:59:10 +08:00

670 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"encoding/csv"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/gin-gonic/gin"
"kefu-cloud/server/internal/middleware"
"kefu-cloud/server/internal/model"
)
const (
quickReplyScopeTeam = "team"
quickReplyScopePersonal = "personal"
quickReplyStatusDraft = "draft"
quickReplyStatusPub = "published"
maxQuickReplyTitle = 100
maxQuickReplyContent = 2000
maxQuickReplyShort = 32
maxQuickReplyGroup = 50
maxQuickReplyImport = 1000
)
var shortcutRe = regexp.MustCompile(`^[a-zA-Z0-9_\-]{1,32}$`)
type QuickReplyHandler struct{}
func NewQuickReplyHandler() *QuickReplyHandler { return &QuickReplyHandler{} }
func requireTeamQuickReplyManager(c *gin.Context) bool {
if middleware.HasAnyRole(c, "admin", "supervisor") {
return true
}
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅管理员或主管可管理团队快捷回复"})
return false
}
func normalizeShortcut(raw string) (string, error) {
s := strings.TrimSpace(raw)
if s == "" {
return "", nil
}
// 允许用户输入 /nh,统一去掉前导 /
s = strings.TrimPrefix(s, "/")
s = strings.ToLower(strings.TrimSpace(s))
if !shortcutRe.MatchString(s) {
return "", fmt.Errorf("输入码仅支持字母、数字、下划线、短横线,最长 32")
}
if utf8.RuneCountInString(s) > maxQuickReplyShort {
return "", fmt.Errorf("输入码过长")
}
return s, nil
}
func normalizeQRStatus(s string, fallback string) string {
s = strings.TrimSpace(s)
if s == quickReplyStatusDraft || s == quickReplyStatusPub {
return s
}
return fallback
}
func loadQuickReply(c *gin.Context, id uint) (*model.QuickReply, bool) {
tenantID := middleware.GetTenantID(c)
var item model.QuickReply
if err := model.DB.Where("id = ? AND tenant_id = ?", id, tenantID).First(&item).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "快捷回复不存在"})
return nil, false
}
return &item, true
}
func canEditQuickReply(c *gin.Context, item *model.QuickReply) bool {
uid := middleware.GetUserID(c)
if item.Scope == quickReplyScopeTeam {
return middleware.HasAnyRole(c, "admin", "supervisor")
}
return item.OwnerUserID != nil && *item.OwnerUserID == uid
}
func shortcutConflict(tenantID uint, scope string, ownerID *uint, shortcut string, excludeID uint) bool {
if shortcut == "" {
return false
}
q := model.DB.Model(&model.QuickReply{}).
Where("tenant_id = ? AND scope = ? AND shortcut = ?", tenantID, scope, shortcut)
if excludeID > 0 {
q = q.Where("id <> ?", excludeID)
}
if scope == quickReplyScopePersonal {
if ownerID == nil {
return true
}
q = q.Where("owner_user_id = ?", *ownerID)
} else {
q = q.Where("owner_user_id IS NULL")
}
var n int64
q.Count(&n)
return n > 0
}
type quickReplyReq struct {
Title string `json:"title"`
Content string `json:"content"`
Shortcut string `json:"shortcut"`
GroupName string `json:"group_name"`
Status string `json:"status"`
SortOrder *int `json:"sort_order"`
Scope string `json:"scope"` // create only
}
func parseQuickReplyBody(req *quickReplyReq) (title, content, shortcut, group, status string, err error) {
title = strings.TrimSpace(req.Title)
content = strings.TrimSpace(req.Content)
if title == "" {
return "", "", "", "", "", fmt.Errorf("标题不能为空")
}
if utf8.RuneCountInString(title) > maxQuickReplyTitle {
return "", "", "", "", "", fmt.Errorf("标题不能超过 %d 字", maxQuickReplyTitle)
}
if content == "" {
return "", "", "", "", "", fmt.Errorf("内容不能为空")
}
if utf8.RuneCountInString(content) > maxQuickReplyContent {
return "", "", "", "", "", fmt.Errorf("内容不能超过 %d 字", maxQuickReplyContent)
}
shortcut, err = normalizeShortcut(req.Shortcut)
if err != nil {
return "", "", "", "", "", err
}
group = strings.TrimSpace(req.GroupName)
if utf8.RuneCountInString(group) > maxQuickReplyGroup {
return "", "", "", "", "", fmt.Errorf("分组名过长")
}
status = normalizeQRStatus(req.Status, quickReplyStatusDraft)
return title, content, shortcut, group, status, nil
}
// List GET /api/quick-replies
// scope=team|personal|all status=draft|published q=keyword
func (h *QuickReplyHandler) List(c *gin.Context) {
tenantID := middleware.GetTenantID(c)
uid := middleware.GetUserID(c)
scope := strings.TrimSpace(c.Query("scope"))
if scope == "" {
scope = "all"
}
status := strings.TrimSpace(c.Query("status"))
q := strings.TrimSpace(c.Query("q"))
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "50"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 200 {
pageSize = 50
}
db := model.DB.Model(&model.QuickReply{}).Where("tenant_id = ?", tenantID)
switch scope {
case quickReplyScopeTeam:
db = db.Where("scope = ?", quickReplyScopeTeam)
// 非管理端:工作台只看已发布;管理页可传 status
if !middleware.HasAnyRole(c, "admin", "supervisor") {
db = db.Where("status = ?", quickReplyStatusPub)
} else if status != "" {
db = db.Where("status = ?", status)
}
case quickReplyScopePersonal:
db = db.Where("scope = ? AND owner_user_id = ?", quickReplyScopePersonal, uid)
if status != "" {
db = db.Where("status = ?", status)
}
case "all":
// 工作台:团队已发布 + 本人个人(默认全部个人,便于草稿也自己用)
db = db.Where(
"(scope = ? AND status = ?) OR (scope = ? AND owner_user_id = ?)",
quickReplyScopeTeam, quickReplyStatusPub,
quickReplyScopePersonal, uid,
)
if status == quickReplyStatusPub {
db = db.Where("status = ?", quickReplyStatusPub)
}
default:
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "scope 无效"})
return
}
if q != "" {
like := "%" + q + "%"
db = db.Where("title LIKE ? OR content LIKE ? OR shortcut LIKE ? OR group_name LIKE ?", like, like, like, like)
}
var total int64
db.Count(&total)
var list []model.QuickReply
if err := db.Order("usage_count desc, sort_order asc, id desc").
Offset((page - 1) * pageSize).Limit(pageSize).
Find(&list).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "加载失败"})
return
}
middleware.JSONList(c, list, total, page, pageSize)
}
// Suggest GET /api/quick-replies/suggest?prefix=
// 供输入框 / 触发:匹配 shortcut 前缀或标题
func (h *QuickReplyHandler) Suggest(c *gin.Context) {
tenantID := middleware.GetTenantID(c)
uid := middleware.GetUserID(c)
prefix := strings.TrimSpace(c.Query("prefix"))
prefix = strings.TrimPrefix(strings.ToLower(prefix), "/")
db := model.DB.Model(&model.QuickReply{}).Where("tenant_id = ?", tenantID).Where(
"(scope = ? AND status = ?) OR (scope = ? AND owner_user_id = ? AND status = ?)",
quickReplyScopeTeam, quickReplyStatusPub,
quickReplyScopePersonal, uid, quickReplyStatusPub,
)
if prefix != "" {
like := prefix + "%"
titleLike := "%" + prefix + "%"
db = db.Where("shortcut LIKE ? OR title LIKE ?", like, titleLike)
}
var list []model.QuickReply
// 无前缀时返回高频
order := "usage_count desc, sort_order asc, id desc"
if err := db.Order(order).Limit(20).Find(&list).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "搜索失败"})
return
}
middleware.JSON(c, list)
}
// Create POST /api/quick-replies
func (h *QuickReplyHandler) Create(c *gin.Context) {
tenantID := middleware.GetTenantID(c)
uid := middleware.GetUserID(c)
var req quickReplyReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
return
}
scope := strings.TrimSpace(req.Scope)
if scope == "" {
scope = quickReplyScopePersonal
}
if scope != quickReplyScopeTeam && scope != quickReplyScopePersonal {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "scope 无效"})
return
}
if scope == quickReplyScopeTeam && !requireTeamQuickReplyManager(c) {
return
}
title, content, shortcut, group, status, err := parseQuickReplyBody(&req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
return
}
// 个人默认 published,便于立刻使用;团队默认 draft(可显式 published
if req.Status == "" {
if scope == quickReplyScopePersonal {
status = quickReplyStatusPub
} else {
status = quickReplyStatusDraft
}
}
var ownerID *uint
if scope == quickReplyScopePersonal {
ownerID = &uid
}
if shortcutConflict(tenantID, scope, ownerID, shortcut, 0) {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "输入码已存在"})
return
}
sortOrder := 0
if req.SortOrder != nil {
sortOrder = *req.SortOrder
}
item := model.QuickReply{
TenantID: tenantID,
Scope: scope,
OwnerUserID: ownerID,
Title: title,
Content: content,
Shortcut: shortcut,
GroupName: group,
Status: status,
SortOrder: sortOrder,
}
if err := model.DB.Create(&item).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建失败"})
return
}
middleware.JSON(c, item)
}
// Update PUT /api/quick-replies/:id
func (h *QuickReplyHandler) Update(c *gin.Context) {
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if id64 == 0 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
return
}
item, ok := loadQuickReply(c, uint(id64))
if !ok {
return
}
if !canEditQuickReply(c, item) {
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权修改该快捷回复"})
return
}
var req quickReplyReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
return
}
title, content, shortcut, group, status, err := parseQuickReplyBody(&req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
return
}
if req.Status == "" {
status = item.Status
}
if shortcutConflict(item.TenantID, item.Scope, item.OwnerUserID, shortcut, item.ID) {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "输入码已存在"})
return
}
updates := map[string]interface{}{
"title": title,
"content": content,
"shortcut": shortcut,
"group_name": group,
"status": status,
}
if req.SortOrder != nil {
updates["sort_order"] = *req.SortOrder
}
if err := model.DB.Model(item).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"})
return
}
_ = model.DB.First(item, item.ID)
middleware.JSON(c, item)
}
// Delete DELETE /api/quick-replies/:id
func (h *QuickReplyHandler) Delete(c *gin.Context) {
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
if id64 == 0 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
return
}
item, ok := loadQuickReply(c, uint(id64))
if !ok {
return
}
if !canEditQuickReply(c, item) {
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权删除该快捷回复"})
return
}
if err := model.DB.Delete(item).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "删除失败"})
return
}
middleware.JSON(c, gin.H{"ok": true})
}
// Publish POST /api/quick-replies/:id/publish 团队:暂存 → 发布同步
func (h *QuickReplyHandler) Publish(c *gin.Context) {
if !requireTeamQuickReplyManager(c) {
return
}
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
item, ok := loadQuickReply(c, uint(id64))
if !ok {
return
}
if item.Scope != quickReplyScopeTeam {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "仅团队快捷回复支持发布同步"})
return
}
if err := model.DB.Model(item).Update("status", quickReplyStatusPub).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "发布失败"})
return
}
_ = model.DB.First(item, item.ID)
middleware.JSON(c, item)
}
// Unpublish POST /api/quick-replies/:id/unpublish
func (h *QuickReplyHandler) Unpublish(c *gin.Context) {
if !requireTeamQuickReplyManager(c) {
return
}
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
item, ok := loadQuickReply(c, uint(id64))
if !ok {
return
}
if item.Scope != quickReplyScopeTeam {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "仅团队快捷回复支持下线"})
return
}
if err := model.DB.Model(item).Update("status", quickReplyStatusDraft).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "操作失败"})
return
}
_ = model.DB.First(item, item.ID)
middleware.JSON(c, item)
}
// Use POST /api/quick-replies/:id/use 使用计数 +1
func (h *QuickReplyHandler) Use(c *gin.Context) {
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
item, ok := loadQuickReply(c, uint(id64))
if !ok {
return
}
// 可见性:团队已发布,或本人个人
uid := middleware.GetUserID(c)
if item.Scope == quickReplyScopeTeam && item.Status != quickReplyStatusPub {
if !middleware.HasAnyRole(c, "admin", "supervisor") {
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "该话术尚未发布"})
return
}
}
if item.Scope == quickReplyScopePersonal && (item.OwnerUserID == nil || *item.OwnerUserID != uid) {
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权使用"})
return
}
_ = model.DB.Model(item).UpdateColumn("usage_count", item.UsageCount+1).Error
middleware.JSON(c, gin.H{"ok": true, "usage_count": item.UsageCount + 1})
}
// Export GET /api/quick-replies/export?scope=team|personal
func (h *QuickReplyHandler) Export(c *gin.Context) {
tenantID := middleware.GetTenantID(c)
uid := middleware.GetUserID(c)
scope := strings.TrimSpace(c.Query("scope"))
if scope == "" {
scope = quickReplyScopePersonal
}
db := model.DB.Where("tenant_id = ?", tenantID)
switch scope {
case quickReplyScopeTeam:
if !requireTeamQuickReplyManager(c) {
return
}
db = db.Where("scope = ?", quickReplyScopeTeam)
case quickReplyScopePersonal:
db = db.Where("scope = ? AND owner_user_id = ?", quickReplyScopePersonal, uid)
default:
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "scope 无效"})
return
}
var list []model.QuickReply
if err := db.Order("id asc").Limit(exportMaxRows).Find(&list).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "导出失败"})
return
}
header := []string{"title", "content", "shortcut", "group_name", "status", "scope"}
rows := make([][]string, 0, len(list))
for _, item := range list {
rows = append(rows, []string{
item.Title,
item.Content,
item.Shortcut,
item.GroupName,
item.Status,
item.Scope,
})
}
name := fmt.Sprintf("quick_replies_%s_%s.csv", scope, time.Now().Format("20060102_150405"))
writeCSVResponse(c, name, header, rows)
}
// ImportTemplate GET /api/quick-replies/import-template
func (h *QuickReplyHandler) ImportTemplate(c *gin.Context) {
header := []string{"title", "content", "shortcut", "group_name", "status"}
rows := [][]string{
{"打招呼", "您好!请问有什么可以帮您?", "nh", "通用", "published"},
{"稍等", "好的,请稍等,我帮您查询一下。", "sd", "通用", "published"},
}
writeCSVResponse(c, "quick_replies_template.csv", header, rows)
}
// Import POST /api/quick-replies/import multipart file + scope=team|personal + on_conflict=skip|overwrite
func (h *QuickReplyHandler) Import(c *gin.Context) {
tenantID := middleware.GetTenantID(c)
uid := middleware.GetUserID(c)
scope := strings.TrimSpace(c.DefaultPostForm("scope", c.Query("scope")))
if scope == "" {
scope = quickReplyScopePersonal
}
if scope != quickReplyScopeTeam && scope != quickReplyScopePersonal {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "scope 无效"})
return
}
if scope == quickReplyScopeTeam && !requireTeamQuickReplyManager(c) {
return
}
onConflict := strings.TrimSpace(c.DefaultPostForm("on_conflict", "skip"))
if onConflict != "skip" && onConflict != "overwrite" {
onConflict = "skip"
}
file, _, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "请上传 CSV 文件字段 file"})
return
}
defer file.Close()
reader := csv.NewReader(file)
reader.FieldsPerRecord = -1
reader.LazyQuotes = true
records, err := reader.ReadAll()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "CSV 解析失败"})
return
}
if len(records) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "CSV 为空"})
return
}
// 跳过表头
start := 0
if len(records[0]) > 0 && strings.EqualFold(strings.TrimSpace(records[0][0]), "title") {
start = 1
}
body := records[start:]
if len(body) > maxQuickReplyImport {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": fmt.Sprintf("单次最多导入 %d 行", maxQuickReplyImport)})
return
}
var ownerID *uint
if scope == quickReplyScopePersonal {
ownerID = &uid
}
created, updated, skipped := 0, 0, 0
var errors []string
for i, row := range body {
lineNo := start + i + 1
if len(row) == 0 || (len(row) == 1 && strings.TrimSpace(row[0]) == "") {
continue
}
// pad columns
for len(row) < 5 {
row = append(row, "")
}
title := strings.TrimSpace(row[0])
content := strings.TrimSpace(row[1])
shortcutRaw := strings.TrimSpace(row[2])
group := strings.TrimSpace(row[3])
status := normalizeQRStatus(row[4], quickReplyStatusPub)
if title == "" || content == "" {
skipped++
errors = append(errors, fmt.Sprintf("第 %d 行:标题或内容为空", lineNo))
continue
}
if utf8.RuneCountInString(title) > maxQuickReplyTitle || utf8.RuneCountInString(content) > maxQuickReplyContent {
skipped++
errors = append(errors, fmt.Sprintf("第 %d 行:标题或内容过长", lineNo))
continue
}
shortcut, err := normalizeShortcut(shortcutRaw)
if err != nil {
skipped++
errors = append(errors, fmt.Sprintf("第 %d 行:%s", lineNo, err.Error()))
continue
}
if utf8.RuneCountInString(group) > maxQuickReplyGroup {
group = string([]rune(group)[:maxQuickReplyGroup])
}
// 冲突:有 shortcut 按 shortcut;否则按 title
var existing model.QuickReply
found := false
if shortcut != "" {
q := model.DB.Where("tenant_id = ? AND scope = ? AND shortcut = ?", tenantID, scope, shortcut)
if scope == quickReplyScopePersonal {
q = q.Where("owner_user_id = ?", uid)
}
if err := q.First(&existing).Error; err == nil {
found = true
}
} else {
q := model.DB.Where("tenant_id = ? AND scope = ? AND title = ?", tenantID, scope, title)
if scope == quickReplyScopePersonal {
q = q.Where("owner_user_id = ?", uid)
}
if err := q.First(&existing).Error; err == nil {
found = true
}
}
if found {
if onConflict == "skip" {
skipped++
continue
}
if err := model.DB.Model(&existing).Updates(map[string]interface{}{
"title": title, "content": content, "shortcut": shortcut,
"group_name": group, "status": status,
}).Error; err != nil {
skipped++
errors = append(errors, fmt.Sprintf("第 %d 行:更新失败", lineNo))
continue
}
updated++
continue
}
item := model.QuickReply{
TenantID: tenantID,
Scope: scope,
OwnerUserID: ownerID,
Title: title,
Content: content,
Shortcut: shortcut,
GroupName: group,
Status: status,
}
if err := model.DB.Create(&item).Error; err != nil {
skipped++
errors = append(errors, fmt.Sprintf("第 %d 行:创建失败", lineNo))
continue
}
created++
}
if len(errors) > 20 {
errors = errors[:20]
}
middleware.JSON(c, gin.H{
"created": created,
"updated": updated,
"skipped": skipped,
"errors": errors,
})
}