Files
kefu/im/backend/internal/app/admin_ai.go
T
Your NameandClaude Opus 5 acd8933dbb 后端:资料距离、语音图片会员限制、免短信注册、AI 托管回复修复
- 单用户资料接口补上距离:此前只有推荐/附近列表会算距离,资料页和
  聊天头部因此无内容可显示。沿用同一套 haversine 与隐私开关。
- 语音/图片消息可限定会员发送,两个开关在管理端「运营配置」中修改
  (迁移 032)。校验放在 persistMessageContext,HTTP 与 WebSocket
  两条发送路径都覆盖;文本消息永不受限。
- 短信服务关闭时注册不再要求验证码:关掉之后没人能拿到验证码,继续
  要求就等于关闭注册通道。重置密码不做同样放宽,那里缺验证码等于
  凭手机号夺号。app/config 增加 smsVerification 供客户端决定表单形态。
- 修复 AI 托管账号之间不回复:原规则按「发送方是否托管账号」拦截,
  把真人操作测试号的正常对话也挡了。改为标记 worker 自己写入的回复,
  只对 AI 生成的消息跳过入队。
- ai.default_model_id 同时接受模型 ID 与名称,填名称时不再被 MySQL
  静默转成 0 而使配置失效。
- 聊天媒体留存管理与清理任务(迁移 033,两台线上均已应用)。

新增集成测试均针对真实 MySQL:会员限制、免短信注册、AI 入队规则、
默认模型解析、资料距离。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:00:08 +08:00

