feat(channels): 无凭据账号的批量清理能力(dry_run 默认开启)

## 为什么需要
`account_add` 允许**不带凭据**创建账号(设计如此:"未带凭据的等 login 完成"),
于是"先建占位、再走登录补齐"是正常用法。但登录没走完时(用户放弃、或历史批量
导入)会留下**只有元数据、永远不可用**的账号:它们占着 provider 的账号计数
(面板显示"10 个账号"却全不可用),让"这个平台到底能不能用"难以判断。

实测生产:13 个账号里只有 1 个真正可用 —— codearts 10 个 + trae 2 个全是占位
(同秒批量创建、nickname=id、凭据库里无对应条目)。

## 安全性(破坏性操作,两条性质都被变异验证钉住)
① **只删没有凭据的**:有凭据的一律不动(哪怕它测试失败 —— 那是"可疑"而非
   "空壳",处置完全不同);
② **dry_run 默认开启**:不显式传 dry_run=false 就只回报会删哪些,不真删;
③ 复用 `plugin.account_delete`(同一份删除实现)—— 删除时要顺带做的事
   (drop source、清旧单账号源、删凭据)不另写一套,否则必然漂移。

## ★ 踩到 Lua 顶层 local 上限(第 13 次同类)
helper 写成顶层 `local function accountHasCredential` 后 `luac -p` 直接报
`too many local variables (limit is 200)`。改为挂在 plugin 表上(`plugin.__hasCredential`)
—— 同样只有一份实现,且不占顶层名额。**每次给本文件加顶层 local 前必须 luac -p。**

## 判据(3 条 + 变异 2/2)
internal/core/channels_clear_credentialless_test.go
- 只删空壳、保留有凭据的,且**有凭据者的凭据必须仍在**(防误删)
- 指定 platform 时范围隔离(不动别的平台)
- 未知平台必须报错(否则会误删全部平台)
变异:无条件全删 → 报红;dry_run 默认改 false → 报红。
This commit is contained in:
JianFeeeee
2026-10-16 07:45:52 +08:00
parent e6fa2a678f
commit 1f18643d1f
2 changed files with 275 additions and 1 deletions

View File

@ -0,0 +1,195 @@
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
}