后端接入 AI 模型并支持测试账号托管回复

按协议而不是按厂商做适配,与现有短信、对象存储的做法一致:openai 兼容格式
一个适配器即可覆盖 DeepSeek、通义、智谱、火山方舟、Ollama 等,新增厂商通常
只需在后台加一条模型记录;anthropic、gemini、webhook、debug 各一个适配器。
密钥沿用 integration.go 的 AES-GCM 加密存储。

测试账号托管的回复走 persistMessage 同一条落库和推送路径,因此未读数、
WebSocket 推送和会话排序全部复用现有逻辑,客户端无需改动。为此把
persistMessage 抽出 persistMessageContext,因为 worker 没有 *http.Request。

任务队列用数据库表而非内存:重启不丢回复,多实例不重复消费。领取用 UPDATE
打 claim_token 再回读,没有用 SELECT ... FOR UPDATE SKIP LOCKED——生产存在
MySQL 5.7 环境,那里该语法无法解析。同一会话同时只允许一个待处理任务
(pending_key 生成列 + 唯一键),所以用户连发多条消息只会得到一条回复。

闸门:仅 is_test=1 且在允许批次内的账号生效,托管账号之间不互相触发,三层
配额,调用失败默认静默。会话内是否标注 AI 身份由 ai.disclose_in_chat 控制
并默认开启,关闭前需确认所在地区的监管要求。

