670 lines
20 KiB
Go
670 lines
20 KiB
Go
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,
|
||
})
|
||
}
|
||
|