新增独立快捷回复模块(团队/个人)
从知识库拆出快捷回复:支持团队暂存与发布同步、个人话术、输入码与工作台 / 调用,以及 CSV 导入导出;管理页顶栏与侧栏对齐。
This commit is contained in:
@@ -0,0 +1,669 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeShortcut(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"", "", false},
|
||||
{"nh", "nh", false},
|
||||
{"/NH", "nh", false},
|
||||
{"hello_1", "hello_1", false},
|
||||
{"a-b", "a-b", false},
|
||||
{"中文", "", true},
|
||||
{"has space", "", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, err := normalizeShortcut(tc.in)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("normalizeShortcut(%q) 期望错误", tc.in)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeShortcut(%q): %v", tc.in, err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("normalizeShortcut(%q)=%q want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
|
||||
session := NewSessionHandler()
|
||||
customer := NewCustomerHandler()
|
||||
knowledge := NewKnowledgeHandler()
|
||||
quickReply := NewQuickReplyHandler()
|
||||
stats := NewStatisticsHandler()
|
||||
admin := NewAdminHandler(store)
|
||||
channel := NewChannelHandler()
|
||||
@@ -87,6 +88,20 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
|
||||
kb.PUT("/entries/:id", knowledge.UpdateEntry)
|
||||
kb.DELETE("/entries/:id", knowledge.DeleteEntry)
|
||||
|
||||
// 快捷回复(团队 / 个人,与知识库独立)
|
||||
qr := authRequired.Group("/quick-replies")
|
||||
qr.GET("", quickReply.List)
|
||||
qr.GET("/suggest", quickReply.Suggest)
|
||||
qr.GET("/export", quickReply.Export)
|
||||
qr.GET("/import-template", quickReply.ImportTemplate)
|
||||
qr.POST("/import", quickReply.Import)
|
||||
qr.POST("", quickReply.Create)
|
||||
qr.PUT("/:id", quickReply.Update)
|
||||
qr.DELETE("/:id", quickReply.Delete)
|
||||
qr.POST("/:id/publish", quickReply.Publish)
|
||||
qr.POST("/:id/unpublish", quickReply.Unpublish)
|
||||
qr.POST("/:id/use", quickReply.Use)
|
||||
|
||||
// 渠道设置(租户级)
|
||||
channels := authRequired.Group("/channels")
|
||||
channels.GET("", channel.List)
|
||||
|
||||
Reference in New Issue
Block a user