Merge branch 'feat/ownergate-owner-lane-gate-plus-conversat' into dev
This commit is contained in:
commit
20051f0168
14 files changed
+1049
-30
No files matched your search
@@ -0,0 +1,211 @@
|
||||
// channels_ownerlane.go 承载 owner 车道结构化旋钮的**落盘面**(design/18 §11
|
||||
// 「乙 = C」,主人 2026-10-13 裁决):主人在群里说「少说点」这类诉求不再靠她自己记,
|
||||
// 而是被写成一份可读可改的每群配置(默认值 + 每群覆盖),面板后续照这份形状写入。
|
||||
//
|
||||
// 它落在 $DATA_DIR/config/owner_lane.json(与 backthink.json 同一目录口径、同一
|
||||
// 「.env 冻结 ⇒ 新配置走 $DATA_DIR/config/**」纪律,design/13 §2.1),**不进 .env**。
|
||||
// 本文件是纯存储层:它只负责读写与默认值归一,**不做任何行为判定** ——
|
||||
// 「说不说 / 多频繁」的判据在 channelbridge/ownerlane.go,身份权限仍只由 actor 元数据决定。
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
// ownerLaneConfigSubdir 与 backthink.json 共用 $DATA_DIR 下的 config 子目录口径。
|
||||
ownerLaneConfigSubdir = "config"
|
||||
// ownerLaneFileName 是 owner 车道旋钮的落盘文件名。
|
||||
ownerLaneFileName = "owner_lane.json"
|
||||
// ownerLaneLogSource 是本模块日志的来源标记,使「旋钮被谁改成什么」跨模块可定位
|
||||
// (项目规范 §五:禁止无来源裸日志)。
|
||||
ownerLaneLogSource = "config/runtimeconfig/ownerlane"
|
||||
)
|
||||
|
||||
// DefaultOwnerLanePath 返回 owner 车道旋钮文件在 dataDir 下的路径。
|
||||
func DefaultOwnerLanePath(dataDir string) string {
|
||||
return filepath.Join(dataDir, ownerLaneConfigSubdir, ownerLaneFileName)
|
||||
}
|
||||
|
||||
// OwnerLanePolicy 是 owner 车道的一档参与策略(design/18 §11 的可调旋钮):
|
||||
// 它**只**影响「说不说 / 多频繁」,绝不动权限、工具面或 actor 元数据(AGENTS §9)。
|
||||
//
|
||||
// 零值 = 最保守的一档(只回指向她的消息),也就是裁决「甲」的默认口径。
|
||||
type OwnerLanePolicy struct {
|
||||
// ReplyToUnaddressed 是「说不说」开关:true 时主人**非指向她**的群消息也参与回复;
|
||||
// false(默认)时只回指向她的(@她 / @全体 / 未接线渠道的旧判据)。
|
||||
ReplyToUnaddressed bool `json:"replyToUnaddressed"`
|
||||
// MinReplyGapSec 是「多频繁」:owner 车道两条**非指向她**的回复之间的最小间隔(秒)。
|
||||
// 0 = 不额外限制。指向她的消息一律必回,**不受本字段约束**(硬保留)。
|
||||
MinReplyGapSec int `json:"minReplyGapSec"`
|
||||
}
|
||||
|
||||
// DefaultOwnerLanePolicy 返回裁决「甲」的默认口径:不对她说就不开口。
|
||||
func DefaultOwnerLanePolicy() OwnerLanePolicy {
|
||||
return OwnerLanePolicy{}
|
||||
}
|
||||
|
||||
// OwnerLaneFile 是 owner_lane.json 的形状:一个全局 default(可被面板改)
|
||||
// 加一份每群覆盖表。键见 OwnerLaneGroupKey。
|
||||
type OwnerLaneFile struct {
|
||||
Default OwnerLanePolicy `json:"default"`
|
||||
Groups map[string]OwnerLanePolicy `json:"groups,omitempty"`
|
||||
}
|
||||
|
||||
// OwnerLaneGroupKey 是每群覆盖表的键:平台 + 群 id。群 id 为空表示**全局默认**那一档
|
||||
// (面板把「所有群」的旋钮写进 default 时用它)。
|
||||
func OwnerLaneGroupKey(platform, groupID string) string {
|
||||
return platform + "|" + groupID
|
||||
}
|
||||
|
||||
// OwnerLaneStore 是 owner 车道旋钮的读写面:内存缓存 + 落盘。它可安全并发使用。
|
||||
// 读缺失文件、坏文件都退回默认值(绝不因为配置坏了而阻断入站),但会留一条带来源的 WARN。
|
||||
type OwnerLaneStore struct {
|
||||
path string
|
||||
log *slog.Logger
|
||||
mu sync.RWMutex
|
||||
cfg OwnerLaneFile
|
||||
}
|
||||
|
||||
// NewOwnerLaneStore 构造 store 并尽力加载已有文件。path 为空或文件缺失时走默认值,
|
||||
// 不返回错误:配置坏了只影响旋钮取值,绝不该拦住渠道启动。
|
||||
func NewOwnerLaneStore(path string, log *slog.Logger) *OwnerLaneStore {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
s := &OwnerLaneStore{
|
||||
path: path,
|
||||
log: logger.WithSource(log, ownerLaneLogSource),
|
||||
cfg: OwnerLaneFile{Default: DefaultOwnerLanePolicy(), Groups: map[string]OwnerLanePolicy{}},
|
||||
}
|
||||
s.load()
|
||||
return s
|
||||
}
|
||||
|
||||
// load 尽力从磁盘读入配置。文件不存在是正常情况(首次运行),静默走默认值;
|
||||
// 其它读/解析错误留一条带来源的 WARN 后仍走默认值。
|
||||
func (s *OwnerLaneStore) load() {
|
||||
if s.path == "" {
|
||||
return
|
||||
}
|
||||
raw, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
s.log.Warn("owner lane config unreadable, using defaults", "path", s.path, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
var f OwnerLaneFile
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
s.log.Warn("owner lane config invalid, using defaults", "path", s.path, "error", err)
|
||||
return
|
||||
}
|
||||
if f.Groups == nil {
|
||||
f.Groups = map[string]OwnerLanePolicy{}
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.cfg = f
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// PolicyFor 返回某个 (platform, groupID) 生效的策略:有每群覆盖就用它,否则用全局
|
||||
// default(default 缺省即「甲」)。它不返回「未设置」,因此调用方永远拿到一个可用的档位。
|
||||
func (s *OwnerLaneStore) PolicyFor(platform, groupID string) OwnerLanePolicy {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if p, ok := s.cfg.Groups[OwnerLaneGroupKey(platform, groupID)]; ok {
|
||||
return p
|
||||
}
|
||||
return s.cfg.Default
|
||||
}
|
||||
|
||||
// Snapshot 返回配置的一份拷贝,供面板/测试读取(改动它不影响 store)。
|
||||
func (s *OwnerLaneStore) Snapshot() OwnerLaneFile {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := OwnerLaneFile{Default: s.cfg.Default, Groups: make(map[string]OwnerLanePolicy, len(s.cfg.Groups))}
|
||||
for k, v := range s.cfg.Groups {
|
||||
out.Groups[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Set 写入某个 (platform, groupID) 的策略并落盘。groupID 为空表示写全局 default 档。
|
||||
//
|
||||
// 内存改动与**落盘都在同一把锁内**(review p1):并发 Set 因此完全串行 —— 既不会两个
|
||||
// 写者去写同一个临时文件把 JSON 写坏,也不会因为原子 rename 的完成先后与各快照的新旧
|
||||
// 不一致而丢掉「后发生的那次更新」。落盘量是一个小 JSON,持锁期间的 I/O 代价可忽略;
|
||||
// 读侧(PolicyFor)读的是内存、不碰文件,因此不会被这次落盘抖动。
|
||||
//
|
||||
// 内存先改、失败返回错误(调用方留可见 WARN),但内存里的新档位仍然生效 ——
|
||||
// 「改不动磁盘」不该让已经做出的行为决定回退。
|
||||
func (s *OwnerLaneStore) Set(platform, groupID string, p OwnerLanePolicy) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if groupID == "" {
|
||||
s.cfg.Default = p
|
||||
} else {
|
||||
if s.cfg.Groups == nil {
|
||||
s.cfg.Groups = map[string]OwnerLanePolicy{}
|
||||
}
|
||||
s.cfg.Groups[OwnerLaneGroupKey(platform, groupID)] = p
|
||||
}
|
||||
if err := s.saveLocked(); err != nil {
|
||||
return fmt.Errorf("runtimeconfig: save owner lane config %s: %w", s.path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveLocked 原子写出当前配置。调用方**必须持有 s.mu**。
|
||||
//
|
||||
// 它每次都用**新建的临时文件**(`os.CreateTemp`,仓库同款先例见 internal/user/save.go)
|
||||
// 再原子 rename:即便有别的进程/goroutine 也在写同一个目录,也不会撞同一个临时文件名,
|
||||
// 而半份文件永远不会以 s.path 的名字出现。
|
||||
func (s *OwnerLaneStore) saveLocked() error {
|
||||
if s.path == "" {
|
||||
return nil
|
||||
}
|
||||
dir := filepath.Dir(s.path)
|
||||
if dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir: %w", err)
|
||||
}
|
||||
}
|
||||
raw, err := json.MarshalIndent(s.cfg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, filepath.Base(s.path)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp in %s: %w", dir, err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
// CreateTemp 以 0600 打开;显式再 chmod 一次,使落盘文件不因 umask 变化而更宽。
|
||||
// 任一步失败都删掉临时文件,绝不留垃圾。
|
||||
if err := tmp.Chmod(0o600); err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmpName)
|
||||
return fmt.Errorf("chmod %s: %w", tmpName, err)
|
||||
}
|
||||
if _, err := tmp.Write(raw); err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmpName)
|
||||
return fmt.Errorf("write %s: %w", tmpName, err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
return fmt.Errorf("close %s: %w", tmpName, err)
|
||||
}
|
||||
if err := os.Rename(tmpName, s.path); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
return fmt.Errorf("rename: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestOwnerLaneStoreDefaultsWhenFileMissing 钉住默认档(裁决「甲」):
|
||||
// 文件不存在时读到的就是最保守的一档 —— 不对她说就不开口,且没有额外频率闸。
|
||||
func TestOwnerLaneStoreDefaultsWhenFileMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config", "owner_lane.json")
|
||||
s := NewOwnerLaneStore(path, nil)
|
||||
|
||||
got := s.PolicyFor("qq-obv11", "672432767")
|
||||
if got.ReplyToUnaddressed || got.MinReplyGapSec != 0 {
|
||||
t.Fatalf("默认档 = %+v, want 零值(不对她说就不开口)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerLaneStoreSetPersistsAndReloads 钉住「可读可改」:Set 落盘后,新建的
|
||||
// store 能读回同一个每群覆盖;未覆盖的群仍走全局 default。
|
||||
func TestOwnerLaneStoreSetPersistsAndReloads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config", "owner_lane.json")
|
||||
s := NewOwnerLaneStore(path, nil)
|
||||
want := OwnerLanePolicy{ReplyToUnaddressed: true, MinReplyGapSec: 120}
|
||||
if err := s.Set("qq-obv11", "672432767", want); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("owner_lane.json 未落盘:%v", err)
|
||||
}
|
||||
|
||||
reloaded := NewOwnerLaneStore(path, nil)
|
||||
if got := reloaded.PolicyFor("qq-obv11", "672432767"); got != want {
|
||||
t.Fatalf("重载后该群策略 = %+v, want %+v", got, want)
|
||||
}
|
||||
// 未被覆盖的群仍走全局 default(零值)。
|
||||
if got := reloaded.PolicyFor("qq-obv11", "another-group"); got != DefaultOwnerLanePolicy() {
|
||||
t.Fatalf("未覆盖群策略 = %+v, want 默认档", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerLaneStoreGlobalDefault 钉住面板可改的「全局 default」那一档:
|
||||
// groupID 为空即写默认,所有未单独覆盖的群都跟着变。
|
||||
func TestOwnerLaneStoreGlobalDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := NewOwnerLaneStore(filepath.Join(t.TempDir(), "config", "owner_lane.json"), nil)
|
||||
global := OwnerLanePolicy{ReplyToUnaddressed: true, MinReplyGapSec: 60}
|
||||
if err := s.Set("", "", global); err != nil {
|
||||
t.Fatalf("Set default: %v", err)
|
||||
}
|
||||
if got := s.PolicyFor("telegram", "-20"); got != global {
|
||||
t.Fatalf("全局默认生效值 = %+v, want %+v", got, global)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerLaneStoreCorruptFileFallsBackToDefaults 钉住「配置坏了也不阻断」:
|
||||
// 畸形文件只退回默认档并留 WARN,绝不 panic、绝不返回错误阻断渠道启动。
|
||||
func TestOwnerLaneStoreCorruptFileFallsBackToDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config", "owner_lane.json")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("{ this is not json "), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
s := NewOwnerLaneStore(path, nil)
|
||||
if got := s.PolicyFor("qq-obv11", "g"); got != DefaultOwnerLanePolicy() {
|
||||
t.Fatalf("坏配置下策略 = %+v, want 默认档", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerLaneStoreConcurrentSetKeepsAllUpdates 钉住并发 Set 的安全性(review p1):
|
||||
// 落盘必须与内存改动**同一把锁**、且用**新建的临时文件**,否则两个写者会撞同一个
|
||||
// tmp 名把 JSON 写坏(⇒ load 退回默认「甲」、主人刚说的「少说点」白说且无提示),
|
||||
// 或者 rename 的完成顺序与新快照的先后不一致,丢掉后发生的更新。
|
||||
//
|
||||
// 修前必红(原实现:固定 `path+".tmp"` + mu.Unlock() 之后落盘),断言:
|
||||
// ① 每一轮结束后文件都是**合法 JSON**;
|
||||
// ② 本轮写过的**每一个**键都能在最终文件里读到(不丢更新)。
|
||||
func TestOwnerLaneStoreConcurrentSetKeepsAllUpdates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config", "owner_lane.json")
|
||||
s := NewOwnerLaneStore(path, nil)
|
||||
|
||||
const (
|
||||
rounds = 24
|
||||
writers = 24
|
||||
)
|
||||
for round := 0; round < rounds; round++ {
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < writers; i++ {
|
||||
key := fmt.Sprintf("g-%d-%d", round, i)
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
// 写者的返回值故意不看:修前它会因 tmp 被别的写者 rename 走而报错,
|
||||
// 但那正是本用例要靠**文件内容**抓住的失败。
|
||||
_ = s.Set("qq-obv11", key, OwnerLanePolicy{ReplyToUnaddressed: true, MinReplyGapSec: i})
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("round %d: 读回 owner_lane.json 失败:%v", round, err)
|
||||
}
|
||||
var f OwnerLaneFile
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
t.Fatalf("round %d: 并发落盘把配置写坏了(文件不是合法 JSON):%v\n内容=%q", round, err, raw)
|
||||
}
|
||||
for i := 0; i < writers; i++ {
|
||||
key := OwnerLaneGroupKey("qq-obv11", fmt.Sprintf("g-%d-%d", round, i))
|
||||
if _, ok := f.Groups[key]; !ok {
|
||||
t.Fatalf("round %d: 丢更新 —— 键 %s 不在最终文件里(并发的某次 Set 没落盘)", round, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user