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, }) }