refactor: 将客服后端重构为宿主可嵌入模块
- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。 - 移除模块内重复的组织、客户、工单、标签、技能、旧工作流、MCP 和迁移实现,将身份权限与业务主体交由宿主管理。 - 使用 libSQL 重构向量存储,并完善图片消息、访客身份、排队调度、企业微信和支持聊天页面。 - 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user