diff --git a/internal/core/channels_zcode_3012_test.go b/internal/core/channels_zcode_3012_test.go new file mode 100644 index 0000000..6d69957 --- /dev/null +++ b/internal/core/channels_zcode_3012_test.go @@ -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", `

Error 3012

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) +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index b74ae2c..5adb8e9 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -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_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" } @@ -8573,11 +8573,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 +9013,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` → 仅看数字会**误判成风控** +-- `

Error 3012

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) == "" + elseif type(c) == "string" then + hasDate = c:sub(1, 17) == "" + 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 +9622,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 +9831,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 +10741,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()