Files
kefu_cloud/server/internal/handler/quick_reply.go
T
yml2213 c6532f2ee3 优化快捷回复 CSV 为中文四列模板,操作列改为单行
导入/导出统一为一级分类、二级分类、输入码、内容;列表操作按钮紧凑并排不换行。
2026-07-19 00:59:53 +08:00

737 lines
21 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"
"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})
}
// 快捷回复 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,
})
}