fix(channel,user): owner 车道只认白名单,堵住群主/伪造 role 冒充主人(裁决 06.4.2/06.4.3/06.4.4)

- 适配器只描述平台身份:qq_obv11 的 owner/admin 一律上报 RoleMember,不再上报 RoleOwner
- session 层唯一判主点只认白名单(owner.yaml Accounts ∪ 每渠道 adminIds),nil 档案 fail-closed 拒绝
- 新增「每渠道管理员层」:channels.json ChannelAccount.adminIds[] → runtimeconfig.AdminIDs → OwnerProfile.SetPlatformAdmins(整体替换语义,热重载后移除的 id 立即失效)
- 组合根接线:启动即播种、面板保存先灌白名单再重载适配器(cmd/cyrene/wire_channels.go + 4 条端到端单测)
- 06.4.3 节流与参与度状态按会话独立计数(ConversationKey 三处统一,空会话 GC);06.4.4 未知平台主动发言保守默认 1 条/分钟
This commit is contained in:
AskaEth committed 2026-10-04 12:07:40 +08:00
1 parent 7e7f53bb41
commit af8591b78a
30 files changed
+1289 -144

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
package user
// 本文件覆盖裁决 06.4.2 的「每渠道账号配置的管理员 id 列表」白名单层:它是 channels.json
// 的 adminIds[](design/08 §1.1)在内存里的落点,与 owner.yaml 的账号白名单取并集后共同
// 决定 owner 车道,且**不写回 owner.yaml**。
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestOwnerProfileSetPlatformAdmins(t *testing.T) {
t.Parallel()
p := &OwnerProfile{ID: "owner", DisplayName: "主人"}
p.SetPlatformAdmins("qq-obv11", []string{"10001", " 10002 ", "10001", " "})
ids := p.PlatformAdmins("qq-obv11")
if len(ids) != 2 || ids[0] != "10001" || ids[1] != "10002" {
t.Fatalf("PlatformAdmins = %v, want [10001 10002](去空白 + 去重)", ids)
}
for _, id := range ids {
if !p.IsOwner("qq-obv11", id) {
t.Errorf("adminIds 命中的 %q 未判为主人", id)
}
}
if p.IsOwner("qq-obv11", "10003") {
t.Error("未声明的账号被判为主人")
}
if p.IsOwner("telegram", "10001") {
t.Error("管理员 id 层跨平台串台了(应按平台隔离)")
}
if p.IsOwner("qq-obv11", "") {
t.Error("空 accountId 不得命中白名单(fail-closed)")
}
// 整体替换:重新声明后旧 id 立即失效,channels.json 热重载不会留下悬挂管理员。
p.SetPlatformAdmins("qq-obv11", []string{"10002"})
if p.IsOwner("qq-obv11", "10001") {
t.Error("SetPlatformAdmins 未整体替换")
}
// 空列表(含只有空白的列表)清空该平台的这一层。
p.SetPlatformAdmins("qq-obv11", []string{" "})
if p.IsOwner("qq-obv11", "10002") || p.PlatformAdmins("qq-obv11") != nil {
t.Error("空列表应清空该平台的管理员层")
}
}
func TestOwnerProfileAdminLayerConcurrentWithOwnerYaml(t *testing.T) {
t.Parallel()
// 两层来源共存:owner.yaml 的账号白名单与 channels.json 的 adminIds 都能判主。
p := &OwnerProfile{
ID: "owner",
DisplayName: "主人",
Accounts: []OwnerAccount{{Platform: "kook", AccountID: "329284282"}},
}
p.SetPlatformAdmins("kook", []string{"555"})
if !p.IsOwner("kook", "329284282") || !p.IsOwner("kook", "555") {
t.Fatalf("两层白名单未取并集: yaml=%v adminIds=%v",
p.IsOwner("kook", "329284282"), p.IsOwner("kook", "555"))
}
}
func TestOwnerProfileAdminLayerNotPersisted(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "owner.yaml")
p := &OwnerProfile{ID: "owner", DisplayName: "主人"}
p.RegisterAccount("kook", "329284282")
p.SetPlatformAdmins("qq-obv11", []string{"10001"})
if err := SaveOwnerProfile(path, p); err != nil {
t.Fatalf("SaveOwnerProfile: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
if strings.Contains(string(data), "10001") {
t.Error("adminIds 层被写回了 owner.yaml(应为只读来源 channels.json)")
}
reloaded, err := LoadOwnerProfile(path)
if err != nil {
t.Fatalf("LoadOwnerProfile: %v", err)
}
if !reloaded.IsOwner("kook", "329284282") {
t.Error("owner.yaml 的账号白名单应在重载后保留")
}
if reloaded.IsOwner("qq-obv11", "10001") {
t.Error("adminIds 层不应来自 owner.yaml")
}
}