817 lines
24 KiB
Go
817 lines
24 KiB
Go
package handler
|
||
|
||
import (
|
||
"encoding/csv"
|
||
"fmt"
|
||
"net/http"
|
||
"regexp"
|
||
"sort"
|
||
"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)
|
||
}
|
||
|
||
const suggestLimit = 10
|
||
|
||
// Suggest GET /api/quick-replies/suggest
|
||
// mode=slash&prefix= → / 调用:按输入码前缀过滤;空前缀返回个人调用频次最高的 10 条
|
||
// mode=keyword&q= → 普通输入:按标题/内容/输入码关键字匹配
|
||
// 排序一律按当前坐席个人调用次数(非全租户)
|
||
func (h *QuickReplyHandler) Suggest(c *gin.Context) {
|
||
tenantID := middleware.GetTenantID(c)
|
||
uid := middleware.GetUserID(c)
|
||
mode := strings.TrimSpace(c.Query("mode"))
|
||
if mode == "" {
|
||
mode = "slash"
|
||
}
|
||
prefix := strings.TrimSpace(c.Query("prefix"))
|
||
prefix = strings.TrimPrefix(strings.ToLower(prefix), "/")
|
||
q := strings.TrimSpace(c.Query("q"))
|
||
|
||
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,
|
||
)
|
||
|
||
switch mode {
|
||
case "slash":
|
||
if prefix != "" {
|
||
// 仅匹配输入码前缀(/n → shortcut 以 n 开头)
|
||
db = db.Where("shortcut <> '' AND LOWER(shortcut) LIKE ?", prefix+"%")
|
||
}
|
||
case "keyword":
|
||
if utf8.RuneCountInString(q) < 1 {
|
||
middleware.JSON(c, []model.QuickReply{})
|
||
return
|
||
}
|
||
like := "%" + q + "%"
|
||
db = db.Where("title LIKE ? OR content LIKE ? OR shortcut LIKE ?", like, like, like)
|
||
default:
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "mode 无效"})
|
||
return
|
||
}
|
||
|
||
// 多取一些再按个人频次排序截断(避免漏掉个人高频但全局低的条目)
|
||
var list []model.QuickReply
|
||
if err := db.Order("usage_count desc, sort_order asc, id desc").Limit(200).Find(&list).Error; err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "搜索失败"})
|
||
return
|
||
}
|
||
if len(list) == 0 {
|
||
middleware.JSON(c, []model.QuickReply{})
|
||
return
|
||
}
|
||
|
||
ids := make([]uint, len(list))
|
||
for i, item := range list {
|
||
ids[i] = item.ID
|
||
}
|
||
var usages []model.QuickReplyUserUsage
|
||
_ = model.DB.Where("user_id = ? AND tenant_id = ? AND quick_reply_id IN ?", uid, tenantID, ids).Find(&usages).Error
|
||
myMap := map[uint]int{}
|
||
for _, u := range usages {
|
||
myMap[u.QuickReplyID] = u.UsageCount
|
||
}
|
||
for i := range list {
|
||
list[i].MyUsageCount = myMap[list[i].ID]
|
||
}
|
||
// 个人调用次数优先
|
||
sort.SliceStable(list, func(i, j int) bool {
|
||
if list[i].MyUsageCount != list[j].MyUsageCount {
|
||
return list[i].MyUsageCount > list[j].MyUsageCount
|
||
}
|
||
if list[i].UsageCount != list[j].UsageCount {
|
||
return list[i].UsageCount > list[j].UsageCount
|
||
}
|
||
if list[i].SortOrder != list[j].SortOrder {
|
||
return list[i].SortOrder < list[j].SortOrder
|
||
}
|
||
return list[i].ID > list[j].ID
|
||
})
|
||
if len(list) > suggestLimit {
|
||
list = list[:suggestLimit]
|
||
}
|
||
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(排序用),并累计全局 usage_count
|
||
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)
|
||
tenantID := middleware.GetTenantID(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
|
||
|
||
// 个人累计(建议排序)
|
||
var usage model.QuickReplyUserUsage
|
||
err := model.DB.Where("user_id = ? AND quick_reply_id = ?", uid, item.ID).First(&usage).Error
|
||
myCount := 1
|
||
if err != nil {
|
||
usage = model.QuickReplyUserUsage{
|
||
TenantID: tenantID,
|
||
UserID: uid,
|
||
QuickReplyID: item.ID,
|
||
UsageCount: 1,
|
||
}
|
||
_ = model.DB.Create(&usage).Error
|
||
} else {
|
||
myCount = usage.UsageCount + 1
|
||
_ = model.DB.Model(&usage).UpdateColumn("usage_count", myCount).Error
|
||
}
|
||
|
||
middleware.JSON(c, gin.H{
|
||
"ok": true,
|
||
"usage_count": item.UsageCount + 1,
|
||
"my_usage_count": myCount,
|
||
})
|
||
}
|
||
|
||
// 快捷回复 CSV 固定四列(与导入模板一致)
|
||
// 一级分类=团队/个人;二级分类=标题;输入码=快捷键;内容=回复正文
|
||
func quickReplyCSVHeader() []string {
|
||
return []string{"一级分类", "二级分类", "输入码", "内容"}
|
||
}
|
||
|
||
func scopeLabelCN(scope string) string {
|
||
if scope == quickReplyScopeTeam {
|
||
return "团队"
|
||
}
|
||
return "个人"
|
||
}
|
||
|
||
// parseQuickReplyScopeLabel 仅接受 团队 / 个人(及空=沿用默认)
|
||
func parseQuickReplyScopeLabel(raw string) (string, bool) {
|
||
s := strings.TrimSpace(raw)
|
||
s = strings.TrimPrefix(s, "\ufeff")
|
||
switch s {
|
||
case "团队":
|
||
return quickReplyScopeTeam, true
|
||
case "个人":
|
||
return quickReplyScopePersonal, true
|
||
case "":
|
||
return "", true
|
||
default:
|
||
return "", false
|
||
}
|
||
}
|
||
|
||
func cellAt(row []string, idx int) string {
|
||
if idx < 0 || idx >= len(row) {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(strings.TrimPrefix(row[idx], "\ufeff"))
|
||
}
|
||
|
||
// isQuickReplyCSVHeader 判断首行是否为标准表头
|
||
func isQuickReplyCSVHeader(row []string) bool {
|
||
if len(row) < 4 {
|
||
return false
|
||
}
|
||
return cellAt(row, 0) == "一级分类" &&
|
||
cellAt(row, 1) == "二级分类" &&
|
||
cellAt(row, 2) == "输入码" &&
|
||
cellAt(row, 3) == "内容"
|
||
}
|
||
|
||
// 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 := quickReplyCSVHeader()
|
||
rows := make([][]string, 0, len(list))
|
||
for _, item := range list {
|
||
rows = append(rows, []string{
|
||
scopeLabelCN(item.Scope),
|
||
item.Title,
|
||
item.Shortcut,
|
||
item.Content,
|
||
})
|
||
}
|
||
name := fmt.Sprintf("快捷回复_%s_%s.csv", scopeLabelCN(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 := quickReplyCSVHeader()
|
||
rows := [][]string{
|
||
{"团队", "欢迎词", "hy", "欢迎访问,请问有什么可以帮您?"},
|
||
{"个人", "您好", "nh", "您好!很高兴为您服务。"},
|
||
{"团队", "稍等", "sd", "好的,请稍等,我帮您查询一下。"},
|
||
}
|
||
writeCSVResponse(c, "快捷回复导入模板.csv", header, rows)
|
||
}
|
||
|
||
// Import POST /api/quick-replies/import
|
||
// 固定 CSV 列:一级分类,二级分类,输入码,内容
|
||
// 一级分类为空时使用表单 scope;可在同一文件中混写团队/个人。
|
||
func (h *QuickReplyHandler) Import(c *gin.Context) {
|
||
tenantID := middleware.GetTenantID(c)
|
||
uid := middleware.GetUserID(c)
|
||
defaultScope := strings.TrimSpace(c.DefaultPostForm("scope", c.Query("scope")))
|
||
if defaultScope == "" {
|
||
defaultScope = quickReplyScopePersonal
|
||
}
|
||
if defaultScope != quickReplyScopeTeam && defaultScope != quickReplyScopePersonal {
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "scope 无效"})
|
||
return
|
||
}
|
||
if defaultScope == quickReplyScopeTeam && !requireTeamQuickReplyManager(c) {
|
||
return
|
||
}
|
||
onConflict := strings.TrimSpace(c.DefaultPostForm("on_conflict", "skip"))
|
||
if onConflict != "skip" && onConflict != "overwrite" {
|
||
onConflict = "skip"
|
||
}
|
||
canManageTeam := middleware.HasAnyRole(c, "admin", "supervisor")
|
||
|
||
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 isQuickReplyCSVHeader(records[0]) {
|
||
start = 1
|
||
} else {
|
||
// 必须使用标准模板表头
|
||
c.JSON(http.StatusBadRequest, gin.H{
|
||
"code": 400,
|
||
"message": "CSV 表头不正确,请下载「导入模板」:一级分类,二级分类,输入码,内容",
|
||
})
|
||
return
|
||
}
|
||
body := records[start:]
|
||
if len(body) > maxQuickReplyImport {
|
||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": fmt.Sprintf("单次最多导入 %d 行", maxQuickReplyImport)})
|
||
return
|
||
}
|
||
|
||
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
|
||
}
|
||
// 固定列:0一级分类 1二级分类 2输入码 3内容
|
||
for len(row) < 4 {
|
||
row = append(row, "")
|
||
}
|
||
|
||
rawScope := cellAt(row, 0)
|
||
title := cellAt(row, 1)
|
||
shortcutRaw := cellAt(row, 2)
|
||
content := cellAt(row, 3)
|
||
|
||
rowScope := defaultScope
|
||
parsed, ok := parseQuickReplyScopeLabel(rawScope)
|
||
if !ok {
|
||
skipped++
|
||
errors = append(errors, fmt.Sprintf("第 %d 行:一级分类请填「团队」或「个人」", lineNo))
|
||
continue
|
||
}
|
||
if parsed != "" {
|
||
rowScope = parsed
|
||
}
|
||
|
||
if rowScope == quickReplyScopeTeam && !canManageTeam {
|
||
skipped++
|
||
errors = append(errors, fmt.Sprintf("第 %d 行:无权限导入团队快捷回复", lineNo))
|
||
continue
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
var ownerID *uint
|
||
if rowScope == quickReplyScopePersonal {
|
||
ownerID = &uid
|
||
}
|
||
|
||
// 冲突:有 shortcut 按 shortcut;否则按 title
|
||
var existing model.QuickReply
|
||
found := false
|
||
if shortcut != "" {
|
||
q := model.DB.Where("tenant_id = ? AND scope = ? AND shortcut = ?", tenantID, rowScope, shortcut)
|
||
if rowScope == 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, rowScope, title)
|
||
if rowScope == 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,
|
||
}).Error; err != nil {
|
||
skipped++
|
||
errors = append(errors, fmt.Sprintf("第 %d 行:更新失败", lineNo))
|
||
continue
|
||
}
|
||
updated++
|
||
continue
|
||
}
|
||
|
||
item := model.QuickReply{
|
||
TenantID: tenantID,
|
||
Scope: rowScope,
|
||
OwnerUserID: ownerID,
|
||
Title: title,
|
||
Content: content,
|
||
Shortcut: shortcut,
|
||
Status: quickReplyStatusPub,
|
||
}
|
||
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,
|
||
})
|
||
}
|
||
|