refactor: 将客服后端重构为宿主可嵌入模块

- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。

- 移除模块内重复的组织、客户、工单、标签、技能、旧工作流、MCP 和迁移实现,将身份权限与业务主体交由宿主管理。

- 使用 libSQL 重构向量存储,并完善图片消息、访客身份、排队调度、企业微信和支持聊天页面。

- 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
This commit is contained in:
t
2026-08-28 22:23:13 +08:00
parent 6845c728f8
commit 18c9354095
377 changed files with 13199 additions and 22881 deletions
+87
View File
@@ -0,0 +1,87 @@
package contract
import (
"context"
"errors"
)
type BusinessActionFailureOutcome string
const (
// BusinessActionFailureRetryable means execution failed before the host
// operation could have produced a side effect. The same idempotency key may
// be claimed again.
BusinessActionFailureRetryable BusinessActionFailureOutcome = "retryable_failed"
// BusinessActionFailureUnknown means the host may have committed the side
// effect even though Agent Desk did not receive a definitive response. Such
// an invocation must be reconciled instead of replayed automatically.
BusinessActionFailureUnknown BusinessActionFailureOutcome = "unknown_outcome"
)
// BusinessActionResult is the customer-safe result returned after a confirmed
// host business operation. Message is sent to the customer verbatim; Data is
// retained only for idempotent replay and future structured clients.
type BusinessActionResult struct {
Message string `json:"message"`
Data any `json:"data,omitempty"`
}
// BusinessActionError separates a customer-safe explanation from its internal
// cause so model observations and chat replies never expose infrastructure
// errors returned by the host application.
type BusinessActionError struct {
Message string
Cause error
Outcome BusinessActionFailureOutcome
}
func (e *BusinessActionError) Error() string {
return e.Message
}
func (e *BusinessActionError) Unwrap() error {
return e.Cause
}
func NewBusinessActionError(message string, cause error) error {
return &BusinessActionError{Message: message, Cause: cause, Outcome: BusinessActionFailureUnknown}
}
func NewRetryableBusinessActionError(message string, cause error) error {
return &BusinessActionError{Message: message, Cause: cause, Outcome: BusinessActionFailureRetryable}
}
func NewUnknownOutcomeBusinessActionError(message string, cause error) error {
return &BusinessActionError{Message: message, Cause: cause, Outcome: BusinessActionFailureUnknown}
}
func BusinessActionErrorOutcome(err error) BusinessActionFailureOutcome {
var actionErr *BusinessActionError
if errors.As(err, &actionErr) {
return actionErr.Outcome
}
return ""
}
// BusinessActionTool lets the host expose a narrowly scoped write operation.
// Preview must perform read-only validation and produce the exact confirmation
// prompt. Execute must independently reload and validate all mutable business
// state before committing the operation.
type BusinessActionTool struct {
Code string
Description string
CustomerTypes []string
InputSchema map[string]any
// MatchIntent lets the host identify an unambiguous customer command that
// must enter the confirmation flow without relying on the language model to
// select a tool. It must be side-effect free.
MatchIntent func(string) bool
Preview func(context.Context, BusinessReadContext, map[string]any) (string, error)
// BindConfirmation binds the generated server checkpoint and canonical
// arguments to the verified request that prepared the action.
BindConfirmation func(context.Context, BusinessReadContext, map[string]any, string) error
// AuthorizeConfirmation re-verifies the current confirmation request and
// the previously bound checkpoint immediately before idempotency claiming.
AuthorizeConfirmation func(context.Context, BusinessReadContext, map[string]any, string) error
Execute func(context.Context, BusinessReadContext, map[string]any) (*BusinessActionResult, error)
}
+31
View File
@@ -0,0 +1,31 @@
package contract
import "context"
// BusinessReadContext identifies the customer bound to the current support
// conversation. Host tools must use these trusted values instead of accepting
// customer identifiers from model-generated arguments.
type BusinessReadContext struct {
ConversationID int64
CustomerType string
CustomerID int64
CustomerExternalID string
CustomerName string
RequestMessageID int64
RequestID string
CheckPointID string
AccessProof *CustomerAccessProof
}
// BusinessReadTool lets the host expose a narrowly scoped, read-only business
// query to AI Agent without coupling the customer-service module to host data.
type BusinessReadTool struct {
Code string
Description string
CustomerTypes []string
InputSchema map[string]any
// MatchIntent lets the host require a fresh read for messages whose answer
// must not rely on stale conversational text. It must be side-effect free.
MatchIntent func(string) bool
Execute func(context.Context, BusinessReadContext, map[string]any) (any, error)
}
+53
View File
@@ -0,0 +1,53 @@
package contract
import (
"context"
"strings"
"time"
)
// CustomerAccessProof is a host-verified, short-lived customer request proof.
// It intentionally contains no password, bearer token, card number, or device
// number. The opaque SessionID can only be issued and validated by the host.
type CustomerAccessProof struct {
SessionID string
TargetType string
TargetID int64
ConversationID int64
MessageID int64
RequestID string
ExpiresAt time.Time
}
type customerAccessProofContextKey struct{}
// WithCustomerAccessProof attaches a copy of a host-verified proof to the
// current request. Agent Desk propagates only these claims into async runs.
func WithCustomerAccessProof(ctx context.Context, proof CustomerAccessProof) context.Context {
return context.WithValue(ctx, customerAccessProofContextKey{}, proof)
}
// CustomerAccessProofFromContext returns only a structurally valid, live
// proof. The host must still verify SessionID against its server-side store.
func CustomerAccessProofFromContext(ctx context.Context) (CustomerAccessProof, bool) {
proof, ok := ctx.Value(customerAccessProofContextKey{}).(CustomerAccessProof)
if !ok || strings.TrimSpace(proof.SessionID) == "" || proof.TargetID <= 0 ||
(strings.TrimSpace(proof.TargetType) != "card" && strings.TrimSpace(proof.TargetType) != "device") ||
proof.ExpiresAt.IsZero() || !proof.ExpiresAt.After(time.Now()) {
return CustomerAccessProof{}, false
}
return proof, true
}
// BindCustomerAccessProofToMessage binds the current request proof to the
// exact persisted customer message that triggered an async Agent run.
func BindCustomerAccessProofToMessage(ctx context.Context, conversationID, messageID int64, requestID string) context.Context {
proof, ok := CustomerAccessProofFromContext(ctx)
if !ok {
return ctx
}
proof.ConversationID = conversationID
proof.MessageID = messageID
proof.RequestID = strings.TrimSpace(requestID)
return WithCustomerAccessProof(ctx, proof)
}
+42
View File
@@ -0,0 +1,42 @@
package contract
import (
"context"
"testing"
"time"
)
func TestBindCustomerAccessProofToExactPersistedMessage(t *testing.T) {
ctx := WithCustomerAccessProof(context.Background(), CustomerAccessProof{
SessionID: "opaque-session", TargetType: "device", TargetID: 27,
ExpiresAt: time.Now().Add(time.Minute),
})
ctx = BindCustomerAccessProofToMessage(ctx, 101, 202, " request-303 ")
proof, ok := CustomerAccessProofFromContext(ctx)
if !ok {
t.Fatal("bound proof was not returned")
}
if proof.ConversationID != 101 || proof.MessageID != 202 || proof.RequestID != "request-303" {
t.Fatalf("proof was not bound to exact request message: %#v", proof)
}
}
func TestCustomerAccessProofRejectsExpiredOrUnscopedClaims(t *testing.T) {
for name, proof := range map[string]CustomerAccessProof{
"expired": {
SessionID: "opaque-session", TargetType: "card", TargetID: 1,
ExpiresAt: time.Now().Add(-time.Second),
},
"unsupported_target": {
SessionID: "opaque-session", TargetType: "guest", TargetID: 1,
ExpiresAt: time.Now().Add(time.Minute),
},
} {
t.Run(name, func(t *testing.T) {
ctx := WithCustomerAccessProof(context.Background(), proof)
if _, ok := CustomerAccessProofFromContext(ctx); ok {
t.Fatalf("invalid proof was accepted: %#v", proof)
}
})
}
}
+19
View File
@@ -0,0 +1,19 @@
package contract
import "context"
// CustomerQuickAction is a deterministic customer-facing action supplied by
// the host application. It does not require an AI model and must only expose
// data belonging to the trusted conversation identity.
type CustomerQuickAction struct {
Code string
Title string
Description string
Message string
Sort int
CustomerTypes []string
TriggerAI bool
MatchIntent func(string) bool
Available func(context.Context, BusinessReadContext) (bool, error)
Execute func(context.Context, BusinessReadContext) (string, error)
}
+17
View File
@@ -0,0 +1,17 @@
package contract
import (
"context"
"io"
)
// FileStorage delegates customer-service file persistence to the host system.
// The customer-service module keeps only its asset metadata and never owns a
// second set of local/cloud storage settings.
type FileStorage interface {
DefaultProvider(ctx context.Context) (string, error)
Upload(ctx context.Context, provider, key, filename, mimeType string, size int64, reader io.Reader) (string, error)
Open(ctx context.Context, provider, key string) (io.ReadCloser, error)
URL(ctx context.Context, provider, key string) (string, error)
Delete(ctx context.Context, provider, key string) error
}
+56
View File
@@ -0,0 +1,56 @@
package contract
import (
"context"
"net/http"
)
const (
ModelSourcePlatform = "platform"
ModelSourceCustom = "custom"
)
// PlatformAIConfig is a runtime-only OpenAI-compatible model configuration.
// Credentials and the signed HTTP client are supplied by the host and are
// never persisted in Agent Desk tables or returned by dashboard APIs.
type PlatformAIConfig struct {
BaseURL string
APIKey string
ChatEnabled bool
ChatModel string
VisionEnabled bool
VisionModel string
EmbeddingEnabled bool
ModelName string
EmbeddingModel string
EmbeddingDimension int
MaxOutputTokens int
TimeoutMS int
MaxRetryCount int
HTTPClient *http.Client
}
type PlatformAIStatus struct {
Enabled bool `json:"enabled"`
Balance float64 `json:"balance"`
Currency string `json:"currency"`
DefaultProvider string `json:"default_provider"`
DefaultModel string `json:"default_model"`
ChatEnabled bool `json:"chat_enabled"`
ChatProvider string `json:"chat_provider"`
ChatModel string `json:"chat_model"`
VisionEnabled bool `json:"vision_enabled"`
VisionModel string `json:"vision_model"`
EmbeddingEnabled bool `json:"embedding_enabled"`
EmbeddingModel string `json:"embedding_model"`
EmbeddingDimension int `json:"embedding_dimension"`
RechargeURL string `json:"recharge_url"`
}
// PlatformAIProvider lets the host resolve the current model source on every
// request, so changing the system setting takes effect without restarting.
type PlatformAIProvider interface {
ModelSource(context.Context) (string, error)
Config(context.Context) (*PlatformAIConfig, error)
Status(context.Context) (*PlatformAIStatus, error)
}
+19
View File
@@ -0,0 +1,19 @@
package contract
import (
"net/http"
)
// Response is the transport-neutral result produced by the customer-service
// module before the host application renders its HTTP response envelope.
type Response struct {
StatusCode int
ErrorCode int
Message string
Data any
Success bool
}
// ResponseWriter lets the host application render module responses with the
// exact same response and error contract used by its own APIs.
type ResponseWriter func(http.ResponseWriter, *http.Request, Response) error
+66
View File
@@ -0,0 +1,66 @@
package contract_test
import (
"go/ast"
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
"unicode"
)
func TestOwnedStructTagsUseSnakeCase(t *testing.T) {
repositoryRoot := filepath.Clean("..")
fileSet := token.NewFileSet()
err := filepath.WalkDir(repositoryRoot, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
switch entry.Name() {
case ".git", "node_modules", "vendor":
return filepath.SkipDir
}
return nil
}
if filepath.Ext(path) != ".go" {
return nil
}
parsed, parseErr := parser.ParseFile(fileSet, path, nil, 0)
if parseErr != nil {
return parseErr
}
ast.Inspect(parsed, func(node ast.Node) bool {
field, ok := node.(*ast.Field)
if !ok || field.Tag == nil {
return true
}
rawTag, unquoteErr := strconv.Unquote(field.Tag.Value)
if unquoteErr != nil {
t.Errorf("%s: invalid struct tag: %v", path, unquoteErr)
return true
}
structTag := reflect.StructTag(rawTag)
for _, tagName := range []string{"json", "form", "query"} {
fieldName := strings.Split(structTag.Get(tagName), ",")[0]
if fieldName == "" || fieldName == "-" {
continue
}
if strings.IndexFunc(fieldName, unicode.IsUpper) >= 0 {
t.Errorf("%s: %s tag %q must use snake_case", path, tagName, fieldName)
}
}
return true
})
return nil
})
if err != nil {
t.Fatal(err)
}
}