feat(events): 渠道事件桥接接线 + 组合根(design/26 E3+E4)
- E3:channelbridge/events.go 唯一插入点 recordEvent(两轴映射 Category→Tier、摘要恒过 SanitizeNoticeText、角色/账号只认白名单)+ Options.EventStore 注入 - 未接线 store=nil ⇒ 内存计数 + 首次 DEBUG,绝不中断入站(变异实测:改成直接调 Record ⇒ panic,测试红) - 普通聊天消息零副作用(变异:去掉 Applicable 早退 ⇒ rows=1 红) - T1 遥测只计数不落行(变异:心跳误判 T2 ⇒ 「逐条落行 1 行,want 0」红) - E4:wire_channel_events.go(启动期清扫 + 双 ticker + 退出 flush,逐字对齐 wire_document_gc 的三条取舍)、wire_channels 注入、main.go 加一行 startChannelEvents - internal/config/duration_env.go:新 env 键的读取口径(进程环境 > .env > 默认;「没写」与「写了空」都当没配;非法值回落 + 报错)——避免「只写在 .env 里被静默忽略」 - 三个新 env 键:CHANNEL_EVENT_RETENTION / GC_INTERVAL / COUNTER_FLUSH(模板同批更新,测试钉住键名一致) - 插入点比设计示意图晚一步(放 preEntryReview 之后,只归一化一次且 NoticeClass 已有结论),「留存无条件、在任何早退之前」这条性质不变
This commit is contained in:
1 parent
7aaf7731dc
commit
afe9afb1b5
12 files changed
+1689
-2
No files matched your search
@@ -0,0 +1,48 @@
|
||||
package config
|
||||
|
||||
// 本文件承载「由**组合根**直接消费的 duration 型 env 键」的读取口径(切片 E3+E4 的
|
||||
// `CHANNEL_EVENT_*` 三个键是当前唯一调用方,design/26 §4.4)。
|
||||
//
|
||||
// 为什么是读取函数,而不是给 Config 加字段:
|
||||
//
|
||||
// - Config 的反射解码只给出**值本身**,承载不了「未配置(用默认)」与「显式 ≤0
|
||||
// (关闭)」必须分开的三态语义 —— 而渠道事件 GC 的关闭判据正是 ≤0(design/26 §4.4
|
||||
// 写死「≤0 = 关闭」)。若用零值代表「未配置」,一次漏配就会把清扫关掉;
|
||||
// - 直接 `os.Getenv` 会让**只写在 `.env` 里**的声明被静默忽略:`.env` 由 parseDotEnv
|
||||
// 读进独立 map,并不写回进程环境(与 LoadContextBudgetRatio / LoadOwnerIdentityInjection
|
||||
// 同一理由,「配了却不生效」是被本项目点名禁止的失效模式)。
|
||||
//
|
||||
// 因此这里复用**同一套优先级**(进程环境 > `.env` > 默认值)与**同一份 `.env` 解析**,
|
||||
// 两种写法都真实生效。envPath 为空表示不看 `.env`(与 Load 的约定一致)。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LoadDurationEnv 读取一个 duration 型 env 键(Go duration 文本,如 `30s` / `1h` /
|
||||
// `720h`,接受负值),按「进程环境 > .env > def」取值。键未配置或值为空白(`KEY=`)
|
||||
// 时一律返回 def —— 「没写」与「写了空」都当没配,绝不把空串当成一个合法取值。
|
||||
//
|
||||
// 返回的 error **只承载「配了但读不懂」**:此时返回值仍是 def(调用方据此回落),
|
||||
// 但必须记一条带原始取值的 WARN 并继续 —— 非法配置不许静默失效(AGENTS §10)。
|
||||
func LoadDurationEnv(envPath, key string, def time.Duration) (time.Duration, error) {
|
||||
fromEnv := map[string]string{}
|
||||
if envPath != "" {
|
||||
m, err := parseDotEnv(envPath)
|
||||
if err != nil {
|
||||
return def, fmt.Errorf("config: read %s: %w", envPath, err)
|
||||
}
|
||||
fromEnv = m
|
||||
}
|
||||
raw := strings.TrimSpace(lookupRaw(key, "", fromEnv))
|
||||
if raw == "" {
|
||||
return def, nil
|
||||
}
|
||||
value, err := time.ParseDuration(raw)
|
||||
if err != nil {
|
||||
return def, fmt.Errorf("config: %s=%q 不是合法的 duration(如 30s / 1h / 720h): %w", key, raw, err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package config
|
||||
|
||||
// 本文件覆盖 LoadDurationEnv(切片 E3+E4 的 CHANNEL_EVENT_* 读取口径):三条判据
|
||||
// 各自的用例 —— **未配置回落默认**、**`.env` 只写在文件里也真实生效**(这正是不能用
|
||||
// os.Getenv 的原因)、**配了但读不懂 ⇒ 回落默认 + 报错**(绝不静默失效)。
|
||||
//
|
||||
// 与 owner_identity_test.go 同一约定:读进程环境 / 写 .env 的用例**不并行**
|
||||
// (unsetEnvForTest 会改动进程环境,与并行读取互相干扰)。
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestLoadDurationEnv_未配置回落默认_正常:键既不在进程环境也不在 .env ⇒ 返回默认值、
|
||||
// 不报错;值为空白(`KEY=`)同样按「没配」处理。
|
||||
func TestLoadDurationEnv_未配置回落默认_正常(t *testing.T) {
|
||||
const key = "CYRENE_TEST_DURATION_UNSET"
|
||||
unsetEnvForTest(t, key)
|
||||
|
||||
value, err := LoadDurationEnv("", key, 720*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("未配置时不该报错:%v", err)
|
||||
}
|
||||
if value != 720*time.Hour {
|
||||
t.Fatalf("未配置时取值 = %v, want 720h(默认)", value)
|
||||
}
|
||||
|
||||
blank := writeDotEnv(t, key+"=")
|
||||
value, err = LoadDurationEnv(blank, key, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("空白取值不该报错:%v", err)
|
||||
}
|
||||
if value != time.Hour {
|
||||
t.Fatalf("空白取值时 = %v, want 1h(按未配置处理)", value)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadDurationEnv_进程环境优先_正常:优先级与其余配置一致(进程环境 > .env > 默认)。
|
||||
func TestLoadDurationEnv_进程环境优先_正常(t *testing.T) {
|
||||
const key = "CYRENE_TEST_DURATION_PRECEDENCE"
|
||||
t.Setenv(key, "45m")
|
||||
|
||||
value, err := LoadDurationEnv(writeDotEnv(t, key+"=3h"), key, 30*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDurationEnv: %v", err)
|
||||
}
|
||||
if value != 45*time.Minute {
|
||||
t.Fatalf("取值 = %v, want 45m(进程环境优先于 .env)", value)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadDurationEnv_只写在env文件里也生效_正常:这是本函数存在的理由 —— `.env` 不写回
|
||||
// 进程环境,os.Getenv 会**静默忽略**它,而本函数必须读到(否则照模板部署的新实例漏配且
|
||||
// 静默失效,AGENTS §10 的 E6 条目)。显式 0 与负值也必须**原样抵达**:渠道事件 GC 正是
|
||||
// 靠 ≤0 判「显式关闭」(design/26 §4.4)。
|
||||
func TestLoadDurationEnv_只写在env文件里也生效_正常(t *testing.T) {
|
||||
const key = "CYRENE_TEST_DURATION_DOTENV"
|
||||
unsetEnvForTest(t, key)
|
||||
|
||||
zero, err := LoadDurationEnv(writeDotEnv(t, "# 注释行", key+"=0"), key, 720*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDurationEnv(0): %v", err)
|
||||
}
|
||||
if zero != 0 {
|
||||
t.Fatalf("取值 = %v, want 0(.env 里的显式 0 必须原样抵达)", zero)
|
||||
}
|
||||
|
||||
neg, err := LoadDurationEnv(writeDotEnv(t, key+"=-1h"), key, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDurationEnv(负值): %v", err)
|
||||
}
|
||||
if neg != -time.Hour {
|
||||
t.Fatalf("负值 = %v, want -1h(负值必须原样抵达,不得被当成默认)", neg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadDurationEnv_非法取值回落默认并报错_异常:配了但读不懂 ⇒ 返回默认值 **且**返回
|
||||
// 错误(调用方据此记 WARN 并继续),绝不静默失效。
|
||||
func TestLoadDurationEnv_非法取值回落默认并报错_异常(t *testing.T) {
|
||||
const key = "CYRENE_TEST_DURATION_INVALID"
|
||||
unsetEnvForTest(t, key)
|
||||
|
||||
value, err := LoadDurationEnv(writeDotEnv(t, key+"=一小时"), key, 30*time.Second)
|
||||
if err == nil {
|
||||
t.Fatal("非法 duration 必须报错(AGENTS §10:非法配置不许静默失效)")
|
||||
}
|
||||
if value != 30*time.Second {
|
||||
t.Fatalf("非法取值时的回落值 = %v, want 30s", value)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadDurationEnv_env文件不存在也不报错_边界:路径为空(不看 .env)与路径指向不存在的
|
||||
// 文件都不该让启动失败 —— 三个键都是可选旋钮。⚠️ 路径非空但文件缺失时 parseDotEnv 的行为
|
||||
// 由既有约定决定(缺失不算错误),本用例把它钉在「返回默认值」这一侧。
|
||||
func TestLoadDurationEnv_env文件不存在也不报错_边界(t *testing.T) {
|
||||
const key = "CYRENE_TEST_DURATION_MISSING_DOTENV"
|
||||
unsetEnvForTest(t, key)
|
||||
|
||||
value, err := LoadDurationEnv(filepath.Join(t.TempDir(), "absent.env"), key, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("缺失的 .env 不该让读取失败:%v", err)
|
||||
}
|
||||
if value != time.Hour {
|
||||
t.Fatalf("取值 = %v, want 1h(默认)", value)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user