428 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-sql-driver/mysql"
)
type aiModelRequest struct {
Name string `json:"name"`
Protocol string `json:"protocol"`
BaseURL string `json:"baseUrl"`
ModelName string `json:"modelName"`
APIKey string `json:"apiKey"`
ClearAPIKey bool `json:"clearApiKey"`
Temperature *float64 `json:"temperature"`
MaxTokens *int `json:"maxTokens"`
TimeoutMS *int `json:"timeoutMs"`
Status *int `json:"status"`
IsDefault *bool `json:"isDefault"`
}
const aiModelColumns = `id,name,protocol,base_url,model_name,api_key_cipher,temperature,max_tokens,timeout_ms,status,is_default,created_at,updated_at`
func (a *App) scanAIModel(scan func(dest ...any) error) (aiModel, error) {
var model aiModel
var cipher string
var created, updated time.Time
if err := scan(&model.ID, &model.Name, &model.Protocol, &model.BaseURL, &model.ModelName, &cipher,
&model.Temperature, &model.MaxTokens, &model.TimeoutMS, &model.Status, &model.IsDefault, &created, &updated); err != nil {
return aiModel{}, err
}
model.HasAPIKey = cipher != ""
if cipher != "" {
plain, err := a.decryptSecret(cipher)
if err != nil {
// A key that cannot be decrypted (rotated encryption key) must not
// silently become an empty key that reaches the provider.
return model, fmt.Errorf("模型 %s 的密钥无法解密,请重新填写", model.Name)
}
model.APIKey = plain
}
model.CreatedAt = created.Format(time.RFC3339)
model.UpdatedAt = updated.Format(time.RFC3339)
return model, nil
}
func (a *App) loadAIModel(ctx context.Context, id int64) (aiModel, error) {
row := a.db.QueryRowContext(ctx, `SELECT `+aiModelColumns+` FROM ai_models WHERE id=?`, id)
return a.scanAIModel(row.Scan)
}
// The default model is what the reply worker uses when an agent has no explicit
// binding: the configured id first, then the flagged row.
func (a *App) defaultAIModel(ctx context.Context) (aiModel, error) {
if configured := strings.TrimSpace(a.configPlain(ctx, "ai.default_model_id", "")); configured != "" {
// The field is labelled "ID", but typing the model's name into it is an
// easy mistake that MySQL would quietly turn into id 0. Accept either,
// rather than silently ignoring what the operator chose.
row := a.db.QueryRowContext(ctx, `SELECT `+aiModelColumns+` FROM ai_models WHERE status=1 AND (id=? OR name=?) ORDER BY id=? DESC LIMIT 1`, configured, configured, configured)
if model, err := a.scanAIModel(row.Scan); err == nil {
return model, nil
}
}
row := a.db.QueryRowContext(ctx, `SELECT `+aiModelColumns+` FROM ai_models WHERE status=1 ORDER BY is_default DESC,id ASC LIMIT 1`)
model, err := a.scanAIModel(row.Scan)
if errors.Is(err, sql.ErrNoRows) {
return aiModel{}, fmt.Errorf("尚未配置可用的 AI 模型")
}
return model, err
}
func (a *App) adminAIModels(w http.ResponseWriter, r *http.Request) {
rows, err := a.db.QueryContext(r.Context(), `SELECT `+aiModelColumns+` FROM ai_models ORDER BY is_default DESC,id ASC`)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "查询模型列表失败")
return
}
defer rows.Close()
items := []aiModel{}
for rows.Next() {
model, scanErr := a.scanAIModel(rows.Scan)
if scanErr != nil && model.ID == 0 {
fail(w, http.StatusInternalServerError, 50001, "查询模型列表失败")
return
}
model.APIKey = ""
items = append(items, model)
}
if rows.Err() != nil {
fail(w, http.StatusInternalServerError, 50001, "查询模型列表失败")
return
}
protocols := make([]map[string]string, 0, len(aiProtocols))
for _, protocol := range aiProtocols {
protocols = append(protocols, map[string]string{"value": protocol, "label": aiProtocolName(protocol)})
}
reply(w, map[string]any{
"items": items,
"protocols": protocols,
"enabled": a.configBool(r.Context(), "ai.enabled", false),
"secretMask": maskedSecret,
})
}
func (a *App) normalizeAIModelRequest(req *aiModelRequest, existing *aiModel) error {
req.Name = strings.TrimSpace(req.Name)
req.Protocol = strings.ToLower(strings.TrimSpace(req.Protocol))
req.BaseURL = strings.TrimSpace(req.BaseURL)
req.ModelName = strings.TrimSpace(req.ModelName)
if existing != nil {
if req.Name == "" {
req.Name = existing.Name
}
if req.Protocol == "" {
req.Protocol = existing.Protocol
}
}
if req.Name == "" || len([]rune(req.Name)) > 60 {
return fmt.Errorf("模型名称须为 160 字")
}
if !containsString(aiProtocols, req.Protocol) {
return fmt.Errorf("不支持的模型协议")
}
if a.config.Environment == "production" && req.Protocol == "debug" {
return fmt.Errorf("生产环境禁止使用本地调试模型")
}
if len(req.BaseURL) > 300 || len([]rune(req.ModelName)) > 120 {
return fmt.Errorf("接口地址最多 300 字,模型名称最多 120 字")
}
if _, err := parseAIBaseURL(req.Protocol, req.BaseURL, a.config.Environment == "production"); err != nil {
return err
}
if req.Protocol != "debug" && req.Protocol != "webhook" && req.ModelName == "" {
return fmt.Errorf("请填写模型名称,例如 gpt-4o-mini 或 deepseek-chat")
}
if req.Temperature != nil && (*req.Temperature < 0 || *req.Temperature > 2) {
return fmt.Errorf("温度必须在 0 到 2 之间")
}
if req.MaxTokens != nil && (*req.MaxTokens < 16 || *req.MaxTokens > 4096) {
return fmt.Errorf("最大回复长度必须在 16 到 4096 之间")
}
if req.TimeoutMS != nil && (*req.TimeoutMS < 1000 || *req.TimeoutMS > 60000) {
return fmt.Errorf("超时必须在 1000 到 60000 毫秒之间")
}
if req.Status != nil && *req.Status != 0 && *req.Status != 1 {
return fmt.Errorf("状态无效")
}
return nil
}
func (a *App) adminCreateAIModel(w http.ResponseWriter, r *http.Request) {
var req aiModelRequest
if decode(r, &req) != nil {
fail(w, http.StatusBadRequest, 20001, "模型配置格式不正确")
return
}
if err := a.normalizeAIModelRequest(&req, nil); err != nil {
fail(w, http.StatusBadRequest, 20001, err.Error())
return
}
cipher, err := a.encryptSecret(strings.TrimSpace(req.APIKey))
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "加密模型密钥失败")
return
}
temperature, maxTokens, timeout, status := 0.8, 256, 15000, 1
if req.Temperature != nil {
temperature = *req.Temperature
}
if req.MaxTokens != nil {
maxTokens = *req.MaxTokens
}
if req.TimeoutMS != nil {
timeout = *req.TimeoutMS
}
if req.Status != nil {
status = *req.Status
}
isDefault := req.IsDefault != nil && *req.IsDefault
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "保存模型失败")
return
}
defer func() { _ = tx.Rollback() }()
if isDefault {
if _, err = tx.ExecContext(r.Context(), `UPDATE ai_models SET is_default=0 WHERE is_default=1`); err != nil {
fail(w, http.StatusInternalServerError, 50001, "保存模型失败")
return
}
}
result, err := tx.ExecContext(r.Context(), `INSERT INTO ai_models
(name,protocol,base_url,model_name,api_key_cipher,temperature,max_tokens,timeout_ms,status,is_default)
VALUES(?,?,?,?,?,?,?,?,?,?)`,
req.Name, req.Protocol, req.BaseURL, req.ModelName, cipher, temperature, maxTokens, timeout, status, btoi(isDefault))
if err != nil {
if isDuplicateAIModelName(err) {
fail(w, http.StatusBadRequest, 20001, "模型名称已存在")
return
}
fail(w, http.StatusInternalServerError, 50001, "保存模型失败")
return
}
id, _ := result.LastInsertId()
if err = tx.Commit(); err != nil {
fail(w, http.StatusInternalServerError, 50001, "保存模型失败")
return
}
a.audit(r, "create", "ai_model", id, map[string]any{"name": req.Name, "protocol": req.Protocol})
reply(w, map[string]any{"id": id})
}
func (a *App) adminUpdateAIModel(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "模型不存在")
return
}
existing, err := a.loadAIModel(r.Context(), id)
if errors.Is(err, sql.ErrNoRows) {
fail(w, http.StatusNotFound, 30001, "模型不存在")
return
}
var req aiModelRequest
if decode(r, &req) != nil {
fail(w, http.StatusBadRequest, 20001, "模型配置格式不正确")
return
}
if err = a.normalizeAIModelRequest(&req, &existing); err != nil {
fail(w, http.StatusBadRequest, 20001, err.Error())
return
}
// An empty key means "keep the stored one"; clearing is explicit.
cipherClause, cipherArgs := "", []any{}
if req.ClearAPIKey {
cipherClause = ",api_key_cipher=''"
} else if strings.TrimSpace(req.APIKey) != "" {
cipher, encryptErr := a.encryptSecret(strings.TrimSpace(req.APIKey))
if encryptErr != nil {
fail(w, http.StatusInternalServerError, 50001, "加密模型密钥失败")
return
}
cipherClause = ",api_key_cipher=?"
cipherArgs = append(cipherArgs, cipher)
}
temperature, maxTokens, timeout, status := existing.Temperature, existing.MaxTokens, existing.TimeoutMS, existing.Status
if req.Temperature != nil {
temperature = *req.Temperature
}
if req.MaxTokens != nil {
maxTokens = *req.MaxTokens
}
if req.TimeoutMS != nil {
timeout = *req.TimeoutMS
}
if req.Status != nil {
status = *req.Status
}
isDefault := existing.IsDefault
if req.IsDefault != nil {
isDefault = *req.IsDefault
}
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "保存模型失败")
return
}
defer func() { _ = tx.Rollback() }()
if isDefault {
if _, err = tx.ExecContext(r.Context(), `UPDATE ai_models SET is_default=0 WHERE is_default=1 AND id<>?`, id); err != nil {
fail(w, http.StatusInternalServerError, 50001, "保存模型失败")
return
}
}
args := append([]any{req.Name, req.Protocol, req.BaseURL, req.ModelName, temperature, maxTokens, timeout, status, btoi(isDefault)}, cipherArgs...)
args = append(args, id)
if _, err = tx.ExecContext(r.Context(), `UPDATE ai_models SET name=?,protocol=?,base_url=?,model_name=?,temperature=?,max_tokens=?,timeout_ms=?,status=?,is_default=?`+cipherClause+` WHERE id=?`, args...); err != nil {
if isDuplicateAIModelName(err) {
fail(w, http.StatusBadRequest, 20001, "模型名称已存在")
return
}
fail(w, http.StatusInternalServerError, 50001, "保存模型失败")
return
}
if err = tx.Commit(); err != nil {
fail(w, http.StatusInternalServerError, 50001, "保存模型失败")
return
}
a.audit(r, "update", "ai_model", id, map[string]any{"name": req.Name, "protocol": req.Protocol, "status": status})
reply(w, map[string]any{"success": true})
}
func (a *App) adminDeleteAIModel(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "模型不存在")
return
}
if _, err = a.db.ExecContext(r.Context(), `DELETE FROM ai_models WHERE id=?`, id); err != nil {
fail(w, http.StatusInternalServerError, 50001, "删除模型失败")
return
}
a.audit(r, "delete", "ai_model", id, nil)
reply(w, map[string]any{"success": true})
}
// A probe call from the console: the operator sees latency, tokens and the
// actual reply before any conversation depends on the configuration.
func (a *App) adminTestAIModel(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "模型不存在")
return
}
model, err := a.loadAIModel(r.Context(), id)
if errors.Is(err, sql.ErrNoRows) {
fail(w, http.StatusNotFound, 30001, "模型不存在")
return
}
if err != nil {
fail(w, http.StatusBadRequest, 20001, err.Error())
return
}
provider, err := newAIProvider(model, a.config.Environment == "production")
if err != nil {
fail(w, http.StatusBadRequest, 20001, err.Error())
return
}
request := aiChatRequest{
System: "你在参与一次接口连通性测试,请直接给出简短的中文回复。",
Messages: []aiChatMessage{{Role: "user", Text: aiProbePrompt}},
}
started := time.Now()
result, err := provider.Chat(r.Context(), request)
latency := int(time.Since(started).Milliseconds())
a.logAICall(r.Context(), model.ID, "probe", 0, 0, latency, result, err)
if err != nil {
fail(w, http.StatusBadGateway, 50002, err.Error())
return
}
a.audit(r, "test", "ai_model", id, map[string]any{"latencyMs": latency})
reply(w, map[string]any{
"reply": result.Text,
"latencyMs": latency,
"promptTokens": result.PromptTokens,
"completionTokens": result.CompletionTokens,
"finishReason": result.FinishReason,
})
}
func (a *App) logAICall(ctx context.Context, modelID int64, scene string, agentUserID, conversationID int64, latencyMS int, result aiChatResult, callErr error) {
status, message := 1, ""
if callErr != nil {
status = 0
message = callErr.Error()
if len([]rune(message)) > 500 {
message = string([]rune(message)[:500])
}
}
text := result.Text
if len([]rune(text)) > 1000 {
text = string([]rune(text)[:1000])
}
digest := ""
if scene != "" {
sum := sha256.Sum256([]byte(fmt.Sprintf("%s:%d:%d", scene, agentUserID, conversationID)))
digest = hex.EncodeToString(sum[:])[:32]
}
_, _ = a.db.ExecContext(ctx, `INSERT INTO ai_call_logs
(model_id,scene,agent_user_id,conversation_id,latency_ms,prompt_tokens,completion_tokens,status,error,prompt_digest,reply_text)
VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
modelID, scene, agentUserID, conversationID, latencyMS, result.PromptTokens, result.CompletionTokens, status, message, digest, text)
}
func isDuplicateAIModelName(err error) bool {
var mysqlErr *mysql.MySQLError
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 && strings.Contains(mysqlErr.Message, "uk_ai_models_name")
}
// Global switches are validated together because several of them only make
// sense as a pair, and the console tests them before the worker ever runs.
func (a *App) validateAIConfig(ctx context.Context) error {
if !a.configBool(ctx, "ai.enabled", false) {
return fmt.Errorf("AI 托管当前未启用")
}
minDelay := a.configInt(ctx, "ai.reply_delay_min_ms", 2000)
maxDelay := a.configInt(ctx, "ai.reply_delay_max_ms", 6000)
if minDelay < 0 || maxDelay < minDelay || maxDelay > 120000 {
return fmt.Errorf("回复延迟区间无效,最大延迟须不小于最小延迟且不超过 120000 毫秒")
}
if concurrency := a.configInt(ctx, "ai.max_concurrency", 4); concurrency < 1 || concurrency > 64 {
return fmt.Errorf("并发调用上限必须在 1 到 64 之间")
}
if limit := a.configInt(ctx, "ai.daily_call_limit", 0); limit < 0 {
return fmt.Errorf("每日调用上限不能为负数")
}
if limit := a.configInt(ctx, "ai.agent_daily_reply_limit", 0); limit < 0 {
return fmt.Errorf("单账号每日回复上限不能为负数")
}
if days := a.configInt(ctx, "ai.log_retention_days", 30); days < 1 || days > 365 {
return fmt.Errorf("调用日志保留天数必须在 1 到 365 之间")
}
for _, batch := range strings.Split(a.configPlain(ctx, "ai.allow_batches", ""), ",") {
if len(strings.TrimSpace(batch)) > 64 {
return fmt.Errorf("测试批次名称长度不能超过 64 位")
}
}
return nil
}
func (a *App) configInt(ctx context.Context, key string, fallback int) int {
value, err := strconv.Atoi(strings.TrimSpace(a.configPlain(ctx, key, "")))
if err != nil {
return fallback
}
return value
}