fix(channel): 热重载把新适配器写回能力注册表
面板保存渠道配置后 Bridge.Reload 只重建了桥接器自己的适配器表,能力注册表里的条目仍指向保存前的旧实例;而管理面的渠道列表、每账号连接状态、open/close 的目标解析全部读注册表 —— 于是卡片永远「未连接」,点「连接」驱动的是旧实例(按旧 URL 拨号)。 生产日志实证:保存后 bridge 侧新实例连上 ws://localhost:3001 且心跳正常,而点连接时被驱动的适配器拨的是旧地址 ws://localhost:10301。 改法:注册表新增 upsert 语义的 ReplaceChannel(覆盖 Value、保留运行期 Policy;Register 遇重名会 ErrDuplicate,热重载必然同名);桥接器经窄端口在适配器上线后同步,账号被移除时删除条目;组合根注入 a.caps 包装。同步失败只记日志、不中断 Reload;未接线时保持旧行为并只 WARN 一次。 验收:go build ./... + go vet 全绿;registry/channelbridge/gateway 三包 test 全绿;主会话亲自变异(摘掉 Policy 沿用 ⇒ TestReplaceChannelSwapsValueAndKeepsPolicy 红)。linux/darwin 交叉编译通过。
This commit is contained in:
1 parent
23b6e3fe1e
commit
d81635672e
6 files changed
+560
-6
No files matched your search
@@ -301,6 +301,57 @@ func (r *Registry) RegisterChannel(c sdk.Channel) error {
|
||||
})
|
||||
}
|
||||
|
||||
// ReplaceChannel 用**同名**的 c 就地更新已登记的渠道条目(热重载,design/04 §3.1)。
|
||||
//
|
||||
// 为什么必须是替换而不是 Register:渠道热重载**必然同名**(同名账号的账号名由
|
||||
// channels.json 决定,重载前后不变),而 Register 遇重名返回 ErrDuplicate,会把
|
||||
// 新适配器挡在门外 —— 注册表于是继续指向旧实例,管理面板照旧按旧配置读写它的状态
|
||||
// (显示「未连接」、点「连接」拨的是旧地址)。
|
||||
//
|
||||
// 语义是 **upsert**,与 ReplaceTool 有意不同(工具的窄方法是「只换现有这把」):
|
||||
// - 名字不存在 ⇒ 按 RegisterChannel 同口径新增(账号被新加进配置后,热重载是它
|
||||
// 第一次出现在注册表里的时机,没有别处会替它注册);
|
||||
// - 名字已存在且 Kind == KindChannel ⇒ 覆盖 Value(指向新适配器),**保留既有
|
||||
// Policy** —— 分组/陌生人的可达标记是运行期可编辑配置(design/11 §3),不属于
|
||||
// 适配器的构造产出,热重载把它清掉等于「面板保存一次,某个群忽然不能用了」;
|
||||
// - 名字已存在但 Kind != KindChannel ⇒ 报错,绝不悄悄改掉那条能力的类型。
|
||||
func (r *Registry) ReplaceChannel(c sdk.Channel) error {
|
||||
if c == nil || c.Name() == "" {
|
||||
return fmt.Errorf("registry: replace channel: %w", ErrInvalidArgument)
|
||||
}
|
||||
name := c.Name()
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
cur, ok := r.caps[name]
|
||||
if !ok {
|
||||
// 新增分支与 Register 同口径:落盘策略覆盖初始标记(DB 是策略的权威载体)。
|
||||
added := Capability{
|
||||
Name: name,
|
||||
Kind: KindChannel,
|
||||
Description: name,
|
||||
Value: c,
|
||||
}
|
||||
if pol, ok := r.persisted[name]; ok {
|
||||
added.Policy = pol
|
||||
}
|
||||
r.caps[name] = added
|
||||
return nil
|
||||
}
|
||||
if cur.Kind != KindChannel {
|
||||
return fmt.Errorf("registry: replace %q: 既有能力类型是 %q,不是渠道: %w", name, cur.Kind, ErrInvalidArgument)
|
||||
}
|
||||
r.caps[name] = Capability{
|
||||
Name: name,
|
||||
Kind: KindChannel,
|
||||
// Description 与 RegisterChannel 一致地取适配器名(面板按注册名精确匹配)。
|
||||
Description: name,
|
||||
// Policy 沿用旧条目:它是可编辑的运行期配置,不属于适配器的构造产出。
|
||||
Policy: cur.Policy,
|
||||
Value: c,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validKind reports whether k is one of the known capability kinds.
|
||||
func validKind(k Kind) bool {
|
||||
switch k {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// 本文件钉住渠道热重载的窄方法 `ReplaceChannel`(upsert,design/04 §3.1):
|
||||
// 热重载重建出来的适配器**必然同名**,Register 会 ErrDuplicate 把新实例挡在门外,
|
||||
// 注册表于是继续指向旧实例——管理面板显示「未连接」、点「连接」驱动的是旧实例。
|
||||
|
||||
// stubChannel 是名字可指定、连接状态可变的最小渠道(只用于注册/替换的判定)。
|
||||
type stubChannel struct {
|
||||
name string
|
||||
up bool
|
||||
}
|
||||
|
||||
func (s *stubChannel) Name() string { return s.name }
|
||||
func (s *stubChannel) Connect(context.Context) error { s.up = true; return nil }
|
||||
func (s *stubChannel) Disconnect(context.Context) error { s.up = false; return nil }
|
||||
func (s *stubChannel) IsConnected() bool { return s.up }
|
||||
func (s *stubChannel) HealthCheck(context.Context) error { return nil }
|
||||
|
||||
var _ sdk.Channel = (*stubChannel)(nil)
|
||||
|
||||
// TestReplaceChannelSwapsValueAndKeepsPolicy 是热重载的主判据:
|
||||
// - 同名条目换成了**新实例**(调用方随后 Connect/IsConnected 驱动的就是新的那一把);
|
||||
// - **Policy 原样保留** —— 分组/陌生人可达标记是运行期配置,不属于适配器实现;
|
||||
// - Kind/Description 与 RegisterChannel 同口径。
|
||||
func TestReplaceChannelSwapsValueAndKeepsPolicy(t *testing.T) {
|
||||
const name = "qq-obv11:9616b27ae314253c54419b7541538cdc"
|
||||
r := New()
|
||||
old := &stubChannel{name: name, up: true}
|
||||
if err := r.RegisterChannel(old); err != nil {
|
||||
t.Fatalf("RegisterChannel: %v", err)
|
||||
}
|
||||
if err := r.SetAllowInGroup(name, true); err != nil {
|
||||
t.Fatalf("SetAllowInGroup: %v", err)
|
||||
}
|
||||
|
||||
next := &stubChannel{name: name, up: true}
|
||||
// 先钉住「为什么必须是替换而不是 Register」:同名注册必被拒。
|
||||
if err := r.RegisterChannel(next); !errors.Is(err, ErrDuplicate) {
|
||||
t.Fatalf("同名的 RegisterChannel err = %v, want ErrDuplicate(热重载必然同名)", err)
|
||||
}
|
||||
if err := r.ReplaceChannel(next); err != nil {
|
||||
t.Fatalf("ReplaceChannel: %v", err)
|
||||
}
|
||||
|
||||
cap, err := r.Get(name)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
// 判据是接口值同一性:新旧实例的 Name() 完全相同,比名字必然假绿。
|
||||
if cap.Value != sdk.Channel(next) {
|
||||
t.Fatalf("替换后 Value = %#v, want 新实例", cap.Value)
|
||||
}
|
||||
if cap.Value == sdk.Channel(old) {
|
||||
t.Fatal("替换后仍指向旧实例:面板会显示未连接、点「连接」驱动旧实例")
|
||||
}
|
||||
if !cap.Policy.AllowInGroup {
|
||||
t.Fatal("替换丢掉了 AllowInGroup:运行期策略不属于适配器构造产出,必须沿用")
|
||||
}
|
||||
if cap.Kind != KindChannel {
|
||||
t.Fatalf("替换后 Kind = %q, want %q", cap.Kind, KindChannel)
|
||||
}
|
||||
if cap.Description != name {
|
||||
t.Fatalf("替换后 Description = %q, want %q(与 RegisterChannel 同口径)", cap.Description, name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplaceChannelRegistersMissingName:名字不存在时按 RegisterChannel 同口径新增
|
||||
// —— 新加进配置的账号,热重载是它第一次出现在注册表里的时机,没有别处替它注册。
|
||||
func TestReplaceChannelRegistersMissingName(t *testing.T) {
|
||||
const name = "kook:conf0"
|
||||
r := New()
|
||||
ch := &stubChannel{name: name, up: true}
|
||||
if err := r.ReplaceChannel(ch); err != nil {
|
||||
t.Fatalf("ReplaceChannel: %v", err)
|
||||
}
|
||||
cap, err := r.Get(name)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if cap.Value != sdk.Channel(ch) {
|
||||
t.Fatalf("新增后 Value = %#v, want 该适配器", cap.Value)
|
||||
}
|
||||
if cap.Kind != KindChannel || cap.Description != name {
|
||||
t.Fatalf("新增条目形状 = %q/%q, want %q/%q", cap.Kind, cap.Description, KindChannel, name)
|
||||
}
|
||||
// 面板按 Kind 枚举渠道卡片,新条目必须能列出来。
|
||||
listed := r.List(KindChannel)
|
||||
if len(listed) != 1 || listed[0].Name != name {
|
||||
t.Fatalf("List(KindChannel) = %#v, want 仅 %q", listed, name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplaceChannelUpsertHonorsPersistedPolicy 钉住新增分支与 Register 的同口径性:
|
||||
// DB 是策略的权威载体(裁决 10.4.2),已落盘的策略要覆盖新条目的零值。
|
||||
func TestReplaceChannelUpsertHonorsPersistedPolicy(t *testing.T) {
|
||||
r := New()
|
||||
r.persisted = map[string]ToolPolicy{"kook": {AllowInGroup: true}}
|
||||
if err := r.ReplaceChannel(&stubChannel{name: "kook"}); err != nil {
|
||||
t.Fatalf("ReplaceChannel: %v", err)
|
||||
}
|
||||
cap, err := r.Get("kook")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if !cap.Policy.AllowInGroup {
|
||||
t.Fatal("新增分支丢掉了已落盘的策略:与 Register 的口径不一致")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplaceChannelRefusesNonChannelEntry:同名条目不是渠道时拒绝,绝不悄悄改掉它的 Kind。
|
||||
func TestReplaceChannelRefusesNonChannelEntry(t *testing.T) {
|
||||
r := New()
|
||||
if err := r.Register(Capability{Name: "kook", Kind: KindMCP, Value: struct{}{}}); err != nil {
|
||||
t.Fatalf("Register: %v", err)
|
||||
}
|
||||
err := r.ReplaceChannel(&stubChannel{name: "kook"})
|
||||
if !errors.Is(err, ErrInvalidArgument) {
|
||||
t.Fatalf("err = %v, want ErrInvalidArgument", err)
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), string(KindMCP)) {
|
||||
t.Fatalf("错误消息 %q 里应指出既有类型 %q", err, KindMCP)
|
||||
}
|
||||
cap, gerr := r.Get("kook")
|
||||
if gerr != nil {
|
||||
t.Fatalf("Get: %v", gerr)
|
||||
}
|
||||
if cap.Kind != KindMCP {
|
||||
t.Fatalf("被拒的替换却改了 Kind:%q", cap.Kind)
|
||||
}
|
||||
if _, ok := cap.Value.(*stubChannel); ok {
|
||||
t.Fatal("被拒的替换却改了 Value")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplaceChannelRejectsNilAndEmptyName:两个不该被静默吞掉的调用错误。
|
||||
func TestReplaceChannelRejectsNilAndEmptyName(t *testing.T) {
|
||||
r := New()
|
||||
if err := r.ReplaceChannel(nil); !errors.Is(err, ErrInvalidArgument) {
|
||||
t.Fatalf("替换成 nil err = %v, want ErrInvalidArgument", err)
|
||||
}
|
||||
if err := r.ReplaceChannel(&stubChannel{}); !errors.Is(err, ErrInvalidArgument) {
|
||||
t.Fatalf("替换成空名 err = %v, want ErrInvalidArgument", err)
|
||||
}
|
||||
if got := r.List(""); len(got) != 0 {
|
||||
t.Fatalf("非法入参不得写进注册表,实际写入 %#v", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user