Files
kefu_cloud/server/internal/handler/quick_reply.go
T
yml2213 1a3b32868c 优化快捷回复联想与个人调用统计,数据统计支持自定义日期
/ 按输入码与个人频次建议(最多10条,唯一自动填入),正文关键字联想;统计页可日历选区间并统一中文日历。
2026-07-19 01:10:16 +08:00

817 lines
24 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"
"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,
})
}