refactor: 将客服后端重构为宿主可嵌入模块
- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。 - 移除模块内重复的组织、客户、工单、标签、技能、旧工作流、MCP 和迁移实现,将身份权限与业务主体交由宿主管理。 - 使用 libSQL 重构向量存储,并完善图片消息、访客身份、排队调度、企业微信和支持聊天页面。 - 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
package supportchat
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestSupportChatRoutesServeEmbeddedPage(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
RegisterRoutes(router)
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
contentType string
|
||||
contains string
|
||||
}{
|
||||
{path: "/support/chat", contentType: "text/html", contains: "在线客服"},
|
||||
{path: "/support/chat/", contentType: "text/html", contains: "message-scroller"},
|
||||
{path: "/support/chat/chat.css", contentType: "text/css", contains: ".support-app"},
|
||||
{path: "/support/chat/chat.js", contentType: "application/javascript", contains: "channel_id"},
|
||||
{path: "/support/demo", contentType: "text/html", contains: "客服渠道测试页"},
|
||||
{path: "/support/demo/", contentType: "text/html", contains: "widget-launcher"},
|
||||
{path: "/support/demo/demo.css", contentType: "text/css", contains: ".demo-app"},
|
||||
{path: "/support/demo/demo.js", contentType: "application/javascript", contains: "external_id"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodGet, tt.path, nil)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: got %d want %d", response.Code, http.StatusOK)
|
||||
}
|
||||
if contentType := response.Header().Get("Content-Type"); !strings.Contains(contentType, tt.contentType) {
|
||||
t.Fatalf("unexpected content type: %q", contentType)
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), tt.contains) {
|
||||
t.Fatalf("response does not contain %q", tt.contains)
|
||||
}
|
||||
if cacheControl := response.Header().Get("Cache-Control"); cacheControl != "no-cache" {
|
||||
t.Fatalf("unexpected cache control: %q", cacheControl)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportDemoUsesSnakeCaseIdentityAndEmbedsChat(t *testing.T) {
|
||||
script, err := assets.ReadFile("assets/demo.js")
|
||||
if err != nil {
|
||||
t.Fatalf("read support demo script: %v", err)
|
||||
}
|
||||
source := string(script)
|
||||
for _, marker := range []string{"channel_id", "external_id", "external_name", "/support/chat/"} {
|
||||
if !strings.Contains(source, marker) {
|
||||
t.Fatalf("support demo script does not contain %q", marker)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"externalId", "externalName"} {
|
||||
if strings.Contains(source, forbidden) {
|
||||
t.Fatalf("support demo script must not contain camelCase identity key %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportChatUsesRealtimeUIWithoutMessagePolling(t *testing.T) {
|
||||
index, err := assets.ReadFile("assets/index.html")
|
||||
if err != nil {
|
||||
t.Fatalf("read support chat index: %v", err)
|
||||
}
|
||||
for _, marker := range []string{
|
||||
`maximum-scale=1, user-scalable=no`,
|
||||
`id="connection-badge"`,
|
||||
`aria-label="Messages"`,
|
||||
`id="close-overlay"`,
|
||||
`id="pending-uploads"`,
|
||||
`id="image-input" type="file" accept="image/*" multiple`,
|
||||
`id="queue-status"`,
|
||||
`id="queue-position"`,
|
||||
`id="queue-eta"`,
|
||||
`enterkeyhint="enter"`,
|
||||
`输入消息,换行键换行`,
|
||||
`点击按钮发送`,
|
||||
} {
|
||||
if !strings.Contains(string(index), marker) {
|
||||
t.Fatalf("support chat index does not contain %q", marker)
|
||||
}
|
||||
}
|
||||
|
||||
script, err := assets.ReadFile("assets/chat.js")
|
||||
if err != nil {
|
||||
t.Fatalf("read support chat script: %v", err)
|
||||
}
|
||||
source := string(script)
|
||||
if !strings.Contains(source, "new WebSocket(websocketUrl())") {
|
||||
t.Fatal("support chat must connect through the realtime websocket")
|
||||
}
|
||||
if strings.Contains(source, "}, 2500)") {
|
||||
t.Fatal("support chat must not poll conversations and messages every 2.5 seconds")
|
||||
}
|
||||
for _, marker := range []string{
|
||||
"function updateQueueStatus",
|
||||
"function refreshConversationQueue",
|
||||
`container.append(sanitizeHtml(toSafeHtml(message.content || "")))`,
|
||||
`const allowedTags = new Set([`,
|
||||
`.split(/\n{2,}/)`,
|
||||
`paragraph.replace(/\n/g, "<br>")`,
|
||||
"async function openQuickDialog",
|
||||
"await loadQuickActions()",
|
||||
"你可以继续留言",
|
||||
"等待期间可以继续留言",
|
||||
`String(event.type || "").startsWith("conversation.")`,
|
||||
} {
|
||||
if !strings.Contains(source, marker) {
|
||||
t.Fatalf("support chat queue flow does not contain %q", marker)
|
||||
}
|
||||
}
|
||||
if strings.Contains(source, `event.key === "Enter" && !event.shiftKey`) {
|
||||
t.Fatal("support chat must only send through the send button; Enter should insert a newline")
|
||||
}
|
||||
if strings.Contains(source, `container.textContent = message.content || ""`) {
|
||||
t.Fatal("plain text messages must render through the safe HTML newline renderer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportChatStagesUploadsUntilExplicitSend(t *testing.T) {
|
||||
script, err := assets.ReadFile("assets/chat.js")
|
||||
if err != nil {
|
||||
t.Fatalf("read support chat script: %v", err)
|
||||
}
|
||||
source := string(script)
|
||||
for _, marker := range []string{
|
||||
"state.pendingUploads",
|
||||
"function stageFiles",
|
||||
"function sendComposer",
|
||||
`dom.send.addEventListener("click", sendComposer)`,
|
||||
`stageFiles(Array.from(dom.imageInput.files || []), "image")`,
|
||||
`await createMessage("image", content ? toSafeHtml(content) : "", JSON.stringify({ assets }))`,
|
||||
`const assets = Array.isArray(payload.assets) && payload.assets.length ? payload.assets : [payload]`,
|
||||
"function settleInitialScroll",
|
||||
"state.initialScrollSettling",
|
||||
} {
|
||||
if !strings.Contains(source, marker) {
|
||||
t.Fatalf("support chat staged upload flow does not contain %q", marker)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
`uploadFile(dom.imageInput.files?.[0], "image")`,
|
||||
`uploadFile(dom.fileInput.files?.[0], "attachment")`,
|
||||
} {
|
||||
if strings.Contains(source, forbidden) {
|
||||
t.Fatalf("file selection must not send immediately: found %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportChatRequiresCurrentVerifiedNumberSession(t *testing.T) {
|
||||
index, err := assets.ReadFile("assets/index.html")
|
||||
if err != nil {
|
||||
t.Fatalf("read support chat index: %v", err)
|
||||
}
|
||||
for _, marker := range []string{
|
||||
`id="access-overlay"`,
|
||||
`id="access-password-tab"`,
|
||||
`id="access-sms-tab"`,
|
||||
`id="access-send-code"`,
|
||||
`id="access-submit"`,
|
||||
} {
|
||||
if !strings.Contains(string(index), marker) {
|
||||
t.Fatalf("support chat verification dialog does not contain %q", marker)
|
||||
}
|
||||
}
|
||||
|
||||
script, err := assets.ReadFile("assets/chat.js")
|
||||
if err != nil {
|
||||
t.Fatalf("read support chat script: %v", err)
|
||||
}
|
||||
source := string(script)
|
||||
for _, marker := range []string{
|
||||
`new URLSearchParams(window.location.hash.replace(/^#/, ""))`,
|
||||
`h5AccessRequest("/access/send-code"`,
|
||||
`h5AccessRequest("/access/authorize"`,
|
||||
`h5AccessRequest("/access/chat-entry"`,
|
||||
`"X-H5-Access-Token": accessToken`,
|
||||
`headers.set("X-H5-Chat-Session", "required")`,
|
||||
`headers.set("X-H5-Chat-Binding", state.chatBinding)`,
|
||||
`query.set("h5_chat_session", "required")`,
|
||||
`query.set("h5_chat_binding", state.chatBinding)`,
|
||||
`credentials: "include"`,
|
||||
`window.sessionStorage.setItem(key, value)`,
|
||||
`destination.searchParams.set("access_ticket", ticket)`,
|
||||
} {
|
||||
if !strings.Contains(source, marker) {
|
||||
t.Fatalf("support chat verified-session flow does not contain %q", marker)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
`localStorage.setItem("h5_access_token"`,
|
||||
`sessionStorage.setItem("h5_access_token"`,
|
||||
} {
|
||||
if strings.Contains(source, forbidden) {
|
||||
t.Fatalf("support chat must not expose reusable number credentials: found %q", forbidden)
|
||||
}
|
||||
}
|
||||
wsStart := strings.Index(source, "function websocketUrl()")
|
||||
if wsStart < 0 {
|
||||
t.Fatal("support chat websocket builder not found")
|
||||
}
|
||||
wsEnd := strings.Index(source[wsStart:], "function connectSocket()")
|
||||
if wsEnd < 0 {
|
||||
t.Fatal("support chat websocket builder end not found")
|
||||
}
|
||||
websocketSource := source[wsStart : wsStart+wsEnd]
|
||||
for _, forbidden := range []string{`query.set("card_no"`, `query.set("device_no"`} {
|
||||
if strings.Contains(websocketSource, forbidden) {
|
||||
t.Fatalf("websocket URL must not expose raw target number: found %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user