mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 17:57:23 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c82c7ff6b | |||
| 1f18643d1f | |||
| e6fa2a678f |
235
internal/core/channels_clear_credentialless_test.go
Normal file
235
internal/core/channels_clear_credentialless_test.go
Normal file
@ -0,0 +1,235 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// ★ 无凭据账号的批量清理:**只删没有凭据的**,有凭据的一律不动。
|
||||
//
|
||||
// 场景(真实发生):`account_add` 允许不带凭据创建账号(设计如此 —— 先建占位、
|
||||
// 登录补齐),于是登录没走完时留下"只有元数据、永远不可用"的账号,占着账号计数,
|
||||
// 让"这个平台到底能不能用"难以判断。实测生产 13 个账号里只有 1 个真可用
|
||||
// (codearts 10 + trae 2 全是占位)。
|
||||
//
|
||||
// 判据看的是**清理后的真实池状态**,而不是"函数被调用过"。
|
||||
func TestAccountClearCredentiallessOnlyRemovesEmptyOnes(t *testing.T) {
|
||||
h := newAccountCleanupHarness(t)
|
||||
|
||||
// ① 默认 dry_run:必须**不删**任何东西
|
||||
pre, err := h.ps.Call("channels", "account_clear_credentialless", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
preJ := jsonOf(t, pre)
|
||||
if preJ["dry_run"] != true {
|
||||
t.Fatalf("默认必须 dry_run(破坏性操作不能默认执行),实得 %v", preJ["dry_run"])
|
||||
}
|
||||
// 夹具放了 4 个 codearts(3 空壳 + 1 有凭据)
|
||||
if got := len(accountIDs(t, h, "codearts")); got != 4 {
|
||||
t.Fatalf("dry_run 不该删任何账号,codearts 仍有 %d 个(want 4)", got)
|
||||
}
|
||||
// 不传 platform ⇒ 全部平台:codearts 3 空壳 + trae 2 空壳 = 5
|
||||
if wd, _ := preJ["would_delete"].(float64); int(wd) != 5 {
|
||||
t.Fatalf("预演应报告将删 5 个(全平台),实得 %v", preJ["would_delete"])
|
||||
}
|
||||
if kc, _ := preJ["kept_with_credential"].(float64); int(kc) != 1 {
|
||||
t.Fatalf("应报告保留 1 个有凭据账号,实得 %v", preJ["kept_with_credential"])
|
||||
}
|
||||
|
||||
// ② 真执行:删无凭据的,**保留有凭据的**
|
||||
if _, err := h.ps.Call("channels", "account_clear_credentialless",
|
||||
map[string]interface{}{"dry_run": false}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := len(accountIDs(t, h, "codearts")); got != 1 {
|
||||
t.Fatalf("应只剩 1 个有凭据账号,实得 %d", got)
|
||||
}
|
||||
// ★ 关键:留下的必须是**有凭据的那个**
|
||||
left := accountIDs(t, h, "codearts")
|
||||
if len(left) != 1 || left[0] != "withcred" {
|
||||
t.Fatalf("留下的必须是有凭据的账号 withcred,实得 %v", left)
|
||||
}
|
||||
// 有凭据的账号其凭据也必须仍在(不能被误删)
|
||||
if !h.hasCredential("withcred") {
|
||||
t.Fatal("有凭据账号的凭据被误删了")
|
||||
}
|
||||
t.Logf("✓ 删 3 保 1,且有凭据账号的凭据完好")
|
||||
}
|
||||
|
||||
// 指定平台时只动那个平台,别的不碰。
|
||||
func TestAccountClearCredentiallessScopedToPlatform(t *testing.T) {
|
||||
h := newAccountCleanupHarness(t)
|
||||
|
||||
if _, err := h.ps.Call("channels", "account_clear_credentialless",
|
||||
map[string]interface{}{"platform": "codearts", "dry_run": false}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// codearts 被清(剩 1),trae 的 2 个占位**不该被动**
|
||||
if got := len(accountIDs(t, h, "codearts")); got != 1 {
|
||||
t.Fatalf("codearts 应剩 1,实得 %d", got)
|
||||
}
|
||||
if got := len(accountIDs(t, h, "trae")); got != 2 {
|
||||
t.Fatalf("指定 platform=codearts 时不该动 trae(应仍 2),实得 %d", got)
|
||||
}
|
||||
t.Log("✓ 平台范围隔离正确")
|
||||
}
|
||||
|
||||
// 未知平台必须报错,不能静默当成"全部平台"(否则会误删别的平台)。
|
||||
func TestAccountClearCredentiallessRejectsUnknownPlatform(t *testing.T) {
|
||||
h := newAccountCleanupHarness(t)
|
||||
res, err := h.ps.Call("channels", "account_clear_credentialless",
|
||||
map[string]interface{}{"platform": "no-such-platform", "dry_run": false})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
j := jsonOf(t, res)
|
||||
if j["error"] == nil {
|
||||
t.Fatalf("未知平台必须报错(否则会误删全部平台),实得 %v", j)
|
||||
}
|
||||
// 且什么都没删
|
||||
if got := len(accountIDs(t, h, "codearts")); got != 4 {
|
||||
t.Fatalf("报错时不该删任何东西,codearts 实得 %d(want 4)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── harness ──
|
||||
|
||||
type accountCleanupHarness struct {
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
}
|
||||
|
||||
func newAccountCleanupHarness(t *testing.T) *accountCleanupHarness {
|
||||
t.Helper()
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &accountCleanupHarness{t: t, ps: ps}
|
||||
// 3 个无凭据占位 + 1 个有凭据账号(codearts);另 2 个 trae 占位
|
||||
for _, id := range []string{"empty1", "empty2", "empty3"} {
|
||||
h.add("codearts", id, "")
|
||||
}
|
||||
h.add("codearts", "withcred", `{"access_key_id":"AK","secret_access_key":"SK"}`)
|
||||
h.add("trae", "tempty1", "")
|
||||
h.add("trae", "tempty2", "")
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *accountCleanupHarness) add(platform, id, cred string) {
|
||||
h.t.Helper()
|
||||
args := map[string]interface{}{"platform": platform, "account_id": id}
|
||||
if cred != "" {
|
||||
args["credential"] = cred
|
||||
}
|
||||
if _, err := h.ps.Call("channels", "account_add", args); err != nil {
|
||||
h.t.Fatalf("account_add(%s/%s): %v", platform, id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *accountCleanupHarness) hasCredential(accountID string) bool {
|
||||
h.t.Helper()
|
||||
res, err := h.ps.Call("channels", "account_list",
|
||||
map[string]interface{}{"platform": "codearts"})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
for _, a := range accountsOf(h.t, res) {
|
||||
if a["id"] == accountID {
|
||||
return a["has_credential"] == true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// jsonOf 取插件调用返回的 {json:{...}} 里那层 json。
|
||||
func jsonOf(t *testing.T, got interface{}) map[string]interface{} {
|
||||
t.Helper()
|
||||
m, ok := got.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("返回值不是对象: %#v", got)
|
||||
}
|
||||
j, ok := m["json"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("返回值缺 json 层: %#v", m)
|
||||
}
|
||||
return j
|
||||
}
|
||||
|
||||
// accountsOf 取 account_list 返回的账号数组。
|
||||
func accountsOf(t *testing.T, got interface{}) []map[string]interface{} {
|
||||
t.Helper()
|
||||
j := jsonOf(t, got)
|
||||
arr, _ := j["accounts"].([]interface{})
|
||||
out := make([]map[string]interface{}, 0, len(arr))
|
||||
for _, e := range arr {
|
||||
if m, ok := e.(map[string]interface{}); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// accountIDs 列某平台的账号 id。
|
||||
func accountIDs(t *testing.T, h *accountCleanupHarness, platform string) []string {
|
||||
t.Helper()
|
||||
res, err := h.ps.Call("channels", "account_list",
|
||||
map[string]interface{}{"platform": platform})
|
||||
if err != nil {
|
||||
t.Fatalf("account_list(%s): %v", platform, err)
|
||||
}
|
||||
var out []string
|
||||
for _, a := range accountsOf(t, res) {
|
||||
if id, ok := a["id"].(string); ok {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ★ 字符串 body(真实 HTTP 路径)+ 不传 platform 时,dry_run 必须被尊重。
|
||||
//
|
||||
// 反例(实测踩到,我自己写的):照其它 handler 写
|
||||
//
|
||||
// `if args.platform == nil then args = req end`
|
||||
//
|
||||
// —— 而"不传 platform"在本函数里是**合法用法**(清全部平台),于是 args 被换成
|
||||
// HandlerRequest,body 里解出来的 dry_run 就丢了 ⇒ 传 dry_run=false 却被当成
|
||||
// 未传,静默只预演。**破坏性操作里最糟的形态**:操作者以为执行了、实际没执行。
|
||||
//
|
||||
// 夹具必须用**字符串 body**(我第一版传 map,走不到这条路径,所以没咬住)。
|
||||
func TestAccountClearCredentiallessRespectsDryRunViaStringBody(t *testing.T) {
|
||||
h := newAccountCleanupHarness(t)
|
||||
|
||||
// 不带 platform + dry_run=false,且 body 是**字符串**(HTTP 真实形态)
|
||||
res, err := h.ps.InvokeHandler("channels", "account_clear_credentialless",
|
||||
&lua.HandlerRequest{Method: "POST", Body: `{"dry_run":false}`})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != 200 {
|
||||
t.Fatalf("status=%d body=%v", res.Status, res.Body)
|
||||
}
|
||||
j, _ := res.JSON.(map[string]interface{})
|
||||
if j["dry_run"] == true {
|
||||
t.Fatalf("字符串 body 里传了 dry_run=false 却被当成未传(回退吃掉 body): %v", j)
|
||||
}
|
||||
if j["deleted"] == nil {
|
||||
t.Fatalf("dry_run=false 时必须真的执行(应有 deleted 字段): %v", j)
|
||||
}
|
||||
// 全平台的 5 个空壳都被删(codearts 3 + trae 2)
|
||||
if got := len(accountIDs(t, h, "codearts")); got != 1 {
|
||||
t.Fatalf("codearts 应剩 1(有凭据那个),实得 %d", got)
|
||||
}
|
||||
if got := len(accountIDs(t, h, "trae")); got != 0 {
|
||||
t.Fatalf("trae 的 2 个空壳应被删,实得 %d", got)
|
||||
}
|
||||
t.Logf("✓ 字符串 body 且无 platform 时 dry_run=false 生效: deleted=%v", j["deleted"])
|
||||
}
|
||||
285
internal/core/channels_zcode_3012_test.go
Normal file
285
internal/core/channels_zcode_3012_test.go
Normal file
@ -0,0 +1,285 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ ZCode 3012 的判据必须是**多形态**的,且误判不是"文案不好看"——
|
||||
// 领取路径会据此**短路**(跳过本可领取的 plan),而限流与风控的用户动作
|
||||
// **完全相反**(稍后重试 vs 别重试)。
|
||||
//
|
||||
// 本判据逐行搬自参考 zcode-diagnostics.ts 的对抗性审计实测矩阵
|
||||
// (2026-10-05 自审 + 审计打出的真实缺陷)。
|
||||
//
|
||||
// 为什么用**共用模块**验:这段逻辑被领取路径(插件主体)与推理路径
|
||||
// (channels-zcode 适配器)共用,任一侧漂移都会让"请勿连续重试"只出现在
|
||||
// 一条路径上。判据直接打共用模块的导出,两边用的是同一份源码文本。
|
||||
func TestZcode3012ClassificationMatrix(t *testing.T) {
|
||||
h := newZcodeIdentityHarness(t)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want bool
|
||||
}{
|
||||
// ── 真风控(必须命中)──
|
||||
{"上游真实文案(带码)", `{"code":3012,"msg":"request has been blocked due to unusual activity."}`, true},
|
||||
{"码是纯数字字符串", `{"code":"3012","msg":"x"}`, true},
|
||||
{"仅正文语义(无数字)", `request has been blocked due to unusual activity.`, true},
|
||||
{"3012 与 block 紧邻", `3012 blocked by waf`, true},
|
||||
|
||||
// ── 必须**不**命中(误判会让用户做错事)──
|
||||
{"13012 不是 3012", `{"code":13012}`, false},
|
||||
{"标识符 plan-3012-trust", `plan-3012-trust`, false},
|
||||
{"标识符 trust_3012", `trust_3012`, false},
|
||||
{"字母包裹 x3012y", `x3012y`, false},
|
||||
{"限流的毫秒数", `too many requests, retry after 3012 ms`, false},
|
||||
{"HTML 里的 3012", `<h1>Error 3012</h1>upstream connect timeout`, false},
|
||||
{"限流+block 距 14", `rate limited: request blocked, retry after 3012 ms`, false},
|
||||
{"block 距 12", `temporarily blocked; see error 3012`, false},
|
||||
{"额度码 1005", `{"code":1005,"msg":"quota"}`, false},
|
||||
|
||||
// ── 隔离「词边界」规则:这些用例同时满足"block 紧邻",
|
||||
// 只有词边界能区分 ⇒ 裸子串实现会把前三个判成风控。
|
||||
// (最初的矩阵没覆盖这一组,变异测试因此没咬住 —— 补上后才有效。)
|
||||
{"标识符+block 紧邻", `plan-3012 blocked`, false},
|
||||
{"字母包裹+block 紧邻", `x3012 blocked`, false},
|
||||
{"下划线+block 紧邻", `trust_3012 blocked`, false},
|
||||
{"独立词+block 紧邻", `3012 blocked`, true},
|
||||
{"13012 里的片段+block 紧邻", `13012 blocked`, false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := h.callZD("hasUnusualActivity", tc.body)
|
||||
if got != tc.want {
|
||||
t.Errorf("hasUnusualActivity(%q) = %v, want %v\n"+
|
||||
"(误判的后果不是文案问题:领取路径会据此短路,且限流='稍后重试'、\n"+
|
||||
" 风控='别重试',两者用户动作完全相反)", tc.body, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 时间差格式化必须逐档单独算:参考初版把分钟档写成 `floor(seconds % 60)`,
|
||||
// 于是 90 秒被渲染成「30分30秒」(分位取的是**秒的余数**)。
|
||||
// 该错误是在**真实探针输出**里发现的,纯函数单测当时没覆盖 ≥60 秒的区间。
|
||||
func TestZcodeFormatGapDoesNotMisrenderMinutes(t *testing.T) {
|
||||
h := newZcodeIdentityHarness(t)
|
||||
for _, tc := range []struct {
|
||||
ms int
|
||||
want string
|
||||
}{
|
||||
{90000, "1分30秒"}, // 参考踩过的坑:错法会得到 "30分30秒"
|
||||
{59000, "59.0s"}, // 不足一分钟
|
||||
{60000, "1分"}, // 整分钟
|
||||
{3600000, "1小时"}, // 整小时
|
||||
{3900000, "1小时5分"}, // 小时+分
|
||||
{500, "500ms"},
|
||||
} {
|
||||
got := h.callZDString("formatGap", tc.ms)
|
||||
if got != tc.want {
|
||||
t.Errorf("formatGap(%d) = %q, want %q", tc.ms, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 诊断行**绝不能含凭据片段**(参考的设计红线之一)。
|
||||
//
|
||||
// 账号只以"进程内自增序号"出现。判据用「传入的账号 id 的每个子串都不得出现在
|
||||
// 文案里」来钉死这条 —— 不是"看起来没有"。
|
||||
func TestZcodeDiagnosticLeaksNoCredentials(t *testing.T) {
|
||||
h := newZcodeIdentityHarness(t)
|
||||
const secretID = "zcode-abcdef1234567890-SECRET"
|
||||
h.callZDAction("accountOrdinal", secretID)
|
||||
h.callZDAction2("noteRequestSent", secretID, 1000)
|
||||
h.callZDAction2("noteRequestOk", secretID, 1100)
|
||||
|
||||
diag := h.callZDString("formatDiagnostic", map[string]interface{}{
|
||||
"accountId": secretID,
|
||||
"sentAt": 1200,
|
||||
"now": 1300,
|
||||
"status": 405,
|
||||
})
|
||||
if diag == "" {
|
||||
t.Fatal("formatDiagnostic 返回空")
|
||||
}
|
||||
// 账号 id 的每个 >=4 字符子串都不得出现
|
||||
for i := 0; i+4 <= len(secretID); i++ {
|
||||
if strings.Contains(diag, secretID[i:i+4]) {
|
||||
t.Fatalf("诊断行泄漏账号 id 片段 %q:%s", secretID[i:i+4], diag)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(diag, "账号#") {
|
||||
t.Fatalf("诊断行应含「账号#序号」形态:%s", diag)
|
||||
}
|
||||
t.Logf("✓ 诊断行无凭据片段: %s", diag)
|
||||
}
|
||||
|
||||
// ★ 3012 的文案必须**共用**(推理 + 领取两条路径),且必须含"请勿连续重试"。
|
||||
//
|
||||
// 3012 不是"重试就能过"(那是 3007),而是**有账号冷却惩罚**:
|
||||
// 30 分钟起步,反复触发升级到 24 小时乃至停用。
|
||||
func TestZcode3012MessageIsSharedAndWarnsAboutRetries(t *testing.T) {
|
||||
h := newZcodeIdentityHarness(t)
|
||||
|
||||
msg := h.callZDString("formatUnusualActivityMessage", map[string]interface{}{
|
||||
"subject": "领取 plan-x",
|
||||
"rawResponse": "request has been blocked",
|
||||
})
|
||||
for _, want := range []string{"3012", "请勿连续重试", "冷却惩罚", "领取 plan-x"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("风控文案缺 %q:%s", want, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 空 subject/diagnostic/rawResponse 时不得留下空壳("本机诊断:"后面啥都没有)
|
||||
bare := h.callZDString("formatUnusualActivityMessage", map[string]interface{}{})
|
||||
if strings.Contains(bare, "本机诊断:\n") || strings.Contains(bare, "原始响应:\n") {
|
||||
t.Fatalf("空参数不应留下空壳段:%q", bare)
|
||||
}
|
||||
t.Logf("✓ 文案: %s", strings.SplitN(msg, "\n", 2)[0])
|
||||
}
|
||||
|
||||
// ★ 请求体形态诊断必须读**真正要发出去的对象**,而不是常量。
|
||||
//
|
||||
// 常量只能证明"源码里写了多少字",证明不了"线上发出去多少字"。
|
||||
// 两者一旦不一致(例如某条改动把 system 顶掉了),只有读实际值才看得出来
|
||||
// —— 它会**如实显示 0 字符**,而不是拿常量里的 2898 把问题盖住。
|
||||
func TestZcodeRequestShapeReadsActualObject(t *testing.T) {
|
||||
h := newZcodeIdentityHarness(t)
|
||||
|
||||
// 用真实的适配器产出 → 再诊断它(而不是手搓一个假 system)
|
||||
out, err := h.transformRequest(`{"model":"m","messages":[{"role":"user","content":"hi"}]}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var req map[string]interface{}
|
||||
if err := jsonUnmarshal(out, &req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shape := h.callZDMap("describeRequestShape", req["system"], req["messages"])
|
||||
if shape == nil {
|
||||
t.Fatal("describeRequestShape 返回空")
|
||||
}
|
||||
chars, _ := shape["identityChars"].(float64)
|
||||
if chars < 2800 {
|
||||
t.Fatalf("身份块实测字符数 %v < 2800(诊断必须读实际值)", chars)
|
||||
}
|
||||
if shape["hasDateBlock"] != true {
|
||||
t.Fatalf("应检测到日期块,实得 %v", shape["hasDateBlock"])
|
||||
}
|
||||
if shape["firstRole"] != "user" {
|
||||
t.Fatalf("firstRole 应为 user,实得 %v", shape["firstRole"])
|
||||
}
|
||||
t.Logf("✓ 形态诊断: 块数=%v 身份块=%v 日期块=%v role=%v",
|
||||
shape["systemBlocks"], chars, shape["hasDateBlock"], shape["firstRole"])
|
||||
|
||||
// 反例:空 system ⇒ 必须**如实显示 0**(不能被常量盖住)
|
||||
empty := h.callZDMap("describeRequestShape", []interface{}{}, req["messages"])
|
||||
if c, _ := empty["identityChars"].(float64); c != 0 {
|
||||
t.Fatalf("空 system 时 identityChars 必须为 0(如实反映实际值),实得 %v", c)
|
||||
}
|
||||
}
|
||||
|
||||
// ── harness 助手:经共用的探针打**那一份** ZD 实现 ──
|
||||
//
|
||||
// 走探针而不是让测试自己 load 一份副本:判据必须打真实被两条路径共用的实现,
|
||||
// 自己 load 副本会退化成"各自测各自的",共用一旦漂移就测不出来。
|
||||
|
||||
func (h *zcodeIdentityHarness) callZD(fn string, a interface{}) bool {
|
||||
h.t.Helper()
|
||||
res, err := h.ps.Call("channels", "__zcode_diag_probe",
|
||||
map[string]interface{}{"fn": fn, "a": a})
|
||||
if err != nil {
|
||||
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
|
||||
}
|
||||
m := res.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if e, ok := m["error"]; ok {
|
||||
h.t.Fatalf("探针错误(%s): %v", fn, e)
|
||||
}
|
||||
v, ok := m["result"].(bool)
|
||||
if !ok {
|
||||
h.t.Fatalf("%s 返回非布尔: %#v", fn, m["result"])
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (h *zcodeIdentityHarness) callZDString(fn string, a interface{}) string {
|
||||
h.t.Helper()
|
||||
var res interface{}
|
||||
var err error
|
||||
if opts, ok := a.(map[string]interface{}); ok {
|
||||
res, err = h.ps.Call("channels", "__zcode_diag_probe",
|
||||
map[string]interface{}{"fn": fn, "opts": opts})
|
||||
} else if fn == "formatGap" {
|
||||
res, err = h.ps.Call("channels", "__zcode_diag_probe",
|
||||
map[string]interface{}{"fn": fn, "a": a})
|
||||
} else {
|
||||
res, err = h.ps.Call("channels", "__zcode_diag_probe",
|
||||
map[string]interface{}{"fn": fn, "a": a})
|
||||
}
|
||||
if err != nil {
|
||||
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
|
||||
}
|
||||
m := res.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if e, ok := m["error"]; ok {
|
||||
h.t.Fatalf("探针错误(%s): %v", fn, e)
|
||||
}
|
||||
s, _ := m["result"].(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// callZDAction / callZDAction2:调"动作型"探针(不返回结果,只看没报错)。
|
||||
func (h *zcodeIdentityHarness) callZDAction(fn string, a interface{}) {
|
||||
h.t.Helper()
|
||||
res, err := h.ps.Call("channels", "__zcode_diag_probe",
|
||||
map[string]interface{}{"fn": fn, "a": a})
|
||||
if err != nil {
|
||||
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
|
||||
}
|
||||
if m := res.(map[string]interface{})["json"].(map[string]interface{}); m["error"] != nil {
|
||||
h.t.Fatalf("探针错误(%s): %v", fn, m["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func (h *zcodeIdentityHarness) callZDAction2(fn string, a interface{}, a2 int) {
|
||||
h.t.Helper()
|
||||
res, err := h.ps.Call("channels", "__zcode_diag_probe",
|
||||
map[string]interface{}{"fn": fn, "a": a, "a2": a2})
|
||||
if err != nil {
|
||||
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
|
||||
}
|
||||
if m := res.(map[string]interface{})["json"].(map[string]interface{}); m["error"] != nil {
|
||||
h.t.Fatalf("探针错误(%s): %v", fn, m["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func (h *zcodeIdentityHarness) callZDMap(fn string, args ...interface{}) map[string]interface{} {
|
||||
h.t.Helper()
|
||||
var res interface{}
|
||||
var err error
|
||||
if len(args) == 2 {
|
||||
res, err = h.ps.Call("channels", "__zcode_diag_probe",
|
||||
map[string]interface{}{"fn": fn, "system": args[0], "messages": args[1]})
|
||||
} else {
|
||||
res, err = h.ps.Call("channels", "__zcode_diag_probe",
|
||||
map[string]interface{}{"fn": fn, "a": args[0]})
|
||||
}
|
||||
if err != nil {
|
||||
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
|
||||
}
|
||||
m := res.(map[string]interface{})["json"].(map[string]interface{})
|
||||
if e, ok := m["error"]; ok {
|
||||
h.t.Fatalf("探针错误(%s): %v", fn, e)
|
||||
}
|
||||
out, _ := m["result"].(map[string]interface{})
|
||||
return out
|
||||
}
|
||||
|
||||
// jsonUnmarshal 是本文件用的小助手(避免与其它测试文件的导入冲突)。
|
||||
func jsonUnmarshal(s string, v interface{}) error {
|
||||
return json.Unmarshal([]byte(s), v)
|
||||
}
|
||||
@ -52,7 +52,7 @@ plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "st
|
||||
|
||||
-- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。
|
||||
-- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。
|
||||
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" }
|
||||
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "__zcode_diag_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" }
|
||||
|
||||
|
||||
|
||||
@ -6289,6 +6289,8 @@ plugin.http = {
|
||||
roles = { "admin" }, description = "列平台账号池" },
|
||||
{ path = "channels/account/update", method = "POST", handler = "account_update",
|
||||
roles = { "admin" }, description = "改账号昵称/启用态" },
|
||||
{ path = "channels/account/clear_credentialless", method = "POST", handler = "account_clear_credentialless",
|
||||
roles = { "admin" }, description = "清理无凭据账号(占位;dry_run 默认开启)" },
|
||||
{ path = "channels/account/delete", method = "POST", handler = "account_delete",
|
||||
roles = { "admin" }, description = "删除账号(含凭据与上游源)" },
|
||||
{ path = "channels/account/test", method = "POST", handler = "account_test",
|
||||
@ -6412,6 +6414,91 @@ function plugin.account_add(req)
|
||||
return { status = 200, json = { ok = true, account_id = aid, credentialRef = ref } }
|
||||
end
|
||||
|
||||
-- account_clear_credentialless:批量清掉**没有任何凭据**的账号。
|
||||
--
|
||||
-- ## 为什么需要它(真实场景)
|
||||
-- account_add 允许**不带凭据**创建账号(设计如此:"未带凭据的等 login 完成"),
|
||||
-- 于是"先建占位、再走登录补齐"是正常用法。但登录若没走完(用户放弃、或历史批量
|
||||
-- 导入),就会留下**只有元数据、永远不可用**的账号:它们占着 provider 的账号计数
|
||||
-- (面板显示"10 个账号"却全不可用),让"这个平台到底能不能用"变得难以判断。
|
||||
-- 实测生产有 12 个这种占位(codearts 10 + trae 2),13 个账号里只有 1 个真可用。
|
||||
--
|
||||
-- ## 安全性(**破坏性**操作,必须能证明它只删该删的)
|
||||
-- ① 只删没有凭据的:有凭据的账号**一律不动**(哪怕它测试失败 —— 那是"可疑"
|
||||
-- 而不是"空壳",两者处置完全不同);
|
||||
-- ② dry_run **默认开启**:不显式传 dry_run=false 就只回报会删哪些,不真删;
|
||||
-- ③ 复用 plugin.account_delete(同一份删除实现)—— 删除时要顺带做的事
|
||||
-- (drop source、清旧单账号源、删凭据)不能另写一套,否则必然漂移。
|
||||
-- ⚠️ 不写成顶层 `local`:本文件顶层 local 数已临近 Lua 的单函数 200 上限
|
||||
-- (加它会直接 luac -p 报 "too many local variables")。
|
||||
-- 挂在 plugin 表上同样只有一份实现,且不占顶层名额。
|
||||
plugin.__hasCredential = function(acct)
|
||||
if not acct or not acct.credentialRef then return false end
|
||||
local v = __llmsproxy_secrets.get(acct.credentialRef)
|
||||
return type(v) == "string" and v ~= ""
|
||||
end
|
||||
|
||||
function plugin.account_clear_credentialless(req)
|
||||
local args = bodyOf(req)
|
||||
-- ⚠️ 不要照其它 handler 写 `if args.platform == nil then args = req end`:
|
||||
-- 那个回退是为「直接把参数当 req 传」的旧调用准备的,但它会**丢掉 body**
|
||||
-- —— 本函数不传 platform 是**合法用法**(清全部平台),此时 args.platform
|
||||
-- 自然为 nil,回退会把 args 换成 HandlerRequest ⇒ 刚解出来的 dry_run 就没了,
|
||||
-- 于是「传 dry_run=false 却被当成未传」而静默只预演(破坏性操作里最糟的形态:
|
||||
-- 操作者以为执行了、实际没执行,或者反过来)。
|
||||
-- 实测踩到:不带 platform 时 deleted 恒为 nil。
|
||||
-- 兜底只认「req 本身就是参数表」这种形态。
|
||||
if type(req) == "table" and req.body == nil and req.query == nil then args = req end
|
||||
local dry = args.dry_run
|
||||
if dry == nil then dry = true end
|
||||
|
||||
local platforms
|
||||
if args.platform and args.platform ~= "" then
|
||||
local plat = platformById(args.platform)
|
||||
if not plat then return { status = 400, json = { error = "未知平台" } } end
|
||||
platforms = { plat }
|
||||
else
|
||||
platforms = allPlatforms()
|
||||
end
|
||||
|
||||
local victims, kept = {}, 0
|
||||
for _, plat in ipairs(platforms) do
|
||||
for _, a in ipairs(poolList(plat.id)) do
|
||||
if plugin.__hasCredential(a) then
|
||||
kept = kept + 1
|
||||
else
|
||||
victims[#victims + 1] = { platform = plat.id, id = a.id, nickname = a.nickname }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if dry then
|
||||
return { status = 200, json = {
|
||||
ok = true, dry_run = true, would_delete = #victims,
|
||||
kept_with_credential = kept, accounts = victims,
|
||||
message = "预演:将删除 " .. #victims .. " 个无凭据账号(有凭据的 " .. kept
|
||||
.. " 个不动)。确认后传 dry_run=false 执行。",
|
||||
} }
|
||||
end
|
||||
|
||||
local done, failed = {}, {}
|
||||
for _, v in ipairs(victims) do
|
||||
local r = plugin.account_delete({ platform = v.platform, account_id = v.id })
|
||||
if r and r.json and r.json.ok then
|
||||
done[#done + 1] = v.platform .. "/" .. v.id
|
||||
else
|
||||
failed[#failed + 1] = v.platform .. "/" .. v.id
|
||||
end
|
||||
end
|
||||
logEvent("清理无凭据账号 " .. #done .. " 个(失败 " .. #failed .. ")")
|
||||
return { status = 200, json = {
|
||||
ok = true, dry_run = false, deleted = #done, failed = #failed,
|
||||
kept_with_credential = kept, deleted_accounts = done,
|
||||
failed_accounts = #failed > 0 and failed or nil,
|
||||
} }
|
||||
end
|
||||
|
||||
|
||||
-- account_list:列平台的账号(对齐参考 account.list,含余额状态字段)
|
||||
function plugin.account_list(req)
|
||||
local args = bodyOf(req)
|
||||
@ -8573,11 +8660,22 @@ function claimZcode(plat, tok, args)
|
||||
else
|
||||
local ok, rec = pcall(json.decode, resp.body or "")
|
||||
if ok and type(rec) == "table" then
|
||||
-- ⚠️ 业务码可能是**纯数字字符串**("3012"),tonumber 必须能认
|
||||
local code = tonumber(rec.code) or tonumber(tostring(rec.code or ""))
|
||||
-- ⚠️ 业务码可能是**纯数字字符串**("3012"):ZD 归一。
|
||||
local code = (ZD and ZD.parseBusinessCode(rec.code))
|
||||
or tonumber(rec.code) or tonumber(tostring(rec.code or ""))
|
||||
local msg = tostring(rec.msg or rec.message or "")
|
||||
if code == 3012 or msg:find("unusual activity", 1, true) then
|
||||
failedMsg = "上游风控拦截(3012 unusual activity)—— 需重新获取验证码"
|
||||
-- ★ 风控判定走**共用模块**(与推理路径同一份源码文本)
|
||||
-- 见 ZCODE_DIAG_SRC 的头注释:3012 有账号冷却惩罚,
|
||||
-- "请勿连续重试"必须同时出现在两条路径上。
|
||||
-- 判定本身:只信业务码(3012 才是风控),无码可读时才看正文语义 ——
|
||||
-- 裸数字会把 `retry after 3012 ms`(限流)误判成风控,而两者动作相反。
|
||||
local isRisk = (ZD and ZD.hasUnusualActivity(resp.body or ""))
|
||||
or (code == 3012) or (msg:find("unusual activity", 1, true) ~= nil)
|
||||
if isRisk then
|
||||
failedMsg = (ZD and ZD.formatUnusualActivityMessage({
|
||||
subject = "领取 " .. tostring(planId or ""),
|
||||
rawResponse = msg,
|
||||
})) or "上游风控拦截(3012 unusual activity)—— 需重新获取验证码"
|
||||
elseif code and code ~= 0 and code ~= 200 then
|
||||
failedMsg = msg ~= "" and msg or ("业务码 " .. tostring(code))
|
||||
else
|
||||
@ -9002,6 +9100,326 @@ end
|
||||
return adapter
|
||||
]==]
|
||||
|
||||
local ZCODE_DIAG_SRC = [===[
|
||||
-- ============================================================================
|
||||
-- ZCode 3012 判据 / 文案 / 诊断(**唯一出处**,两处消费者共用同一份源码)
|
||||
--
|
||||
-- 消费者:
|
||||
-- ① 插件主体(领取路径 claimZcode);
|
||||
-- ② 自装适配器 channels-zcode(推理路径的错误分类 + 请求体形态诊断)。
|
||||
-- 两者由 installZcodeAdapter 把本段**前置**到适配器源码里实现共用
|
||||
-- (照参考的设计:formatZcodeUnusualActivityMessage 被两条路径引用同一份,
|
||||
-- 因为 3012 不是"重试就能过",而是**有账号冷却惩罚**——"请勿连续重试"
|
||||
-- 是这段文案里最重要的一句,绝不能只在其中一条路径上有)。
|
||||
--
|
||||
-- ## 为什么不能只看裸数字(参考 2026-10-05 对抗性审计的实测结论)
|
||||
--
|
||||
-- `too many requests, retry after 3012 ms` → 仅看数字会**误判成风控**
|
||||
-- `<h1>Error 3012</h1>upstream connect timeout` → 同上
|
||||
--
|
||||
-- 前者后果**不是文案问题**:限流是"稍后重试即可"、风控是"**别重试**",
|
||||
-- 两者用户动作**完全相反**;且领取路径据此**短路**,跳过本可领取的 plan。
|
||||
--
|
||||
-- ## 为什么也不能用"独立词 3012"当门禁
|
||||
--
|
||||
-- 上游 3012 的 `msg` 原文就是 `request has been blocked due to unusual
|
||||
-- activity.` —— **不含任何数字**(数字在 `code` 字段里)。以真实文案做样本时,
|
||||
-- "必须有独立词 3012"这条门禁会**反过来把它漏掉**。
|
||||
-- ⇒ 判据以**语义短语**为主,数字只作兜底(且必须与 block 字样紧邻)。
|
||||
-- ============================================================================
|
||||
local Z = {}
|
||||
|
||||
-- isUnusualActivityCode(code):码是否为 3012。只认 number 与**纯数字字符串**。
|
||||
--
|
||||
-- ⚠ 必须认字符串形态(真实缺陷 2026-10-05):上游会回
|
||||
-- {"code":"3012","msg":"..."},而只认 number 的解析会让风控码整条丢失,
|
||||
-- 用户看到的正是那句裸上游串(漏判的直接原因)。
|
||||
-- ⚠ 只认**纯数字**(^%-?%d+$),不把任意文本静默变成业务码。
|
||||
function Z.isUnusualActivityCode(code)
|
||||
if type(code) == "number" then return code == 3012 end
|
||||
if type(code) ~= "string" then return false end
|
||||
return code:match("^%s*3012%s*$") ~= nil
|
||||
end
|
||||
|
||||
-- parseBusinessCode(value):把上游 code 归一成数字;认 number 与纯数字字符串。
|
||||
function Z.parseBusinessCode(value)
|
||||
if type(value) == "number" then
|
||||
if value ~= value or value == math.huge or value == -math.huge then return nil end
|
||||
return value
|
||||
end
|
||||
if type(value) ~= "string" then return nil end
|
||||
local t = value:match("^%s*(.-)%s*$")
|
||||
if not t:match("^%-?%d+$") then return nil end
|
||||
return tonumber(t)
|
||||
end
|
||||
|
||||
-- looksLikeUnusualActivity(body):正文里以**独立词**出现 3012。
|
||||
--
|
||||
-- ⚠ 必须是词边界,不能是 body:find("3012", 1, true)(真实缺陷):裸子串会把
|
||||
-- 其它码里的数字片段一并命中,实测误判形态:
|
||||
-- {"code":13012} → 误命中
|
||||
-- plan-3012-trust → 误命中
|
||||
-- 后果不是"文案不好看":领取路径据此短路,把"plan 名里恰好含 3012"的普通
|
||||
-- 失败当成风控,**跳过本可领取的 plan**,并把真实业务码(如 1005 额度用尽)
|
||||
-- 改写成 3012 —— 两者用户动作完全相反(等额度重置 vs 别重试)。
|
||||
--
|
||||
-- ⚠ 边界集必须含 **`-` 与 `_`**:初版只排除数字 ⇒ `plan-3012-trust` 仍命中;
|
||||
-- 第二版补字母 ⇒ 仍命中(连字符不属 [0-9A-Za-z])。故取 [0-9A-Za-z_-]。
|
||||
-- `.` `,` `}` `:` 空格 行首行尾都**不**被排除,故上游真实形态
|
||||
-- {"code":3012,...} 与自然语言 `error: 3012 blocked` 照常命中。
|
||||
function Z.looksLikeUnusualActivity(body)
|
||||
if type(body) ~= "string" then return false end
|
||||
-- Lua 无 lookbehind/lookahead ⇒ 手工检查两个前导/后继字符
|
||||
local from = 1
|
||||
while true do
|
||||
local s, e = body:find("3012", from, true)
|
||||
if not s then return false end
|
||||
local before = s > 1 and body:sub(s - 1, s - 1) or ""
|
||||
local after = e < #body and body:sub(e + 1, e + 1) or ""
|
||||
local function bad(c)
|
||||
if c == "" then return false end
|
||||
return c:match("[0-9A-Za-z_%-]") ~= nil
|
||||
end
|
||||
if not bad(before) and not bad(after) then return true end
|
||||
from = s + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- mentionsUnusualActivity(body):正文是否**表达**了风控(仅凭正文时的唯一入口)。
|
||||
--
|
||||
-- ① 主判据:上游**实测原文**的语义短语(覆盖 `request has been blocked due to
|
||||
-- unusual activity.` 及其变体);
|
||||
-- ② 兜底:独立词 3012 与 block 字样**紧邻(≤10 字符)**。
|
||||
--
|
||||
-- ⚠ 为什么必须"紧邻"而不是"全文共现"(真实缺陷 N2):第一版写成"独立词 3012
|
||||
-- 且全文含 block",仍会误判限流正文:
|
||||
-- `rate limited: request blocked, retry after 3012 ms` → 距 14,误判
|
||||
-- `temporarily blocked; see error 3012` → 距 12,误判
|
||||
-- 而 `3012 blocked by waf` → 距 1,应命中
|
||||
-- 窗口 10 是实测收出来的(40 仍放过上面两个反例)。
|
||||
function Z.mentionsUnusualActivity(body)
|
||||
if type(body) ~= "string" then return false end
|
||||
if body:lower():find("unusual activity", 1, true) then return true end
|
||||
if body:lower():find("blocked due to", 1, true) then return true end
|
||||
if not Z.looksLikeUnusualActivity(body) then return false end
|
||||
-- 紧邻检查:3012 前后 10 字符内出现 block
|
||||
local low = body:lower()
|
||||
local from = 1
|
||||
while true do
|
||||
local s, e = low:find("3012", from, true)
|
||||
if not s then return false end
|
||||
local before = s > 1 and low:sub(math.max(1, s - 10), s - 1) or ""
|
||||
local after = e < #low and low:sub(e + 1, math.min(#low, e + 10)) or ""
|
||||
if before:find("block", 1, true) or after:find("block", 1, true) then return true end
|
||||
from = s + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- readBusinessCodeFromBody(body):从响应正文读业务码(认 number 与数字字符串)。
|
||||
function Z.readBusinessCodeFromBody(body)
|
||||
if type(body) ~= "string" then return nil end
|
||||
local ok, rec = pcall(json.decode, body)
|
||||
if not ok or type(rec) ~= "table" then return nil end
|
||||
return Z.parseBusinessCode(rec.code)
|
||||
end
|
||||
|
||||
-- hasUnusualActivity(body):**有优先级**——正文能解析出业务码就只信码;
|
||||
-- 无码可读才看正文语义。
|
||||
--
|
||||
-- ⚠ 优先级是必须的:{"code":3012} 这种正文**不含语义词**,直接走文本判定会
|
||||
-- 漏判 ⇒ 错误被归为可重试类 ⇒ 有**冷却惩罚**的风控被白重试多次
|
||||
-- (qoder 那边记过同型缺陷)。
|
||||
function Z.hasUnusualActivity(body)
|
||||
local code = Z.readBusinessCodeFromBody(body)
|
||||
if code ~= nil then return Z.isUnusualActivityCode(code) end
|
||||
return Z.mentionsUnusualActivity(body)
|
||||
end
|
||||
|
||||
-- formatUnusualActivityMessage(opts):3012 的**共用文案**。
|
||||
-- opts.subject(定位信息,如 planId)/ opts.diagnostic / opts.rawResponse。
|
||||
function Z.formatUnusualActivityMessage(opts)
|
||||
opts = opts or {}
|
||||
local subject = (type(opts.subject) == "string" and opts.subject ~= "")
|
||||
and (" · " .. opts.subject) or ""
|
||||
local diag = (type(opts.diagnostic) == "string" and opts.diagnostic ~= "")
|
||||
and ("本机诊断:" .. opts.diagnostic .. "\n") or ""
|
||||
local raw = (type(opts.rawResponse) == "string" and opts.rawResponse ~= "")
|
||||
and ("原始响应:" .. opts.rawResponse) or ""
|
||||
return "上游风控拦截(3012 unusual activity)" .. subject .. "。"
|
||||
.. "⚠ 该错误有账号冷却惩罚(30 分钟,反复触发会升级到 24 小时乃至停用),"
|
||||
.. "请勿连续重试。\n" .. diag .. raw
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- 进程级账号画像(只给诊断用;**绝不写凭据**)
|
||||
-- ============================================================================
|
||||
-- 红线(照参考):账号只以**进程内自增序号**出现,不含 accountId/JWT/账号名
|
||||
-- 的任何字符片段。
|
||||
local profiles, nextOrdinal = {}, 1
|
||||
|
||||
local function normalizeKey(accountId)
|
||||
if accountId == nil or accountId == "" then return "-" end
|
||||
return tostring(accountId)
|
||||
end
|
||||
|
||||
-- accountOrdinal(accountId):进程内序号,**首次见到时分配**(之后稳定)。
|
||||
-- 同一账号跨请求必须是同一个序号,否则"本次是第几个账号"会随请求变化。
|
||||
function Z.accountOrdinal(accountId)
|
||||
local k = normalizeKey(accountId)
|
||||
local p = profiles[k]
|
||||
if p then return p.ordinal end
|
||||
p = { ordinal = nextOrdinal, sent = 0, ok = 0, failed = 0 }
|
||||
nextOrdinal = nextOrdinal + 1
|
||||
profiles[k] = p
|
||||
return p.ordinal
|
||||
end
|
||||
|
||||
-- noteRequestSent(accountId, sentAt):记一次"请求已发出"。
|
||||
-- ⚠ sentAt 必须在**真正发出之前**取,否则"距上一条"会把本次排队时间算进去。
|
||||
function Z.noteRequestSent(accountId, sentAt)
|
||||
Z.accountOrdinal(accountId)
|
||||
local p = profiles[normalizeKey(accountId)]
|
||||
if not p then return end
|
||||
p.sent = p.sent + 1
|
||||
-- ⚠ 旧值**下沉一格**再写新的:诊断要的"距上条"是**上一条**,不是本次。
|
||||
-- 若拿 lastSentAt 顶替,算出来恒为 0ms,这一项就永远没有信息量
|
||||
-- (参考做端到端验证时真的打印出过"距上条 0ms")。
|
||||
p.previousSentAt = p.lastSentAt
|
||||
p.lastSentAt = sentAt
|
||||
end
|
||||
|
||||
function Z.noteRequestOk(accountId, at)
|
||||
local p = profiles[normalizeKey(accountId)]
|
||||
if not p then return end
|
||||
p.ok = p.ok + 1
|
||||
p.lastOkAt = at
|
||||
end
|
||||
|
||||
function Z.noteRequestFailed(accountId)
|
||||
local p = profiles[normalizeKey(accountId)]
|
||||
if not p then return end
|
||||
p.failed = p.failed + 1
|
||||
end
|
||||
|
||||
-- formatGap(ms):把毫秒差写成一眼能读的形态。
|
||||
--
|
||||
-- ⚠ 各档**逐档单独算**:参考初版把分钟档写成 floor(seconds % 60),于是 90 秒
|
||||
-- 被渲染成"30分30秒"(分位取的是**秒的余数**)。该错误在真实探针输出里发现,
|
||||
-- 纯函数单测当时没覆盖 ≥60 秒的区间 —— 故本实现直接按 totalSeconds 拆。
|
||||
function Z.formatGap(ms)
|
||||
if ms == nil or ms < 0 then return "—" end
|
||||
if ms < 1000 then return tostring(math.floor(ms + 0.5)) .. "ms" end
|
||||
local totalSeconds = math.floor(ms / 1000)
|
||||
if totalSeconds < 60 then return string.format("%.1fs", ms / 1000) end
|
||||
local minutes = math.floor(totalSeconds / 60)
|
||||
if minutes < 60 then
|
||||
local rest = totalSeconds % 60
|
||||
if rest == 0 then return tostring(minutes) .. "分" end
|
||||
return tostring(minutes) .. "分" .. tostring(rest) .. "秒"
|
||||
end
|
||||
local hours = math.floor(minutes / 60)
|
||||
local restMin = minutes % 60
|
||||
if restMin == 0 then return tostring(hours) .. "小时" end
|
||||
return tostring(hours) .. "小时" .. tostring(restMin) .. "分"
|
||||
end
|
||||
|
||||
local function formatSinceLastOk(at, now)
|
||||
if at == nil then return "从未成功过" end
|
||||
return Z.formatGap(now - at) .. "前"
|
||||
end
|
||||
|
||||
-- describeRequestShape(system, messages):读**真正要发出去的那个对象**的形态。
|
||||
--
|
||||
-- ⚠ 刻意**不读**常量(如"官方身份块 2898 字符"):常量只能证明"源码里写了多少
|
||||
-- 字",证明不了"线上发出去多少字"。两者一旦不一致(例如某条改动把 system
|
||||
-- 顶掉了),只有读实际值才看得出来 —— 它会**如实显示 0 字符**。
|
||||
function Z.describeRequestShape(system, messages)
|
||||
local blocks = type(system) == "table" and system or {}
|
||||
local function blockChars(b)
|
||||
if type(b) ~= "table" then return 0 end
|
||||
return type(b.text) == "string" and #b.text or 0
|
||||
end
|
||||
local n = #blocks
|
||||
local identityChars = 0
|
||||
local perBlock = {}
|
||||
for i = 1, math.min(2, n) do
|
||||
local c = blockChars(blocks[i])
|
||||
perBlock[#perBlock + 1] = c
|
||||
identityChars = identityChars + c
|
||||
end
|
||||
local first = type(messages) == "table" and messages[1] or nil
|
||||
local hasDate = false
|
||||
if type(first) == "table" then
|
||||
local c = first.content
|
||||
if type(c) == "table" and type(c[1]) == "table" and type(c[1].text) == "string" then
|
||||
hasDate = c[1].text:sub(1, 17) == "<system-reminder>"
|
||||
elseif type(c) == "string" then
|
||||
hasDate = c:sub(1, 17) == "<system-reminder>"
|
||||
end
|
||||
end
|
||||
return {
|
||||
systemBlocks = n,
|
||||
identityChars = identityChars,
|
||||
identityBlockChars = perBlock,
|
||||
cliPrefixChars = n >= 1 and blockChars(blocks[1]) or 0,
|
||||
hasDateBlock = hasDate,
|
||||
firstRole = (type(first) == "table" and tostring(first.role)) or "",
|
||||
}
|
||||
end
|
||||
|
||||
-- formatDiagnostic(input):拼一行现场数据(**不含任何凭据片段**)。
|
||||
function Z.formatDiagnostic(input)
|
||||
input = input or {}
|
||||
local p = profiles[normalizeKey(input.accountId)]
|
||||
local ordinal = (p and p.ordinal) or Z.accountOrdinal(input.accountId)
|
||||
local gap = (p and p.previousSentAt ~= nil)
|
||||
and Z.formatGap((input.sentAt or 0) - p.previousSentAt) or "—"
|
||||
local parts = {
|
||||
"账号#" .. tostring(ordinal),
|
||||
"本进程 成功 " .. tostring(p and p.ok or 0) .. "/失败 " .. tostring(p and p.failed or 0),
|
||||
"最近成功 " .. formatSinceLastOk(p and p.lastOkAt, input.now or 0),
|
||||
"距上条 " .. gap,
|
||||
}
|
||||
local shape = input.shape
|
||||
if shape then
|
||||
-- ⚠ 用 ~= nil 而非真值判断:Lua 里表恒为真,真正的分支是"传了/没传"
|
||||
local per = #(shape.identityBlockChars or {}) > 0
|
||||
and table.concat(shape.identityBlockChars, "+") or "无"
|
||||
parts[#parts + 1] = "身份块 " .. tostring(shape.identityChars or 0)
|
||||
.. " 字符(" .. per .. ")"
|
||||
parts[#parts + 1] = "日期块 " .. (shape.hasDateBlock and "有" or "无")
|
||||
end
|
||||
parts[#parts + 1] = "HTTP " .. tostring(input.status)
|
||||
return table.concat(parts, " · ")
|
||||
end
|
||||
|
||||
-- resetDiagnostics():清空全部进程级状态(只给判据用)。
|
||||
function Z.resetDiagnostics()
|
||||
profiles = {}
|
||||
nextOrdinal = 1
|
||||
end
|
||||
|
||||
return Z
|
||||
|
||||
]===]
|
||||
|
||||
-- ★ ZCode 3012 判据/文案/诊断的**唯一出处**(插件主体与 channels-zcode 适配器
|
||||
-- 共用同一份源码文本)。
|
||||
--
|
||||
-- 为什么必须共用:3012 不是"重试就能过",而是**有账号冷却惩罚**(30 分钟起步,
|
||||
-- 反复触发升级到 24 小时甚至停用)。"请勿连续重试"是这段文案里最重要的一句,
|
||||
-- 绝不能只在领取路径或只在推理路径上有(参考为此专门把文案抽成单一份)。
|
||||
--
|
||||
-- 本段在插件主体里**本地执行**得到 ZD;适配器里同文本前置进它自己的环境。
|
||||
local ZD = (function()
|
||||
local env = setmetatable({ json = json }, { __index = _G })
|
||||
local f = load(ZCODE_DIAG_SRC, "zcode-diag")
|
||||
if not f then return nil end
|
||||
local ok, mod = pcall(f)
|
||||
if not ok or type(mod) ~= "table" then return nil end
|
||||
return mod
|
||||
end)()
|
||||
|
||||
local ZCODE_ADAPTER_SRC = [===[
|
||||
-- ============================================================================
|
||||
-- ZCode 专用适配器(插件自装:channels-zcode)
|
||||
@ -9291,6 +9709,33 @@ function adapter.transform_request(raw_body)
|
||||
return json.encode(req)
|
||||
end
|
||||
|
||||
-- ★ transform_error:把 3012 判成**不可重试**并带上现场诊断。
|
||||
--
|
||||
-- 为什么不能只依赖通用文案:3012 有账号冷却惩罚(30 分钟起步),
|
||||
-- 归成"可重试的服务端错误"会让调用方**白重试**、把冷却越推越长。
|
||||
-- 诊断行(账号序号/成功失败计数/距上条/身份块实测字符数)是排查的全部现场,
|
||||
-- 且**不含任何凭据片段**(只出现进程内自增序号)。
|
||||
function adapter.transform_error(status, body)
|
||||
local text = tostring(body or "")
|
||||
if Z and Z.hasUnusualActivity(text) then
|
||||
return Z.formatUnusualActivityMessage({
|
||||
diagnostic = Z.formatDiagnostic({
|
||||
accountId = nil,
|
||||
sentAt = os.time and os.time() * 1000 or 0,
|
||||
now = os.time and os.time() * 1000 or 0,
|
||||
status = status,
|
||||
}),
|
||||
rawResponse = text:sub(1, 500),
|
||||
})
|
||||
end
|
||||
-- 非风控:交回 anthropic 基座的通用分类
|
||||
if type(BASE) == "table" and type(BASE.transform_error) == "function" then
|
||||
local ok, res = pcall(BASE.transform_error, status, body)
|
||||
if ok and type(res) == "string" then return res end
|
||||
end
|
||||
return text
|
||||
end
|
||||
|
||||
-- 暴露给判据:身份块长度与装载状态("装了但身份块为空"这类问题没有它只能靠猜)
|
||||
adapter.__identity_probe = function()
|
||||
return {
|
||||
@ -9473,11 +9918,19 @@ end
|
||||
-- 幂等(update 失败退化为 install);失败只记日志,不阻断插件加载。
|
||||
local function installZcodeAdapter()
|
||||
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
|
||||
local f, lerr = load(ZCODE_ADAPTER_SRC)
|
||||
-- ★ 把 3012 判据/文案/诊断模块**前置**到适配器源码里。
|
||||
-- 这样适配器与插件主体用的是**同一份源码文本**(ZCODE_DIAG_SRC)——
|
||||
-- 否则"请勿连续重试"这句话会只在一条路径上有(参考为此专门抽成单一份)。
|
||||
-- ⚠ 不能直接拼接:ZCODE_DIAG_SRC 末尾是 `return Z`,直接拼会让那个 return
|
||||
-- 结束**整个 chunk** ⇒ 后面的适配器代码永不执行("加载成功但适配器不存在",
|
||||
-- 且装载会静默成功)。故把它包进一个函数,让 return 只结束该函数。
|
||||
local full = "local Z = (function()\n" .. ZCODE_DIAG_SRC ..
|
||||
"\nend)()\n" .. ZCODE_ADAPTER_SRC
|
||||
local f, lerr = load(full)
|
||||
if not f then return false, "源码语法错误: " .. tostring(lerr) end
|
||||
local _, uerr = __llmsproxy_adapters.update("channels-zcode", ZCODE_ADAPTER_SRC)
|
||||
local _, uerr = __llmsproxy_adapters.update("channels-zcode", full)
|
||||
if uerr then
|
||||
local _, ierr = __llmsproxy_adapters.install("channels-zcode", ZCODE_ADAPTER_SRC)
|
||||
local _, ierr = __llmsproxy_adapters.install("channels-zcode", full)
|
||||
if ierr then return false, ierr end
|
||||
end
|
||||
return true, nil
|
||||
@ -10375,6 +10828,36 @@ function plugin.claims_capable(args)
|
||||
} }
|
||||
end
|
||||
|
||||
-- plugin.__zcode_diag_probe:只给测试/诊断用 —— 暴露共用的 3012 判据模块。
|
||||
--
|
||||
-- 为什么需要它:该模块被**两条路径共用**(领取路径在插件主体、推理路径在
|
||||
-- channels-zcode 适配器)。判据必须能直接打**那一份**实现,否则会退化成
|
||||
-- “各自测各自的副本”,而共用一旦漂移就再也测不出来。
|
||||
function plugin.__zcode_diag_probe(args)
|
||||
args = args or {}
|
||||
local fn = tostring(args.fn or "")
|
||||
if not ZD then return { json = { error = "诊断模块未装载" } } end
|
||||
local f = ZD[fn]
|
||||
if type(f) ~= "function" then return { json = { error = "未知函数: " .. fn } } end
|
||||
if fn == "formatGap" then return { json = { result = f(tonumber(args.a) or 0) } } end
|
||||
if fn == "formatUnusualActivityMessage" then
|
||||
return { json = { result = f(args.opts or {}) } }
|
||||
end
|
||||
if fn == "formatDiagnostic" then return { json = { result = f(args.opts or {}) } } end
|
||||
if fn == "describeRequestShape" then
|
||||
return { json = { result = f(args.system, args.messages) } }
|
||||
end
|
||||
if fn == "accountOrdinal" then return { json = { result = f(args.a) } } end
|
||||
if fn == "noteRequestSent" then f(args.a, tonumber(args.a2) or 0) return { json = { ok = true } } end
|
||||
if fn == "noteRequestOk" then f(args.a, tonumber(args.a2) or 0) return { json = { ok = true } } end
|
||||
if fn == "noteRequestFailed" then f(args.a) return { json = { ok = true } } end
|
||||
if fn == "hasUnusualActivity" or fn == "mentionsUnusualActivity"
|
||||
or fn == "looksLikeUnusualActivity" then
|
||||
return { json = { result = f(args.a) } }
|
||||
end
|
||||
return { json = { error = "探针未暴露该函数: " .. fn } }
|
||||
end
|
||||
|
||||
-- plugin.__adapter_install_probe:只给测试/诊断用 —— 报告本插件自装适配器的
|
||||
-- 装载结果(含语法错误详情)。"装了但没生效"这类问题没有它只能靠猜。
|
||||
function plugin.__adapter_install_probe()
|
||||
|
||||
Reference in New Issue
Block a user