@@ -0,0 +1,669 @@
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 ,
} )
}