app.go、integration.go、im.go 三个文件同时包含本次改动之前工作区里就已存在
的未提交修改,一并带入。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-03 08:31:04 +08:00
co-authored by Claude Opus 5
parent 334890171e
commit a024d59827
14 changed files with 3239 additions and 146 deletions
+488
View File
@@ -0,0 +1,488 @@
package app
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const (
aiResponseLimit = 512 << 10
aiProbePrompt = "用一句不超过 20 个字的中文打个招呼。"
)
// Vendors are many but wire protocols are few, so adapters are written per
// protocol. Anything speaking the OpenAI chat format needs no new code, only a
// new row in ai_models.
var aiProtocols = []string{"openai", "anthropic", "gemini", "webhook", "debug"}
type aiChatMessage struct {
Role string // user 或 assistant
Text string
}
type aiChatRequest struct {
System string
Messages []aiChatMessage
MaxTokens int
Temperature float64
}
type aiChatResult struct {
Text string
PromptTokens int
CompletionTokens int
FinishReason string
RequestID string
}
type aiProvider interface {
Chat(ctx context.Context, req aiChatRequest) (aiChatResult, error)
}
type aiModel struct {
ID int64 `json:"id"`
Name string `json:"name"`
Protocol string `json:"protocol"`
BaseURL string `json:"baseUrl"`
ModelName string `json:"modelName"`
Temperature float64 `json:"temperature"`
MaxTokens int `json:"maxTokens"`
TimeoutMS int `json:"timeoutMs"`
Status int `json:"status"`
IsDefault bool `json:"isDefault"`
APIKey string `json:"-"`
HasAPIKey bool `json:"hasApiKey"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
func aiProtocolName(protocol string) string {
switch protocol {
case "openai":
return "OpenAI 兼容"
case "anthropic":
return "Anthropic Messages"
case "gemini":
return "Google Gemini"
case "webhook":
return "自定义 Webhook"
case "debug":
return "本地调试"
}
return protocol
}
// The endpoint is operator-supplied, so it is validated the same way the SMS
// endpoints are: no credentials, no query string, no fragment.
func parseAIBaseURL(protocol, raw string, production bool) (*url.URL, error) {
trimmed := strings.TrimSpace(raw)
if protocol == "debug" {
return nil, nil
}
if trimmed == "" {
return nil, fmt.Errorf("%s 需要填写接口地址", aiProtocolName(protocol))
}
parsed, err := url.Parse(trimmed)
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return nil, fmt.Errorf("接口地址必须是无账号、查询参数和片段的有效地址")
}
if parsed.Scheme != "https" && !(parsed.Scheme == "http" && !production) {
return nil, fmt.Errorf("接口地址必须使用 HTTPS")
}
return parsed, nil
}
func aiEndpoint(base *url.URL, suffix string) string {
trimmed := strings.TrimRight(base.String(), "/")
if suffix == "" || strings.HasSuffix(trimmed, suffix) {
return trimmed
}
return trimmed + suffix
}
func newAIProvider(model aiModel, production bool) (aiProvider, error) {
if !containsString(aiProtocols, model.Protocol) {
return nil, fmt.Errorf("不支持的模型协议 %q", model.Protocol)
}
if model.Protocol == "debug" {
if production {
return nil, fmt.Errorf("生产环境禁止使用本地调试模型")
}
return debugAIProvider{}, nil
}
base, err := parseAIBaseURL(model.Protocol, model.BaseURL, production)
if err != nil {
return nil, err
}
if strings.TrimSpace(model.APIKey) == "" && model.Protocol != "webhook" {
return nil, fmt.Errorf("%s 需要填写密钥", aiProtocolName(model.Protocol))
}
if strings.TrimSpace(model.ModelName) == "" && model.Protocol != "webhook" {
return nil, fmt.Errorf("请填写模型名称")
}
timeout := time.Duration(model.TimeoutMS) * time.Millisecond
if timeout < time.Second || timeout > time.Minute {
timeout = 15 * time.Second
}
client := &http.Client{Timeout: timeout}
switch model.Protocol {
case "openai":
return openAIProvider{model: model, base: base, client: client}, nil
case "anthropic":
return anthropicProvider{model: model, base: base, client: client}, nil
case "gemini":
return geminiProvider{model: model, base: base, client: client}, nil
}
return webhookAIProvider{model: model, base: base, client: client}, nil
}
func aiRequestTemperature(req aiChatRequest, model aiModel) float64 {
if req.Temperature > 0 {
return req.Temperature
}
return model.Temperature
}
func aiRequestMaxTokens(req aiChatRequest, model aiModel) int {
if req.MaxTokens > 0 {
return req.MaxTokens
}
if model.MaxTokens > 0 {
return model.MaxTokens
}
return 256
}
func aiPostJSON(ctx context.Context, client *http.Client, endpoint string, headers map[string]string, payload any) ([]byte, error) {
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return nil, err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
for key, value := range headers {
request.Header.Set(key, value)
}
response, err := client.Do(request)
if err != nil {
return nil, fmt.Errorf("模型服务请求失败:%w", err)
}
defer response.Body.Close()
raw, err := io.ReadAll(io.LimitReader(response.Body, aiResponseLimit))
if err != nil {
return nil, fmt.Errorf("读取模型响应失败:%w", err)
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, fmt.Errorf("模型服务返回 HTTP %d%s", response.StatusCode, aiErrorSnippet(raw))
}
return raw, nil
}
// Provider error bodies are quoted back to the operator, so they are trimmed to
// something a config page can display.
func aiErrorSnippet(raw []byte) string {
text := strings.TrimSpace(string(raw))
text = strings.Join(strings.Fields(text), " ")
if len([]rune(text)) > 200 {
return string([]rune(text)[:200]) + "…"
}
if text == "" {
return "无响应内容"
}
return text
}
type openAIProvider struct {
model aiModel
base *url.URL
client *http.Client
}
func (p openAIProvider) Chat(ctx context.Context, req aiChatRequest) (aiChatResult, error) {
messages := make([]map[string]string, 0, len(req.Messages)+1)
if strings.TrimSpace(req.System) != "" {
messages = append(messages, map[string]string{"role": "system", "content": req.System})
}
for _, message := range req.Messages {
messages = append(messages, map[string]string{"role": message.Role, "content": message.Text})
}
raw, err := aiPostJSON(ctx, p.client, aiEndpoint(p.base, "/chat/completions"),
map[string]string{"Authorization": "Bearer " + p.model.APIKey},
map[string]any{
"model": p.model.ModelName,
"messages": messages,
"max_tokens": aiRequestMaxTokens(req, p.model),
"temperature": aiRequestTemperature(req, p.model),
"stream": false,
})
if err != nil {
return aiChatResult{}, err
}
var payload struct {
ID string `json:"id"`
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
} `json:"usage"`
Error struct {
Message string `json:"message"`
} `json:"error"`
}
if err = json.Unmarshal(raw, &payload); err != nil {
return aiChatResult{}, fmt.Errorf("模型响应不是有效 JSON%s", aiErrorSnippet(raw))
}
if payload.Error.Message != "" {
return aiChatResult{}, fmt.Errorf("模型服务返回错误:%s", payload.Error.Message)
}
if len(payload.Choices) == 0 {
return aiChatResult{}, fmt.Errorf("模型没有返回任何内容")
}
return aiChatResult{
Text: strings.TrimSpace(payload.Choices[0].Message.Content),
PromptTokens: payload.Usage.PromptTokens,
CompletionTokens: payload.Usage.CompletionTokens,
FinishReason: payload.Choices[0].FinishReason,
RequestID: payload.ID,
}, nil
}
type anthropicProvider struct {
model aiModel
base *url.URL
client *http.Client
}
func (p anthropicProvider) Chat(ctx context.Context, req aiChatRequest) (aiChatResult, error) {
messages := make([]map[string]any, 0, len(req.Messages))
for _, message := range req.Messages {
messages = append(messages, map[string]any{
"role": message.Role,
"content": []map[string]string{{"type": "text", "text": message.Text}},
})
}
payloadBody := map[string]any{
"model": p.model.ModelName,
"messages": messages,
"max_tokens": aiRequestMaxTokens(req, p.model),
"temperature": aiRequestTemperature(req, p.model),
}
// Anthropic carries the system prompt outside the message list.
if strings.TrimSpace(req.System) != "" {
payloadBody["system"] = req.System
}
raw, err := aiPostJSON(ctx, p.client, aiEndpoint(p.base, "/v1/messages"),
map[string]string{"x-api-key": p.model.APIKey, "anthropic-version": "2023-06-01"}, payloadBody)
if err != nil {
return aiChatResult{}, err
}
var payload struct {
ID string `json:"id"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
StopReason string `json:"stop_reason"`
Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
} `json:"usage"`
Error struct {
Message string `json:"message"`
} `json:"error"`
}
if err = json.Unmarshal(raw, &payload); err != nil {
return aiChatResult{}, fmt.Errorf("模型响应不是有效 JSON%s", aiErrorSnippet(raw))
}
if payload.Error.Message != "" {
return aiChatResult{}, fmt.Errorf("模型服务返回错误:%s", payload.Error.Message)
}
text := ""
for _, block := range payload.Content {
if block.Type == "text" {
text += block.Text
}
}
if strings.TrimSpace(text) == "" {
return aiChatResult{}, fmt.Errorf("模型没有返回任何内容")
}
return aiChatResult{
Text: strings.TrimSpace(text),
PromptTokens: payload.Usage.InputTokens,
CompletionTokens: payload.Usage.OutputTokens,
FinishReason: payload.StopReason,
RequestID: payload.ID,
}, nil
}
type geminiProvider struct {
model aiModel
base *url.URL
client *http.Client
}
func (p geminiProvider) Chat(ctx context.Context, req aiChatRequest) (aiChatResult, error) {
contents := make([]map[string]any, 0, len(req.Messages))
for _, message := range req.Messages {
role := "user"
if message.Role == "assistant" {
role = "model"
}
contents = append(contents, map[string]any{
"role": role,
"parts": []map[string]string{{"text": message.Text}},
})
}
payloadBody := map[string]any{
"contents": contents,
"generationConfig": map[string]any{
"temperature": aiRequestTemperature(req, p.model),
"maxOutputTokens": aiRequestMaxTokens(req, p.model),
},
}
if strings.TrimSpace(req.System) != "" {
payloadBody["systemInstruction"] = map[string]any{"parts": []map[string]string{{"text": req.System}}}
}
endpoint := fmt.Sprintf("%s/v1beta/models/%s:generateContent", strings.TrimRight(p.base.String(), "/"), url.PathEscape(p.model.ModelName))
// The key travels in a header rather than the query string so it stays out
// of proxy logs; Gemini accepts both.
raw, err := aiPostJSON(ctx, p.client, endpoint, map[string]string{"x-goog-api-key": p.model.APIKey}, payloadBody)
if err != nil {
return aiChatResult{}, err
}
var payload struct {
Candidates []struct {
Content struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
} `json:"content"`
FinishReason string `json:"finishReason"`
} `json:"candidates"`
UsageMetadata struct {
PromptTokenCount int `json:"promptTokenCount"`
CandidatesTokenCount int `json:"candidatesTokenCount"`
} `json:"usageMetadata"`
Error struct {
Message string `json:"message"`
} `json:"error"`
}
if err = json.Unmarshal(raw, &payload); err != nil {
return aiChatResult{}, fmt.Errorf("模型响应不是有效 JSON%s", aiErrorSnippet(raw))
}
if payload.Error.Message != "" {
return aiChatResult{}, fmt.Errorf("模型服务返回错误:%s", payload.Error.Message)
}
if len(payload.Candidates) == 0 {
return aiChatResult{}, fmt.Errorf("模型没有返回任何内容")
}
text := ""
for _, part := range payload.Candidates[0].Content.Parts {
text += part.Text
}
return aiChatResult{
Text: strings.TrimSpace(text),
PromptTokens: payload.UsageMetadata.PromptTokenCount,
CompletionTokens: payload.UsageMetadata.CandidatesTokenCount,
FinishReason: payload.Candidates[0].FinishReason,
}, nil
}
// Orchestration platforms (Coze, Dify, self-hosted flows) each have their own
// shape, so they get one normalised contract instead of one adapter each.
type webhookAIProvider struct {
model aiModel
base *url.URL
client *http.Client
}
func (p webhookAIProvider) Chat(ctx context.Context, req aiChatRequest) (aiChatResult, error) {
messages := make([]map[string]string, 0, len(req.Messages))
for _, message := range req.Messages {
messages = append(messages, map[string]string{"role": message.Role, "content": message.Text})
}
headers := map[string]string{}
if strings.TrimSpace(p.model.APIKey) != "" {
headers["Authorization"] = "Bearer " + p.model.APIKey
}
raw, err := aiPostJSON(ctx, p.client, aiEndpoint(p.base, ""), headers, map[string]any{
"model": p.model.ModelName,
"system": req.System,
"messages": messages,
"maxTokens": aiRequestMaxTokens(req, p.model),
"temperature": aiRequestTemperature(req, p.model),
})
if err != nil {
return aiChatResult{}, err
}
var payload struct {
Text string `json:"text"`
Reply string `json:"reply"`
Content string `json:"content"`
Usage struct {
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
} `json:"usage"`
Error string `json:"error"`
}
if err = json.Unmarshal(raw, &payload); err != nil {
return aiChatResult{}, fmt.Errorf("模型响应不是有效 JSON%s", aiErrorSnippet(raw))
}
if payload.Error != "" {
return aiChatResult{}, fmt.Errorf("模型服务返回错误:%s", payload.Error)
}
text := payload.Text
if text == "" {
text = payload.Reply
}
if text == "" {
text = payload.Content
}
if strings.TrimSpace(text) == "" {
return aiChatResult{}, fmt.Errorf("Webhook 必须返回 text 字段")
}
return aiChatResult{
Text: strings.TrimSpace(text),
PromptTokens: payload.Usage.PromptTokens,
CompletionTokens: payload.Usage.CompletionTokens,
}, nil
}
// Deterministic and offline: local development and tests exercise the whole
// pipeline without credentials or network access.
type debugAIProvider struct{}
func (debugAIProvider) Chat(_ context.Context, req aiChatRequest) (aiChatResult, error) {
last := ""
for index := len(req.Messages) - 1; index >= 0; index-- {
if req.Messages[index].Role == "user" {
last = strings.TrimSpace(req.Messages[index].Text)
break
}
}
reply := "在的,刚看到消息"
if last != "" {
runes := []rune(last)
if len(runes) > 12 {
runes = runes[:12]
}
reply = "收到:" + string(runes)
}
return aiChatResult{Text: reply, PromptTokens: len([]rune(last)), CompletionTokens: len([]rune(reply)), FinishReason: "stop"}, nil
}