From 03430bcef51d44a7c0aee43ef6d1c27bcd2eeaa0 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 13 Oct 2026 08:05:24 +0800 Subject: [PATCH] =?UTF-8?q?fix(deploy):=20=E5=86=85=E7=BD=AE=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E7=9A=84=E4=BF=AE=E5=A4=8D=E6=97=A0=E6=B3=95=E4=B8=8B?= =?UTF-8?q?=E5=8F=91=20+=20buddy=20=E4=BD=99=E9=A2=9D=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## deploy.sh:内置插件修复本该覆盖 sync_plugins 原为「只补缺,已存在一律不动」。看着安全,实际造成功能性缺口: **内置插件自身的修复永远下发不了**。v2.0.3/v2.0.4/v2.0.5 三个渠道修复就这样 全部卡在仓库里 —— 二进制换了、/etc/llmsproxy/plugins/channels.lua 还是旧版 (实测 sha256 不同、headers_fn 计数 0),生产行为毫无变化。我自己的验证还被 这个"静默不生效"骗过一轮:测试全绿、部署成功,线上仍是旧逻辑。 修法:与 sync_adapters 同纪律 —— **内置同名文件覆盖(带备份)**, 运行时新增/运维自加的非内置名字一律不动。内置源码是发行物的一部分, 部署必须与二进制内容对齐;"只补缺"想保护的是非内置文件,两者不是一回事。 判据:部署日志出现 `~ 更新内置插件: channels.lua`;部署后生产文件 sha256 与仓库一致。 ## buddy 余额:双层嵌套 + 只累加有效包 余额端点响应是 **data.Response.Data.Accounts[]**(与签到端点的单层 data 不同),原实现按顶层找 totalCredit → 永远解析不出、回落 0 或报错。 且刻意**不用**服务端的 TotalDosage:那是终身口径且取整(参考实测同一响应 TotalDosage=655 而 IDE 显示 155.67)。正确算法是遍历 Accounts 现算: 只累加有效包的 CycleCapacityRemain(失效包服务端仍返回,并入会虚高), 按两位小数规整;双层缺失时如实报错,不把「查不到」当「余额为 0」。 判据 TestBuddyBalanceParseDoubleNested:100.5+55.17=155.67(失效的 500 不计入)+ 单层 data 判为失败。变异:改用 TotalDosage → 立即 FAIL(已实测)。 go test ./internal/core/ 与 ./internal/lua/ 全绿。 --- deploy.sh | 47 +++++++++++++++++---------- internal/core/channels_pool_test.go | 48 ++++++++++++++++++++++++++++ internal/lua/plugins/channels.lua | 49 +++++++++++++++++++++++++++-- 3 files changed, 126 insertions(+), 18 deletions(-) diff --git a/deploy.sh b/deploy.sh index bd5fbd3..b908167 100755 --- a/deploy.sh +++ b/deploy.sh @@ -393,13 +393,17 @@ sync_adapters() { # 同步内置插件到 plugin_dir。 # -# 为什么需要它:新内置插件(如 channels.lua)过去只能靠内核的 SeedBundled 落地, -# 而旧版 SeedBundled "目录已存在就什么都不做",于是升级后新插件永远不会出现在 -# 生产 —— 它在二进制里、却不在 /api/plugins。内核端已修(改为按文件补齐), -# 这里再显式同步一道,让部署与二进制内容对齐、不依赖内核那一侧的行为。 +# 内置插件是**内核发行物的一部分**(go:embed 进二进制,随版本演进),与内置适配器 +# 同性质。因此这里的纪律与 sync_adapters 完全一致:**覆盖内置同名文件**(带备份), +# 保留目录里运行时新增/运维自加的 .lua。 # -# 只补缺:已存在的插件文件一律不动。运维自己改过、或运行时新增的插件必须跨 -# 部署存活;覆盖它们会静默抹掉线上正在用的逻辑。 +# 早期实现是「只补缺,已存在一律不动」,看着安全,实际造成一个功能性缺口: +# 内置插件本身的**修复无法下发**。v2.0.3/v2.0.4/v2.0.5 三个渠道修复就这样 +# 全部卡在仓库里 —— 二进制换了、/etc/llmsproxy/plugins/channels.lua 还是旧版, +# 生产行为毫无变化。内置源码的正确性由仓库保证,部署必须与二进制内容对齐。 +# +# 「只补缺」原本想保护的是「运维改过 / 运行时新增的插件」,但那些都是**非内置 +# 名字**:内置名一律以发行物为准,其余名字一律不动。两者不是一回事。 sync_plugins() { log "同步内置插件" @@ -408,24 +412,35 @@ sync_plugins() { mkdir -p "$TARGET_PLUGINS" - # 备份将被触及的文件(只会是本次新增的那些) + # 备份将被覆盖/新增的内置插件(非内置名字不进入循环,天然不动) BACKUP_DIR="/etc/llmsproxy/plugins.bak.$(date +%Y%m%d%H%M%S)" - local added=0 mkdir -p "$BACKUP_DIR" + local added=0 updated=0 same=0 for src in "$SRC_PLUGINS"/*.lua; do [[ -f "$src" ]] || continue local name; name="$(basename "$src")" if [[ -f "$TARGET_PLUGINS/$name" ]]; then - continue # 已存在:运维版本优先,不覆盖 + if cmp -s "$src" "$TARGET_PLUGINS/$name"; then + ((same++)) || true + continue + fi + cp -f "$TARGET_PLUGINS/$name" "$BACKUP_DIR/$name.bak" 2>/dev/null || true + cp -f "$src" "$TARGET_PLUGINS/$name" + chmod 0644 "$TARGET_PLUGINS/$name" + log " ~ 更新内置插件: $name" + ((updated++)) || true + else + cp -f "$src" "$TARGET_PLUGINS/$name" + chmod 0644 "$TARGET_PLUGINS/$name" + log " + 新增内置插件: $name" + ((added++)) || true fi - cp -f "$src" "$TARGET_PLUGINS/$name" - chmod 0644 "$TARGET_PLUGINS/$name" - log " + 新增内置插件: $name" - ((added++)) || true done - if (( added == 0 )); then - log " 无新增内置插件(均已存在)" + # 没有任何改动时不必留空备份目录 + if (( updated == 0 && added == 0 )); then rmdir "$BACKUP_DIR" 2>/dev/null || true + else + log " 旧插件已备份到 $BACKUP_DIR" fi # 校验:内嵌插件目录里的每个 .lua 都必须在目标目录存在 @@ -434,7 +449,7 @@ sync_plugins() { local name; name="$(basename "$src")" [[ -f "$TARGET_PLUGINS/$name" ]] || warn " 缺少插件: $name" done - log "✓ 插件已同步到 $TARGET_PLUGINS/(新增 $added,已有文件未改动)" + log "✓ 插件已同步到 $TARGET_PLUGINS/(新增 $added,更新 $updated,未变 $same)" } # ---------- 重启服务 ---------- diff --git a/internal/core/channels_pool_test.go b/internal/core/channels_pool_test.go index c4b8e84..a7b61bd 100644 --- a/internal/core/channels_pool_test.go +++ b/internal/core/channels_pool_test.go @@ -368,3 +368,51 @@ func TestBuddyBillingHeadersMatchReference(t *testing.T) { } } } + +// TestBuddyBalanceParseDoubleNested 逐行锁定参考 fetchCreditBalance 的解析口径: +// 响应是**双层嵌套** data.Response.Data.Accounts[](与签到端点的单层 data 不同, +// 这是该接口最容易错的地方),且只累加**有效包**的 CycleCapacityRemain —— +// 失效包服务端仍返回,并进总额会让数字虚高(参考实测 155.67 → 655.67)。 +func TestBuddyBalanceParseDoubleNested(t *testing.T) { + _, ps := poolCore(t) + + // 两个有效包 + 一个失效包(ExpiredTime 在 2020 年) + body := `{"code":0,"msg":"OK","data":{"Response":{"Data":{"Accounts":[ + {"PackageName":"A","Status":1,"CycleCapacityRemain":100.5,"ExpiredTime":"2099-01-01 00:00:00"}, + {"PackageName":"B","Status":1,"CycleCapacityRemain":55.17000031,"ExpiredTime":"2099-01-01 00:00:00"}, + {"PackageName":"C","Status":1,"CycleCapacityRemain":500,"ExpiredTime":"2020-01-01 00:00:00"} + ]}}}}` + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, body) + })) + defer ts.Close() + + invoke(t, ps, "set_endpoints", fmt.Sprintf( + `{"platform":"buddy","balance":{"url":%q,"method":"POST","body":"{}"}}`, ts.URL)) + invoke(t, ps, "account_add", `{"platform":"buddy","credential":"BD-AT","nickname":"b"}`) + + j := invoke(t, ps, "credits_balances", `{"platform":"buddy"}`) + var row map[string]interface{} + for _, a := range asArr(j["accounts"]) { + row = a.(map[string]interface{}) + } + bal, _ := row["balance"].(map[string]interface{}) + if bal == nil { + t.Fatalf("应解析出余额, 实得 error=%v", row["error"]) + } + // 100.5 + 55.17 = 155.67(失效包的 500 不计入) + if got, _ := bal["total"].(float64); got != 155.67 { + t.Fatalf("余额 = %v, 期望 155.67(只累加有效包、两位小数)", bal["total"]) + } + + // 单层 data(缺 Response 层)必须判为解析失败,不能当 0 + body = `{"code":0,"data":{"TotalDosage":609}}` + j = invoke(t, ps, "credits_balances", `{"platform":"buddy"}`) + for _, a := range asArr(j["accounts"]) { + row = a.(map[string]interface{}) + } + if row["balance"] != nil { + t.Fatalf("双层缺失时应报错而非当 0(参考语义:区分「查不到」与「余额为 0」): %v", row) + } +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 1da1a7a..5446c94 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -331,12 +331,57 @@ local PLATFORMS = { end, }, -- 余额(对齐参考 USER_RESOURCE_PATH /v2/billing/meter/get-user-resource) + -- + -- ⚠️ 响应是**双层嵌套** data.Response.Data.Accounts[](与签到端点的单层 + -- data 不同),这是本接口最容易解析错的地方。 + -- + -- ⚠️ 刻意**不用**服务端的 TotalDosage:它是**终身口径**且取整(实测同一 + -- 响应里 TotalDosage=655 而 IDE 显示 155.67)。正确算法是遍历 Accounts[] + -- 现算:只累加**有效包**的 CycleCapacityRemain(失效包服务端仍会返回, + -- 但不能用于扣费,并进总额会让数字虚高),最后按两位小数规整。 balance = { url = "https://copilot.tencent.com/v2/billing/meter/get-user-resource", method = "POST", body = "{}", total_fn = function(rec) - local d = rec.data or rec - return tonumber(d.totalCredit or d.total_credit or d.balance or d.amount) or 0 + local data = rec.data + if type(data) ~= "table" then return nil end + local resp = data.Response + if type(resp) ~= "table" then return nil end + local inner = resp.Data + if type(inner) ~= "table" then return nil end + local accounts = inner.Accounts + if type(accounts) ~= "table" then return nil end + local now = os.time and os.time() or 0 + local sum = 0 + for _, pkg in ipairs(accounts) do + if type(pkg) == "table" then + -- 失效判定:Status 显式已过期,或 ExpiredTime / DeductionEndTime 已过。 + local active = true + local st = pkg.Status + if st == 2 or st == "2" or st == "EXPIRED" or st == "expired" then active = false end + local function past(t) + if type(t) ~= "string" or t == "" then return false end + local Y, Mo, D, H, Mi, S = t:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)[T ](%d%d):(%d%d):(%d%d)") + if Y then + local ts = os.time({ year = tonumber(Y), month = tonumber(Mo), day = tonumber(D), + hour = tonumber(H), min = tonumber(Mi), sec = tonumber(S), isdst = false }) + return ts and now >= ts + end + return false + end + if past(pkg.ExpiredTime) then active = false end + local ded = tonumber(pkg.DeductionEndTime) + if ded and ded > 0 then + -- 毫秒时间戳 + if now >= math.floor(ded / 1000) then active = false end + end + if active then + sum = sum + (tonumber(pkg.CycleCapacityRemain) or 0) + end + end + end + -- 两位小数规整(服务端精确值带浮点噪声,如 55.67000031) + return math.floor(sum * 100 + 0.5) / 100 end, }, },