mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 20:27:23 +00:00
feat(channels): 渠道控制台对齐参考 ChannelCard —— 账号池/模型开关/供应商治理/备份 + 全量 UI
用户要求与示例插件 dsh-our-free-model 的 WebUI 全面对齐。参考前端的
核心是 ChannelCard(client.js 3627 行起):一个渠道可挂**多账号池**,
账号可逐个启用/停用/测试/删除/排序,模型可逐个开关,供应商整体开关,
账号备份导出/导入,自动签到开关。此前是单凭据 + 平表登录,差距大。
## 后端(channels.lua 新增 19 个 RPC,对齐参考 channel-pack 契约)
账号池(对齐参考 account.*):
account_add/list/update/delete/test/refresh/reorder —— 一平台多账号,
每账号一条上游源 channels-<plat>-<aid>,凭据走保险库(0600 加密落盘)。
complete_login 带 account_id 时走池路径回填凭据。
模型开关(对齐 model.*):
model_list/set_disabled/set_all_disabled —— 黑名单存 state,开关即重同步
该平台所有账号源(关掉模型=该账号源里去掉该模型)。
供应商治理(对齐 provider.*):
provider_status(账号数/模型数/closed 态)+ provider_set_enabled
(关闭=全关模型+停用全部账号;打开=清黑名单+启用全部账号)。
一键领取(对齐 credits.claimAll):
credits_claim_all —— 逐账号 claim_daily 收集结果,返回 {claimed, results}。
备份(对齐 backup.*):
backup_export/import —— 账号池+凭据+模型黑名单+自动签到开关打包,导入
恢复凭据(走保险库)+重建全部账号源。
自动签到开关(对齐 usage.autoCheckin):
auto_checkin —— 读写 state.settings.auto_checkin,关闭时 auto_claim 跳过。
auto_claim 升级为账号池驱动(逐账号 claim + 尊重 CHANNEL_CREDITS 能力表)。
**修一个真 bug**:__llmsproxy_secrets.set 返回 (true, nil) 成功 / (nil, err) 失败,
我多值赋给单变量拿到第一个返回值,误判为失败 → 改为 local _, serr = set(...)。
## WebUI(整个重写,对齐 ChannelCard 交互)
渠道网格(搜索/筛选全部·已接入·待接入/汇总条已接入N·账号总数)
→ 渠道详情(账号池列表:昵称/启用态/上次领取摘要 + 测试/刷新/停用/优先/删除;
模型折叠:逐个开关 + 全部启用/关闭 + 计数;添加账号=登录(占位账号先入池,
login 完成回填凭据,两步式对齐参考 account.create);一键领取额度(结果
摘要展示成功数+失败原因);供应商整体开关;自动签到开关;导出/导入备份)。
登录区仍覆盖 device-poll/callback/paste/manual/vnc 全部流程。
node --check 过内联 JS;凭据只经 complete_login 落保险库,不回显。
## 行为测试(channels_pool_test.go)
TestChannelsAccountPoolBehavior 5 个子测试全过:
账号增删改(含返回 account_id、改名、删空)
模型开关同步上游源(3模型→关1→2→全关→closed=true→全开恢复)
enabled开关同步上游源(停用删源、启用重建源)
供应商整体开关(关→无源+closed;开→源重建)
备份导出含凭据明文
## 已知边界(如实说明,未假装对齐)
- 余额逐账号展示:参考有 credits.balances(真实查上游余额),本环境各平台余额
端点形状差异大且多数需真实凭据,未编造假余额;账号行只显示上次领取摘要。
- usage.tokenLedger / cline.requestLog(用量账本/请求日志页):参考有,本插件
内核侧已有 usage 记录但未暴露给插件,暂缺这两页(其余控制台能力已对齐)。
回归:全量 8 包(core/lua/gateway/config/types/scheduler/provider/e2e)通过。
This commit is contained in:
196
internal/core/channels_pool_test.go
Normal file
196
internal/core/channels_pool_test.go
Normal file
@ -0,0 +1,196 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// channels_pool_test:对齐参考 account.* / model.* / provider.* 契约的行为测试。
|
||||
// 账号池增删改、上游源随 enabled/模型开关同步、供应商整体开关、备份导出。
|
||||
|
||||
func poolCore(t *testing.T) (*Core, *lua.Plugins) {
|
||||
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)
|
||||
}
|
||||
_ = ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "remove_source", "status", "probe_all", "reset_health"}, false)
|
||||
return c, ps
|
||||
}
|
||||
|
||||
// listSourcesViaKernel 直查内核 list_sources(actionListSources 返回 []map)。
|
||||
func listSourcesViaKernel(t *testing.T, c *Core) []map[string]interface{} {
|
||||
t.Helper()
|
||||
out, err := c.Invoke(nil, "list_sources", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("list_sources: %v", err)
|
||||
}
|
||||
if ms, ok := out.([]map[string]interface{}); ok {
|
||||
return ms
|
||||
}
|
||||
raw, _ := out.([]interface{})
|
||||
var srcs []map[string]interface{}
|
||||
for _, s := range raw {
|
||||
if m, ok := s.(map[string]interface{}); ok {
|
||||
srcs = append(srcs, m)
|
||||
}
|
||||
}
|
||||
return srcs
|
||||
}
|
||||
|
||||
func invoke(t *testing.T, ps *lua.Plugins, fn, body string) map[string]interface{} {
|
||||
t.Helper()
|
||||
out, err := ps.InvokeHandler("channels", fn, &lua.HandlerRequest{Method: "POST", Body: body})
|
||||
if err != nil {
|
||||
t.Fatalf("invoke %s: %v", fn, err)
|
||||
}
|
||||
return out.JSON.(map[string]interface{})
|
||||
}
|
||||
|
||||
// asArr 规整桥接后的数组。Lua 空表过桥是 map[string]interface{}(无法与空数组
|
||||
// 区分,见 vm.go luaTableToGoValue);内核 list_sources 的 models 过桥是 []string。
|
||||
func asArr(v interface{}) []interface{} {
|
||||
switch a := v.(type) {
|
||||
case []interface{}:
|
||||
return a
|
||||
case []string:
|
||||
out := make([]interface{}, len(a))
|
||||
for i, s := range a {
|
||||
out[i] = s
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func srcHasAccount(srcs []map[string]interface{}, needle string) (map[string]interface{}, bool) {
|
||||
for _, sm := range srcs {
|
||||
if name, _ := sm["name"].(string); strings.Contains(name, needle) {
|
||||
return sm, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// TestChannelsAccountPoolBehavior:账号池 CRUD + 上游源同步 + 模型/供应商开关 + 备份。
|
||||
func TestChannelsAccountPoolBehavior(t *testing.T) {
|
||||
c, ps := poolCore(t)
|
||||
|
||||
t.Run("账号增删改", func(t *testing.T) {
|
||||
m := invoke(t, ps, "account_add", `{"platform":"minimax","nickname":"我的号"}`)
|
||||
if m["ok"] != true {
|
||||
t.Fatalf("account_add: %v", m)
|
||||
}
|
||||
aid, _ := m["account_id"].(string)
|
||||
if aid == "" {
|
||||
t.Fatalf("account_add 应返回 account_id: %v", m)
|
||||
}
|
||||
m = invoke(t, ps, "account_list", `{"platform":"minimax"}`)
|
||||
accts := asArr(m["accounts"])
|
||||
if len(accts) != 1 {
|
||||
t.Fatalf("应有1个账号: %d", len(accts))
|
||||
}
|
||||
a := accts[0].(map[string]interface{})
|
||||
if a["nickname"] != "我的号" || a["enabled"] != true {
|
||||
t.Fatalf("账号字段: %v", a)
|
||||
}
|
||||
invoke(t, ps, "account_update", `{"platform":"minimax","account_id":"`+aid+`","patch":{"nickname":"改名"}}`)
|
||||
m = invoke(t, ps, "account_list", `{"platform":"minimax"}`)
|
||||
if asArr(m["accounts"])[0].(map[string]interface{})["nickname"] != "改名" {
|
||||
t.Fatalf("改名失败")
|
||||
}
|
||||
invoke(t, ps, "account_delete", `{"platform":"minimax","account_id":"`+aid+`"}`)
|
||||
m = invoke(t, ps, "account_list", `{"platform":"minimax"}`)
|
||||
if n := len(asArr(m["accounts"])); n != 0 {
|
||||
t.Fatalf("删除后应为0, 实得 %d", n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("模型开关同步上游源", func(t *testing.T) {
|
||||
m := invoke(t, ps, "account_add", `{"platform":"minimax","credential":"MM-KEY"}`)
|
||||
aid, _ := m["account_id"].(string)
|
||||
sm, ok := srcHasAccount(listSourcesViaKernel(t, c), aid)
|
||||
if !ok {
|
||||
t.Fatal("带凭据账号应已同步源")
|
||||
}
|
||||
if n := len(asArr(sm["models"])); n != 3 {
|
||||
t.Fatalf("源应有3模型, 实得 %d", n)
|
||||
}
|
||||
invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":true}`)
|
||||
sm, _ = srcHasAccount(listSourcesViaKernel(t, c), aid)
|
||||
if n := len(asArr(sm["models"])); n != 2 {
|
||||
t.Fatalf("关1模型后源应2模型, 实得 %d", n)
|
||||
}
|
||||
invoke(t, ps, "model_set_all_disabled", `{"platform":"minimax","disabled":true}`)
|
||||
m = invoke(t, ps, "provider_status", `{}`)
|
||||
for _, s := range asArr(m["statuses"]) {
|
||||
x := s.(map[string]interface{})
|
||||
if x["id"] == "minimax" && x["closed"] != true {
|
||||
t.Fatalf("全关模型后应 closed=true: %v", x)
|
||||
}
|
||||
}
|
||||
invoke(t, ps, "model_set_all_disabled", `{"platform":"minimax","disabled":false}`)
|
||||
})
|
||||
|
||||
t.Run("enabled开关同步上游源", func(t *testing.T) {
|
||||
m := invoke(t, ps, "account_list", `{"platform":"minimax"}`)
|
||||
accts := asArr(m["accounts"])
|
||||
if len(accts) == 0 {
|
||||
t.Fatal("上个用例应留有账号")
|
||||
}
|
||||
aid := accts[0].(map[string]interface{})["id"].(string)
|
||||
before := len(listSourcesViaKernel(t, c))
|
||||
invoke(t, ps, "account_update", `{"platform":"minimax","account_id":"`+aid+`","patch":{"enabled":false}}`)
|
||||
if n := len(listSourcesViaKernel(t, c)); n != before-1 {
|
||||
t.Fatalf("停用账号应删源: 前 %d 后 %d", before, n)
|
||||
}
|
||||
invoke(t, ps, "account_update", `{"platform":"minimax","account_id":"`+aid+`","patch":{"enabled":true}}`)
|
||||
if n := len(listSourcesViaKernel(t, c)); n != before {
|
||||
t.Fatalf("启用账号应重建源: 前 %d 后 %d", before, n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("供应商整体开关", func(t *testing.T) {
|
||||
invoke(t, ps, "provider_set_enabled", `{"platform":"minimax","enabled":false}`)
|
||||
if _, ok := srcHasAccount(listSourcesViaKernel(t, c), "minimax"); ok {
|
||||
t.Fatal("关闭渠道后不应有 minimax 源")
|
||||
}
|
||||
m := invoke(t, ps, "provider_status", `{}`)
|
||||
for _, s := range asArr(m["statuses"]) {
|
||||
x := s.(map[string]interface{})
|
||||
if x["id"] == "minimax" && x["closed"] != true {
|
||||
t.Fatalf("关闭后应 closed: %v", x)
|
||||
}
|
||||
}
|
||||
invoke(t, ps, "provider_set_enabled", `{"platform":"minimax","enabled":true}`)
|
||||
if _, ok := srcHasAccount(listSourcesViaKernel(t, c), "minimax"); !ok {
|
||||
t.Fatal("开启渠道后应有 minimax 源")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("备份导出含凭据", func(t *testing.T) {
|
||||
invoke(t, ps, "account_add", `{"platform":"cline","credential":"CL-KEY","nickname":"cl1"}`)
|
||||
exp := invoke(t, ps, "backup_export", `{}`)
|
||||
payload, _ := exp["payload"].(map[string]interface{})
|
||||
if payload == nil {
|
||||
t.Fatal("backup_export 应返回 payload")
|
||||
}
|
||||
creds, _ := payload["credentials"].(map[string]interface{})
|
||||
found := false
|
||||
for _, v := range creds {
|
||||
if v == "CL-KEY" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("备份应含凭据明文: %v", creds)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user