按协议而不是按厂商做适配,与现有短信、对象存储的做法一致: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>
66 lines
2.5 KiB
Go
66 lines
2.5 KiB
Go
package app
|
|
|
|
import "testing"
|
|
|
|
func alertMessages(alerts []map[string]string) string {
|
|
joined := ""
|
|
for _, alert := range alerts {
|
|
joined += alert["level"] + ":" + alert["message"] + "\n"
|
|
}
|
|
return joined
|
|
}
|
|
|
|
func TestAIUsageAlerts(t *testing.T) {
|
|
// Nothing is wrong while the feature is off, however bad the numbers look.
|
|
if got := aiUsageAlerts(false, false, 0, 100, 500, 400, 9, "boom"); len(got) != 0 {
|
|
t.Fatalf("a disabled feature must not raise alerts: %v", got)
|
|
}
|
|
healthy := aiUsageAlerts(true, true, 3, 100, 10, 0, 0, "")
|
|
if len(healthy) != 0 {
|
|
t.Fatalf("a healthy setup must be quiet: %v", healthy)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
alerts []map[string]string
|
|
contains string
|
|
}{
|
|
{name: "no model", alerts: aiUsageAlerts(true, false, 3, 0, 0, 0, 0, ""), contains: "没有可用的模型"},
|
|
{name: "no agents", alerts: aiUsageAlerts(true, true, 0, 0, 0, 0, 0, ""), contains: "还没有账号开启托管"},
|
|
{name: "quota reached", alerts: aiUsageAlerts(true, true, 2, 100, 100, 0, 0, ""), contains: "今日调用已达上限"},
|
|
{name: "quota near", alerts: aiUsageAlerts(true, true, 2, 100, 80, 0, 0, ""), contains: "超过配额的 80%"},
|
|
{name: "failure rate", alerts: aiUsageAlerts(true, true, 2, 0, 10, 4, 0, "上游 429"), contains: "失败率超过 30%"},
|
|
{name: "stuck worker", alerts: aiUsageAlerts(true, true, 2, 0, 0, 0, 3, ""), contains: "worker 是否在运行"},
|
|
}
|
|
for _, test := range tests {
|
|
if !contains(alertMessages(test.alerts), test.contains) {
|
|
t.Errorf("%s: expected an alert mentioning %q, got %s", test.name, test.contains, alertMessages(test.alerts))
|
|
}
|
|
}
|
|
|
|
// The most recent error is quoted so the console shows why calls fail.
|
|
if !contains(alertMessages(aiUsageAlerts(true, true, 2, 0, 10, 4, 0, "上游 429")), "上游 429") {
|
|
t.Error("the failure alert must quote the latest error")
|
|
}
|
|
// A couple of failures out of a handful of calls is noise, not an alert.
|
|
if got := aiUsageAlerts(true, true, 2, 0, 4, 2, 0, ""); len(got) != 0 {
|
|
t.Errorf("a tiny sample must not trigger the failure alert: %v", got)
|
|
}
|
|
if got := aiUsageAlerts(true, true, 2, 0, 10, 2, 0, ""); len(got) != 0 {
|
|
t.Errorf("a 20%% failure rate must stay below the threshold: %v", got)
|
|
}
|
|
}
|
|
|
|
func contains(haystack, needle string) bool {
|
|
return len(needle) > 0 && len(haystack) >= len(needle) && indexOf(haystack, needle) >= 0
|
|
}
|
|
|
|
func indexOf(haystack, needle string) int {
|
|
for index := 0; index+len(needle) <= len(haystack); index++ {
|
|
if haystack[index:index+len(needle)] == needle {
|
|
return index
|
|
}
|
|
}
|
|
return -1
|
|
}
|