Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21b906ffb0 | ||
|
|
68ba85389b | ||
|
|
0c5fa7b2ae | ||
|
|
9a3b3cff8b | ||
|
|
ae68bb0241 | ||
|
|
b4d55f412e | ||
|
|
d81635672e |
No files matched your search
+33
-5
@@ -1,6 +1,6 @@
|
||||
# Cyrene v2 客户端 API 契约
|
||||
|
||||
> **契约版本:`v0.89`**(上一版 `v0.88`,其条目见 §6;`v0.87` / `v0.86` / `v0.85` / `v0.84` / `v0.83` / `v0.82` / `v0.81` / `v0.80` / `v0.79` / `v0.78` / `v0.77` / `v0.76` / `v0.75` / `v0.74` / `v0.73` / `v0.72` / `v0.71` 及更早见本文件 §6,`v0.67` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。
|
||||
> **契约版本:`v0.90`**(上一版 `v0.89`,其条目见 §6;`v0.88` / `v0.87` / `v0.86` / `v0.85` / `v0.84` / `v0.83` / `v0.82` / `v0.81` / `v0.80` / `v0.79` / `v0.78` / `v0.77` / `v0.76` / `v0.75` / `v0.74` / `v0.73` / `v0.72` / `v0.71` 及更早见本文件 §6,`v0.67` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。
|
||||
> **读者**:Web 前端 / 移动端原生客户端(含 Android)/ IM 桥接等**跨平台客户端开发者**。
|
||||
> **唯一真源 = 当前代码**(`internal/services/gateway/*` 等)。本文档由代码逐条核对得出;与代码不符时**以代码为准**,并回改本文档。
|
||||
> **旧的超详细版已归档、只读**:`Docs/archive/api-client.v0.65.md`(412 KB,含 v0.28→v0.65 全部历史条目)与 `Docs/archive/api-reference.v0.65.md`(170 KB,后端全量字段级文档)。归档的原因是**结构性臃肿 + 历史条目并列 + 长期漂移**;本文档**不复制**那段历史,需要考古时去归档。
|
||||
@@ -255,8 +255,8 @@
|
||||
| C16 | POST | `/api/v1/admin/llm` | 添加/更新 LLM provider(写 `models.json` + 热重载) |
|
||||
| C17 | POST | `/api/v1/admin/llm/test` | 连通性测试 |
|
||||
| C18 | POST | `/api/v1/admin/llm/reset-from-env` | 重置为 env 默认(切片 R1,v0.62;body 必须 `{"confirm":true}`,否则 `400` 且一个字节都不动;审计 `llm.resetFromEnv`) |
|
||||
| C19 | POST | `/api/v1/admin/channels/open` | 打开渠道(v0.66 增 `accountId`,见下注) |
|
||||
| C20 | POST | `/api/v1/admin/channels/close` | 关闭渠道(v0.66 增 `accountId`,见下注) |
|
||||
| C19 | POST | `/api/v1/admin/channels/open` | 打开渠道(v0.66 增 `accountId`,见下注;**v0.90 起 `connected` 为真实状态**,并按需回可选 `pending`/`ready`/`reason`) |
|
||||
| C20 | POST | `/api/v1/admin/channels/close` | 关闭渠道(v0.66 增 `accountId`,见下注;**v0.90 起 `connected` 取真实 `IsConnected()`**) |
|
||||
| C21 | POST | `/api/v1/admin/channels/test` | 渠道连通测试 |
|
||||
| C22 | GET | `/api/v1/admin/channels/{platform}/login-qr` | 取登录二维码(获取凭据,故 owner-only) |
|
||||
| C23 | GET | `/api/v1/admin/channels/{platform}/login-status` | 查扫码登录状态 |
|
||||
@@ -291,7 +291,7 @@
|
||||
> - `id` 与 `empty=1` 同时给出 ⇒ `400 bad_request`;**两者都缺 ⇒ `400 bad_request`**(绝不把「id 缺失」默认解释成删无 id 条目 —— 那是一次没有字面证据的批量删除)。`?empty=` / `?empty=0` / `?empty=false` 一律是「否」。
|
||||
> - 响应:`{data:{persisted, path, platform, accountId, empty, removed}}`(`empty`/`removed` 是 v0.66 新增);`removed` = 实际删除的条目数。删空后平台键整个消失(与按 id 删除的收尾口径一致)。
|
||||
|
||||
### 2.4 v0.67 登记的管理面(渠道事件查询 + 账号在线状态)—— **(a) 已实现 / (b) 已实现**;v0.78 追加 **(c) iLink 凭据三态**
|
||||
### 2.4 v0.67 登记的管理面(渠道事件查询 + 账号在线状态)—— **(a) 已实现 / (b) 已实现**;v0.78 追加 **(c) iLink 凭据三态**;v0.90 追加 **(d) 渠道连接态三态**
|
||||
|
||||
> ⚠️ **本节两项都已落地**(账号在线状态 `52dac45`;渠道事件查询面本切片 E6):形状取自设计文档(`design/26 §4.5`、`§2.6`),落地时已按代码核对。**§2.0 的计数(12 + 11 + 40 = 63)原本不含本节端点**;两个渠道事件端点已登记进 `routes.go`(`owner|admin` 档),下版改文档时应把它们并入 §2.1/§2.2 并更新计数。**本行留在此处仅为与 v0.67 冻结版对照**。
|
||||
|
||||
@@ -334,6 +334,15 @@
|
||||
- 🔴 **同域但未实现的面**:`GET /api/v1/admin/channels` 的 `accounts[]` 元素**尚未**带**每账号** `authState`/`authDetail`(缺 `AdminChannelAccount` 两字段与合并器的接线)—— 见 §7.2。
|
||||
- ⚠️ **未确证(真机)**:真实 `errcode=-14` 至今**从未在日志里观测到** ⇒ `rejected` 分支只有单测 / httptest 证据,**没有真机证据**(见 §7.4-26)。
|
||||
|
||||
#### (d) 渠道连接态三态:`pending` / `ready` / `reason`(**v0.90 新增,已实现**)
|
||||
|
||||
- **出现位置**:`POST /api/v1/admin/channels/open` 与 `.../close` 的响应体、`GET /api/v1/admin/channels` 的**平台行**与 `accounts[]` **账号行**(三处同名同义)。
|
||||
- **三个键都是可选的,取不到就整键缺席**(同 `needsRescan` / `lastError` 口径):`connected:true` 时,或适配器没有这条状态面时,`pending` / `ready` / `reason` 一律不出现。
|
||||
- `reason` 只可能在 `pending:true` 时出现,是**已脱敏**的原因短句(`access_token` 等凭据绝不出现在其中)。
|
||||
- **判据是结构化字段**(适配器自报的闭集状态),不是正文、不是提示词 —— 客户端据此把卡片画成「已连接 / 连接中(原因)/ 就绪待接入 / 未连接」四档。
|
||||
- **口径归属**:**适配器自报**(`ConnectOutcome`,网关经类型断言探测);核心 `sdk.Channel` 契约**刻意不扩展** ⇒ 没实现该状态面的平台响应**逐字节不变**。
|
||||
- ⚠️ **为什么 `ready` 不带 `reason`**:它本来就不是坏消息(监听已就绪、就等对面来连),给它编一个原因等于把「等接入」说成「出问题了」。
|
||||
|
||||
---
|
||||
|
||||
## 3. WebSocket `/ws/chat`
|
||||
@@ -1487,7 +1496,26 @@ curl -s -X POST -H "X-API-Key: $KEY" \
|
||||
> 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。
|
||||
> 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。**
|
||||
|
||||
### v0.89(当前契约版本)
|
||||
### v0.90(当前契约版本)
|
||||
|
||||
**「渠道连接态三态」版 —— 管理面新增三个可选键,并把 `connected` 的语义从「动作被受理」纠正为「真的连上了」**(依据:2026-10-13 主人报「改了 QQ 适配器配置后点连接,卡片仍显示未连接,却提示已连接」;实现推库 `b4d55f4`。)
|
||||
|
||||
- 🔴 **纠正的谎**:`POST /api/v1/admin/channels/open` 此前在 `ch.Connect` 返回 nil 后**硬编码** `connected:true`。而 `Connect` 返回 nil 只说明「这次调用没有致命失败」——**ws+client 首连失败刻意非致命**(OneBot 实现可能比 Cyrene 后启动,重试交给读循环),**ws+server 则只需监听就绪**,这两种情况下 `IsConnected()` 都是 `false`。前端又只按「请求成功」写死「已连接」⇒ 管理面把「后端受理了」说成了「渠道连上了」。
|
||||
- **三态闭集(口径落在适配器自身,网关只做映射)**:
|
||||
|
||||
| 状态 | 含义 | 线上投影 |
|
||||
|---|---|---|
|
||||
| `connected` | 真连上(client 拨号成功 / HTTP 传输已装配就绪) | `connected: true`,三个可选键**全缺席** |
|
||||
| `pending` | 已在后台重试、当前未连上(仅 ws+client 有重试循环) | `connected: false` + `pending: true` + `reason` |
|
||||
| `ready` | 已绑定监听、就绪等对端接入(ws+server);`IsConnected()` 同样为 `false`,但**不是失败** | `connected: false` + `ready: true`(**不带** `reason`) |
|
||||
| 无意见 | 未启动,或已启动但无可解释的就绪面 | 退回只看 `IsConnected()`,三个键全缺席 |
|
||||
|
||||
- **新增可选键(三处形状同名同义)**:① `POST /api/v1/admin/channels/open` 与 `.../close` 的响应体;② `GET /api/v1/admin/channels` 的**平台行**;③ 同端点的**账号行**(`accounts[]`)。`reason` 已**脱敏**(形状级 → 已知明文替换 → 剥 `access_token=` 查询参数),**凭据绝不出现**。
|
||||
- 🔴 **行为变更(须客户端跟进)**:`open` 的 `connected` 现在是**真实连接状态**,**不再恒为 `true`**;`close` 报**真实** `IsConnected()`,不再假定「调用成功 ⇒ 一定断开了」。老客户端若只看 HTTP 2xx 就提示「已连接」,在 `pending` / `ready` 下会继续骗人 —— 请改读 `connected` 加上述两个键,把卡片画成「已连接 / 连接中(原因)/ 就绪待接入 / 未连接」四档。
|
||||
- **退回行为(无破坏性)**:没实现该状态面的平台(kook / telegram / webhook / weixin-iLink)响应**逐字节不变**(既有四键 + 零变换)。
|
||||
- **计数不变**:无新端点 ⇒ §2.0 维持 `14 + 25 + 40 = 79`。详见 **§2.4(d)**。
|
||||
|
||||
### v0.89
|
||||
|
||||
**「助手消息的行内标签白名单」版 —— 只影响渲染口径,**服务端 API 形状一字未改****(依据:主人 2026-10-07「她在聊天消息中插入了 html/md 标签,能否增加网页前端对此类标签的基本渲染支持?」;Web 端实现推库 `2dae469`。)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/channelbridge"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// ownerRegistrar 实现 gateway.OwnerRegistrar:它把一个渠道账号 id 持久化进主人
|
||||
@@ -57,6 +58,30 @@ func (r *ownerRegistrar) RegisterChannelOwner(_ context.Context, platform, accou
|
||||
// 加载失败则为 nil,gateway 会退回「真实落盘 + 提示需重启」,绝不假装热生效。
|
||||
func (r *ownerRegistrar) LiveOwnerProfile() *user.OwnerProfile { return r.owner }
|
||||
|
||||
// channelCaps 把能力注册表适配成 channelbridge.ChannelRegistry(热重载同步注册表的
|
||||
// 窄端口)。替换时写入的形状与 wireChannels 启动注册**逐字相同**
|
||||
// (Name/Kind/Description/Value):Description 取适配器名、Value 取适配器本身 ——
|
||||
// adminListChannels 把 Value 断言成 sdk.Channel、面板按注册名精确匹配,
|
||||
// 形状一旦不一致,渠道就会从面板上消失。
|
||||
type channelCaps struct{ caps *registry.Registry }
|
||||
|
||||
func (c channelCaps) ReplaceChannel(ch sdk.Channel) error {
|
||||
if ch == nil {
|
||||
return fmt.Errorf("wire channel registry: %w", registry.ErrInvalidArgument)
|
||||
}
|
||||
if err := c.caps.ReplaceChannel(ch); err != nil {
|
||||
return fmt.Errorf("wire channel registry %q: %w", ch.Name(), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c channelCaps) RemoveChannel(name string) error {
|
||||
if err := c.caps.Remove(name); err != nil {
|
||||
return fmt.Errorf("wire channel registry %q: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// wireChannels 依环境配置构造四个 IM 渠道适配器,把它们注册进能力注册表(管理
|
||||
// API 据此枚举),并接进渠道 bridge——让已配置的适配器连上,把入站消息按
|
||||
// session→actor→loop→reply 路由。缺凭据的适配器保持未连接(只记日志,绝不作
|
||||
@@ -122,6 +147,10 @@ func (a *app) wireChannels() error {
|
||||
// EngageFor 留空(P2a);按群覆盖随 P2c 到来。
|
||||
// Factory 让面板保存时热替换某平台的适配器而无需重启(design/04 §3.1)。
|
||||
Factory: factory,
|
||||
// Channels 让热重载把新适配器写回能力注册表:管理面板(渠道卡片、账号在线
|
||||
// 状态、open/close 的目标解析)读的就是注册表,不同步它就只能看到旧实例,
|
||||
// 于是卡片永远「未连接」、点「连接」驱动的是旧 URL 的那把。
|
||||
Channels: channelCaps{caps: a.caps},
|
||||
// ChannelPersona 从工厂构建所用的同一份配置里解析账号的渠道级角色
|
||||
// (design/22 §2 方案 b);bridge 按已连接的适配器缓存它,并在每次重载时刷新。
|
||||
ChannelPersona: factory.Persona,
|
||||
|
||||
@@ -41,7 +41,10 @@ var errT1ModelNotReady = errors.New("memory review: t1 judge: model adapter not
|
||||
// - `{"ingestGate": false}` ⇒ 一键回到「只插不审」(同步入库闸);
|
||||
// - `{"t1Judge": false}` ⇒ 一键回到纯规则(T1 灰区判定,**零模型调用**);
|
||||
// - `{"t1PerWindow": 120, "t1WindowSec": 3600}` ⇒ T1 的**全局限速**额度
|
||||
// (design/23 §15.4);缺位 = 代码默认 120 次/60 分钟。
|
||||
// (design/23 §15.4);缺位 = 代码默认 120 次/60 分钟;
|
||||
// - `{"tempTtlSec": 604800}` ⇒ 第五态 `defer` 的**临时记忆存活秒数**
|
||||
// (design/03 §9.6);缺位/坏值 = 代码默认 7 天。**它没有 `.env` 档**
|
||||
// (`.env` 已冻结,本键在那里从来没有出处)⇒ 实际是「面板 > 代码默认」。
|
||||
func (a *app) wireMemoryReview() error {
|
||||
cfg, err := config.LoadMemoryReviewConfig(envPath)
|
||||
if err != nil {
|
||||
@@ -61,6 +64,10 @@ func (a *app) wireMemoryReview() error {
|
||||
// 交零值给 normalizeReviewConfig 回落**代码默认值**(120 次/60 分钟)——
|
||||
// 坏配置绝不能让一处限速静默变成「无上限」。
|
||||
t1PerWindow, t1WindowSec := a.memoryT1RateLimit()
|
||||
// 临时记忆 TTL(design/03 §9.6):面板 `tempTtlSec` 优先,缺位/坏值回落代码
|
||||
// 默认 **7 天**(经 normalizeReviewConfig 落地)。**中间那一档(`.env`)在本键
|
||||
// 上结构性为空** —— `.env` 已冻结(design/13 §2.1),这个键在那里从来没有出处。
|
||||
tempTTL := a.memoryTempTTL()
|
||||
reviewer, err := memory.NewPreInsertReviewer(memory.ReviewConfig{
|
||||
Enabled: cfg.Enabled,
|
||||
QueueSize: cfg.QueueSize,
|
||||
@@ -82,6 +89,9 @@ func (a *app) wireMemoryReview() error {
|
||||
// (120 次/60 分钟)。**没有「无上限」这个状态**:要零花费请关 t1Judge。
|
||||
T1PerWindow: t1PerWindow,
|
||||
T1Window: time.Duration(t1WindowSec) * time.Second,
|
||||
// 第五态 `defer` 的落点(design/23 §18.1):`expires_at` = 判定时刻 + 它。
|
||||
// 零值 = 没配 ⇒ 由 normalizeReviewConfig 回落代码默认 7 天(tempTtlSec)。
|
||||
TempTTL: tempTTL,
|
||||
}, memory.ReviewDeps{
|
||||
Store: a.memStore,
|
||||
Embedder: a.embedder,
|
||||
@@ -151,6 +161,33 @@ func (a *app) memoryT1RateLimit() (perWindow int, windowSec int) {
|
||||
return tuning.T1PerWindow, tuning.T1WindowSec
|
||||
}
|
||||
|
||||
// memoryTempTTL 返回**临时记忆 TTL**(design/03 §9.6 的 `tempTtlSec`):面板
|
||||
// `$DATA_DIR/config/memory-tidy.json` 的值优先,缺位 / 坏值一律回落**代码默认
|
||||
// 7 天**(返回零值即「不覆盖」,由 NewPreInsertReviewer 的 normalizeReviewConfig
|
||||
// 落地 —— 与 memoryT1RateLimit 返回零值的口径逐字相同)。
|
||||
//
|
||||
// 解析与坏值 WARN 都交给 `memory.ApplyReviewTuning`(与 tidy_tuning.go 对
|
||||
// TidyConfig 是同一手法):runtimeconfig 只管**文件形状**,而「缺位 / 坏值怎么
|
||||
// 回落」属于 ReviewConfig 的归一化语义 —— 那里是**唯一**实现点,组合根不再抄一遍
|
||||
// (抄一遍就意味着两条路径各自决定「什么是坏值」)。
|
||||
//
|
||||
// 读不到 / 解不开那个文件时返回零值 + WARN:坏配置绝不能让 TTL 静默变成
|
||||
// 「不过期」,也不能让每条临时记忆**落库即过期**(一个 ≤0 的 TTL 正是那个形态)。
|
||||
//
|
||||
// 🔴 中间那一档(`.env`)在本键上**结构性为空**:`.env` 已冻结
|
||||
// (design/13 §2.1「不加、不改、不删」),而这个键在那里**从来没有出处**。
|
||||
// 因此本键的实际生效链是「**面板 > 代码默认**」——不是漏配,设计原文
|
||||
// (design/03 §9.6)写的就是这一档。
|
||||
func (a *app) memoryTempTTL() time.Duration {
|
||||
tuning, path, err := a.readMemoryTidyTuning()
|
||||
log := logger.WithSource(a.log, "memory/review")
|
||||
if err != nil {
|
||||
log.Warn("memory temp ttl tuning unreadable, falling back to code default (7 days)",
|
||||
"path", path, "error", err)
|
||||
}
|
||||
return memory.ApplyReviewTuning(memory.ReviewConfig{}, tuning, log).TempTTL
|
||||
}
|
||||
|
||||
// memoryT1Judge 把「调用时才解析的模型适配器」包成 T1 的一次性调用口。
|
||||
//
|
||||
// 为什么是闭包而不是直接塞 `a.modelAdapter`:组合根的顺序是 **wireMemory(建
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { AdminChannelAccount } from "../../../lib/types";
|
||||
import { Button, Tag } from "../../../components/primitives";
|
||||
import { authKeyOf } from "../channelFieldRules";
|
||||
import { adapterNameOf } from "./channelUtils";
|
||||
import { accountActionGate, accountOnlineStatus, shortAccountID, type ChannelStatus } from "./channelStatus";
|
||||
import { accountActionGate, accountOnlineStatus, channelConnectStateOf, connectStateView, shortAccountID, type ChannelStatus, type ChannelStatusTone } from "./channelStatus";
|
||||
|
||||
/** 一条渠道账号在活动适配器列表里的行。
|
||||
*
|
||||
@@ -46,7 +46,7 @@ export function AccountRow({
|
||||
const raw = account.config?.[authKey];
|
||||
const authSet = typeof raw === "string" && raw.trim() !== "";
|
||||
const gate = accountActionGate(status, account.connected);
|
||||
const state = connectionTag(account.connected);
|
||||
const state = connectionTag(account);
|
||||
const online = accountOnlineStatus(account.accountOnline);
|
||||
const blocker = status.kind === "unknown" ? "读取不到连接状态(后端失联),已禁用" : undefined;
|
||||
|
||||
@@ -67,7 +67,7 @@ export function AccountRow({
|
||||
id {short}
|
||||
</span>
|
||||
)}
|
||||
<Tag tone={account.connected === true ? "ok" : account.connected === false ? "err" : "neutral"}>{state}</Tag>
|
||||
<Tag tone={state.tone} title={state.hint}>{state.label}</Tag>
|
||||
{/* 账号在**平台侧**的在线状态 —— 与上一枚「连接」标签说的是两件事:OneBot 的
|
||||
进程/WS 可以一直连着,而背后的 QQ 账号已掉线(消息收不到也发不出)。
|
||||
两枚标签刻意不合并:合并后「我们连着但账号掉了」这一个事故现场就无迹可寻。
|
||||
@@ -127,11 +127,23 @@ export function AccountRow({
|
||||
);
|
||||
}
|
||||
|
||||
/** 账号级连接状态的呈现:已连接 / 未连接 / 状态未知(后端没报告该账号的活适配器)。 */
|
||||
function connectionTag(connected: boolean | undefined): string {
|
||||
if (connected === true) return "已连接";
|
||||
if (connected === false) return "未连接";
|
||||
return "状态未知";
|
||||
/** 账号级连接状态的呈现:已连接 / 连接中 / 等待接入 / 未连接 / 状态未知
|
||||
* (后端没报告该账号的活适配器)。
|
||||
*
|
||||
* 「连接中」与「等待接入」来自后端自报的连接态三态(见 ChannelConnectState)。
|
||||
* 没有它们时,一个正在后台重试的 ws+client 账号会被画成红色的「未连接」,主人就会
|
||||
* 以为它坏了 —— 那正是 2026-10-07 事故的另一半(一半是弹「已连接」,一半是卡片
|
||||
* 永远显示「未连接」)。两态都用非错误腔:它们不是结论,是进行中。 */
|
||||
function connectionTag(account: AdminChannelAccount): {
|
||||
label: string;
|
||||
tone: ChannelStatusTone;
|
||||
hint: string | undefined;
|
||||
} {
|
||||
if (account.connected === true) return { label: "已连接", tone: "ok", hint: undefined };
|
||||
const view = connectStateView(channelConnectStateOf(account), account.reason);
|
||||
if (view !== null) return { label: view.label, tone: view.tone, hint: view.hint };
|
||||
if (account.connected === false) return { label: "未连接", tone: "err", hint: undefined };
|
||||
return { label: "状态未知", tone: "neutral", hint: undefined };
|
||||
}
|
||||
|
||||
/** 「本次会作用在哪一条账号」的小字。这是修掉「连了谁」这一困惑的关键文案:
|
||||
|
||||
@@ -154,4 +154,24 @@ function buttonHTML(html: string, label: string): string {
|
||||
ok(has(down, "账号在平台上"), "离线标签的悬停说明点明这是账号在平台上的状态");
|
||||
}
|
||||
|
||||
// ── ⑤ 重试中 / 就绪等接入:不许画成红色的「未连接」 ────────────────────────────
|
||||
// 2026-10-07 事故的另一半:点了「连接」之后卡片一直显示「未连接」。ws+client 的首连
|
||||
// 失败刻意非致命(OneBot 实现可能比 Cyrene 后启动),所以那不是故障,是「正在连」;
|
||||
// ws+server 则是「就绪等对端接入」。两者都必须与静态的「未连接」分开画。
|
||||
{
|
||||
const REASON = "qq-obv11: dial onebot ws ws://localhost:10301/: connection refused";
|
||||
const retrying = renderRow({ id: "9616b27a", config: {}, connected: false, pending: true, reason: REASON });
|
||||
ok(has(retrying, ">连接中</span>"), "重试中的账号画「连接中」");
|
||||
ok(!has(retrying, ">未连接</span>"), "重试中不许画成「未连接」(它是进行中,不是结论)");
|
||||
ok(has(retrying, REASON), "重试中的原因随标签悬停一并给出(说不出为什么就等于没说)");
|
||||
|
||||
const ready = renderRow({ id: "9616b27a", config: {}, connected: false, ready: true });
|
||||
ok(has(ready, ">等待接入</span>"), "监听就绪的账号画「等待接入」");
|
||||
ok(!has(ready, ">未连接</span>"), "就绪等接入不许画成「未连接」");
|
||||
|
||||
// 回归:后端没这条状态面的账号逐字不变。
|
||||
const plain = renderRow({ id: "9616b27a", config: {}, connected: false });
|
||||
ok(has(plain, ">未连接</span>"), "没有状态面的账号仍是「未连接」");
|
||||
}
|
||||
|
||||
console.log(`channel account row render test: ${checks} checks passed`);
|
||||
@@ -0,0 +1,213 @@
|
||||
// 「点了连接之后,面板到底说了什么」的真实断言(2026-10-07 UI 撒谎事故的回归测试)。
|
||||
//
|
||||
// 为什么必须存在:旧代码只按「请求成功」判断 —— `adminChannelOpen` 一 resolve 就弹
|
||||
// 「已连接」,而 ws+client 的首连失败**刻意非致命**(OneBot 实现可能比 Cyrene 后启动),
|
||||
// 于是「后端受理了」被说成了「渠道连上了」。这里不看组件、不靠记忆:既直接驱动
|
||||
// `createChannelActions`(stub 掉 globalThis.fetch,读它真正写进 notice 的那句话),
|
||||
// 也直接驱动卡片用的纯函数 `channelStatus`。
|
||||
//
|
||||
// 跑法(与 channelActions.test.ts 同款 esbuild + node,react-dom 的 CJS 需要 require):
|
||||
//
|
||||
// cd frontend/web \
|
||||
// && ./node_modules/.bin/esbuild src/admin/modules/channels/channelConnectNotice.test.ts \
|
||||
// --bundle --platform=node --format=cjs \
|
||||
// --outfile=/tmp/cyrene-channel-connect-notice.test.cjs \
|
||||
// && node /tmp/cyrene-channel-connect-notice.test.cjs
|
||||
//
|
||||
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内
|
||||
// 导入、不 import node:*。
|
||||
|
||||
import type { AdminChannelRecord } from "../../../lib/types";
|
||||
import { channelConnectStateOf, channelStatus } from "./channelStatus";
|
||||
import { connectDisconnectNotice, createChannelActions } from "./useChannelActions";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
function ok(cond: boolean, what: string): void {
|
||||
checks += 1;
|
||||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||
}
|
||||
|
||||
function notOk(cond: boolean, what: string): void {
|
||||
ok(!cond, what);
|
||||
}
|
||||
|
||||
function eq(got: unknown, want: unknown, what: string): void {
|
||||
checks += 1;
|
||||
if (got !== want) throw new Error(`FAILED: ${what}(got ${JSON.stringify(got)}, want ${JSON.stringify(want)})`);
|
||||
}
|
||||
|
||||
const REASON = "qq-obv11: dial onebot ws ws://localhost:10301/: connection refused";
|
||||
|
||||
// ── ① 纯函数:提示文案只看**响应**,不看请求是否成功 ─────────────────────────────
|
||||
{
|
||||
// 本次事故的正题:请求 200 + connected:false + pending:true ⇒ 绝不是「已连接」。
|
||||
const pending = connectDisconnectNotice(true, "qq-obv11", {
|
||||
name: "qq-obv11",
|
||||
connected: false,
|
||||
pending: true,
|
||||
reason: REASON,
|
||||
});
|
||||
notOk(pending.includes("已连接"), `重试中不许说「已连接」,got ${JSON.stringify(pending)}`);
|
||||
ok(pending.includes("正在连接"), `重试中必须说「正在连接」,got ${JSON.stringify(pending)}`);
|
||||
ok(pending.includes(REASON), `重试中必须带上原因,got ${JSON.stringify(pending)}`);
|
||||
|
||||
// 连上了才是「已连接」。
|
||||
eq(
|
||||
connectDisconnectNotice(true, "qq-obv11:9616b27a", { name: "qq-obv11", connected: true }),
|
||||
"已连接 qq-obv11:9616b27a",
|
||||
"真连上才说「已连接」(并回显实际驱动的适配器)",
|
||||
);
|
||||
|
||||
// server 已就绪、等待对端接入:既不是「已连接」,也不是一句静态的「未连接」。
|
||||
const ready = connectDisconnectNotice(true, "qq-obv11", { name: "qq-obv11", connected: false, ready: true });
|
||||
notOk(ready.includes("已连接"), `就绪等接入不许说「已连接」,got ${JSON.stringify(ready)}`);
|
||||
ok(ready.includes("已就绪"), `就绪等接入必须说清楚,got ${JSON.stringify(ready)}`);
|
||||
|
||||
// 后端没有这条状态面(两个键都缺):如实说「尚未连上」,绝不乐观。
|
||||
const bare = connectDisconnectNotice(true, "kook", { name: "kook", connected: false });
|
||||
notOk(bare.includes("已连接"), `连不上不许说「已连接」,got ${JSON.stringify(bare)}`);
|
||||
ok(bare.includes("尚未连上"), `必须如实说尚未连上,got ${JSON.stringify(bare)}`);
|
||||
|
||||
// 断开同样不乐观:后端报连接仍在,就不能说「已断开」。
|
||||
eq(
|
||||
connectDisconnectNotice(false, "kook", { name: "kook", connected: false }),
|
||||
"已断开 kook",
|
||||
"真断开才说「已断开」",
|
||||
);
|
||||
const stuck = connectDisconnectNotice(false, "kook", { name: "kook", connected: true });
|
||||
notOk(stuck.includes("已断开"), `仍未断开不许说「已断开」,got ${JSON.stringify(stuck)}`);
|
||||
}
|
||||
|
||||
// ── ② 卡片状态:三态在**真实渲染逻辑**里与「未连接」分开 ────────────────────────
|
||||
{
|
||||
const pendingRecord: AdminChannelRecord = {
|
||||
name: "qq-obv11",
|
||||
connected: false,
|
||||
pending: true,
|
||||
reason: REASON,
|
||||
};
|
||||
const pending = channelStatus([pendingRecord], null, pendingRecord);
|
||||
eq(pending.kind, "connecting", "重试中的卡片是 connecting,不是 disconnected");
|
||||
eq(pending.label, "连接中", "重试中的卡片写「连接中」");
|
||||
eq(pending.tone, "warn", "重试中用警示色而不是错误色(它不是故障)");
|
||||
ok(pending.hint !== null && pending.hint.includes(REASON), "重试中的卡片带上原因");
|
||||
notOk(pending.label.includes("未连接"), "重试中不许画成「未连接」");
|
||||
|
||||
const readyRecord: AdminChannelRecord = { name: "qq-obv11", connected: false, ready: true };
|
||||
const ready = channelStatus([readyRecord], null, readyRecord);
|
||||
eq(ready.kind, "ready", "就绪等接入的卡片是 ready");
|
||||
eq(ready.label, "等待接入", "就绪等接入的卡片写「等待接入」");
|
||||
eq(ready.tone, "neutral", "就绪等接入不用错误色");
|
||||
|
||||
// 回归:后端没这条状态面的平台逐字不变。
|
||||
const plain: AdminChannelRecord = { name: "kook", connected: false };
|
||||
const disconnected = channelStatus([plain], null, plain);
|
||||
eq(disconnected.kind, "disconnected", "没有状态面的平台仍是 disconnected");
|
||||
eq(disconnected.label, "未连接", "没有状态面的平台仍写「未连接」");
|
||||
eq(disconnected.tone, "err", "没有状态面的平台仍用错误色");
|
||||
|
||||
// 真连上优先:pending 与 connected 同时给出时不许被降级成「连接中」。
|
||||
const both: AdminChannelRecord = { name: "qq-obv11", connected: true, pending: true };
|
||||
eq(channelStatus([both], null, both).kind, "connected", "connected=true 优先于 pending");
|
||||
|
||||
eq(channelConnectStateOf({}), null, "两个键都缺省 = 没有这条状态面");
|
||||
eq(channelConnectStateOf({ pending: true }), "pending", "pending 优先");
|
||||
eq(channelConnectStateOf({ ready: true }), "ready", "ready 单独给出也认");
|
||||
}
|
||||
|
||||
// ── ③ 真实请求:createChannelActions 写进 notice 的那句话 ───────────────────────
|
||||
// 与前两段不同,这里驱动的是面板按钮真正调用的那一层,并用 stub 的 fetch 回一个
|
||||
// 「请求成功但没连上」的响应 —— 旧代码在这条路径上会说「已连接」。
|
||||
|
||||
interface Captured {
|
||||
url: string;
|
||||
method: string;
|
||||
}
|
||||
|
||||
const captured: Captured[] = [];
|
||||
// 每次请求各取一个响应:三个动作在同一个 tick 里发出,若共用一个可变变量,
|
||||
// 先发的两次会读到第三次的响应(那样断言的就是错的东西)。
|
||||
const payloadQueue: unknown[] = [];
|
||||
|
||||
function installStubs(): void {
|
||||
const store = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, v),
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, "fetch", {
|
||||
configurable: true,
|
||||
value: (url: string, init?: RequestInit) => {
|
||||
captured.push({ url: String(url), method: String(init?.method ?? "GET") });
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ data: payloadQueue.shift() ?? {} }),
|
||||
} as unknown as Response);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
installStubs();
|
||||
|
||||
const notices: string[] = [];
|
||||
const errors: string[] = [];
|
||||
let reloads = 0;
|
||||
const actions = createChannelActions({
|
||||
log: () => {},
|
||||
setBusy: () => {},
|
||||
setError: (e) => {
|
||||
if (e != null) errors.push(e);
|
||||
},
|
||||
setNotice: (n) => notices.push(n ?? ""),
|
||||
reload: () => {
|
||||
reloads += 1;
|
||||
},
|
||||
});
|
||||
|
||||
// 场景 A:后端回「请求成功但仍在重试」。
|
||||
payloadQueue.push({
|
||||
name: "qq-obv11",
|
||||
resolved: "qq-obv11:9616b27a",
|
||||
accountId: "9616b27a",
|
||||
connected: false,
|
||||
pending: true,
|
||||
reason: REASON,
|
||||
});
|
||||
actions.toggle("qq-obv11", true, "9616b27a");
|
||||
|
||||
// 场景 B:后端回「真连上」。
|
||||
payloadQueue.push({ name: "qq-obv11", resolved: "qq-obv11:9616b27a", connected: true });
|
||||
actions.toggle("qq-obv11", true, "9616b27a");
|
||||
|
||||
// 场景 C:断开成功。
|
||||
payloadQueue.push({ name: "qq-obv11", resolved: "qq-obv11:9616b27a", connected: false });
|
||||
actions.toggle("qq-obv11", false, "9616b27a");
|
||||
|
||||
setTimeout(() => {
|
||||
const pendingNotice = notices.find((n) => n.includes("正在连接"));
|
||||
ok(pendingNotice !== undefined, `重试中必须提示「正在连接」,got ${JSON.stringify(notices)}`);
|
||||
ok(
|
||||
!notices[0]?.includes("已连接"),
|
||||
`第一个动作(仍在重试)绝不能说「已连接」,got ${JSON.stringify(notices[0])}`,
|
||||
);
|
||||
ok(notices[0]?.includes(REASON) === true, `重试提示必须带上原因,got ${JSON.stringify(notices[0])}`);
|
||||
ok(
|
||||
notices.some((n) => n.includes("已连接 qq-obv11:9616b27a")),
|
||||
`真连上时必须回显实际驱动的适配器,got ${JSON.stringify(notices)}`,
|
||||
);
|
||||
ok(
|
||||
notices.some((n) => n.includes("已断开 qq-obv11:9616b27a")),
|
||||
`断开成功要说「已断开」,got ${JSON.stringify(notices)}`,
|
||||
);
|
||||
eq(errors.length, 0, "这些路径都不该产生错误提示");
|
||||
eq(reloads, 3, "每次动作后都刷新列表(卡片状态要靠它才不再撒谎)");
|
||||
eq(captured[0].url, "/api/v1/admin/channels/open", "连接仍打到既有路径");
|
||||
eq(captured[2].url, "/api/v1/admin/channels/close", "断开仍打到既有路径");
|
||||
console.log(`channel connect notice test: ${checks} checks passed`);
|
||||
}, 20);
|
||||
@@ -7,6 +7,8 @@ import type { AdminChannelRecord } from "../../../lib/types";
|
||||
* as plain node — see channelStatus.test.ts. */
|
||||
export type ChannelStatusKind =
|
||||
| "connected"
|
||||
| "connecting"
|
||||
| "ready"
|
||||
| "disconnected"
|
||||
| "unconfigured"
|
||||
| "unknown"
|
||||
@@ -29,6 +31,55 @@ export interface ChannelStatus {
|
||||
/** How much of the channel list we actually know right now. */
|
||||
export type ChannelListState = "ready" | "pending" | "unknown";
|
||||
|
||||
/** 适配器自报的**连接态三态**(后端可选键 `pending` / `ready`,契约 v0.68;见
|
||||
* internal/services/gateway/admin_channel_connect_outcome.go)。
|
||||
*
|
||||
* 为什么需要它:`connected:false` 至少有两种互不相同的现状 —— 「已在后台重试」
|
||||
* (ws+client 的首连失败刻意非致命:OneBot 实现可能比 Cyrene 后启动)与「已就绪、
|
||||
* 等待对端接入」(server 模式监听已绑定)。用一句静态的「未连接」覆盖两者,主人就会
|
||||
* 把「正在连接」读成故障,或者(更糟)反方向把「没连上」读成「已连接」——
|
||||
* 2026-10-07 的 UI 撒谎事故正是这么来的。
|
||||
*
|
||||
* `null` = 适配器没有这条状态面(后端两个键都缺省)⇒ 调用方退回只看 `connected`
|
||||
* 布尔,**绝不**凭空编一个状态。 */
|
||||
export type ChannelConnectState = "pending" | "ready" | null;
|
||||
|
||||
/** 把后端可选的 `pending` / `ready` 两键映成上面的三态。两者都缺省 ⇒ null。 */
|
||||
export function channelConnectStateOf(record: {
|
||||
pending?: boolean;
|
||||
ready?: boolean;
|
||||
}): ChannelConnectState {
|
||||
if (record.pending === true) return "pending";
|
||||
if (record.ready === true) return "ready";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 未连上时那句如实的话:`pending` 说「正在重试」并把原因带上(原因缺失时也不假装
|
||||
* 知道为什么),`ready` 说「等待接入」。两者都不是失败腔。`null` 表示后端没有这条
|
||||
* 状态面 ⇒ 调用方退回自己那套说法。 */
|
||||
export function connectStateView(
|
||||
state: ChannelConnectState,
|
||||
reason?: string,
|
||||
): { kind: ChannelStatusKind; label: string; tone: ChannelStatusTone; hint: string } | null {
|
||||
if (state === "pending") {
|
||||
return {
|
||||
kind: "connecting",
|
||||
label: "连接中",
|
||||
tone: "warn",
|
||||
hint: reason ? `正在后台重试连接:${reason}` : "已在后台重试连接(尚未连上)",
|
||||
};
|
||||
}
|
||||
if (state === "ready") {
|
||||
return {
|
||||
kind: "ready",
|
||||
label: "等待接入",
|
||||
tone: "neutral",
|
||||
hint: "监听已就绪,等待对端接入(尚未连上)",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** List-level tri-state: a list we never read plus a recorded error is *unknown*,
|
||||
* never "empty". `data == null` with no error is the first load still running. */
|
||||
export function channelListState<T>(
|
||||
@@ -91,6 +142,24 @@ export function channelStatus(
|
||||
}
|
||||
|
||||
const connected = record.connected === true;
|
||||
if (!connected) {
|
||||
// 「没连上」有三种互不相同的现状,只有后端自报时才敢细说(见 ChannelConnectState):
|
||||
// pending 是「正在重试」、ready 是「就绪等接入」。两者都不是「未连接」这样一个
|
||||
// 静态结论,因此不给错误腔,也不把「断开」摆出来(会话本来就没建立)。
|
||||
const state = channelConnectStateOf(record);
|
||||
const view = connectStateView(state, record.reason);
|
||||
if (view !== null) {
|
||||
return {
|
||||
kind: view.kind,
|
||||
label: view.label,
|
||||
tone: view.tone,
|
||||
hint: error != null ? `${view.hint};本次刷新失败,数据可能过期:${error}` : view.hint,
|
||||
stale: error != null,
|
||||
canConnect: true,
|
||||
canDisconnect: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: connected ? "connected" : "disconnected",
|
||||
label: connected ? "已连接" : "未连接",
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { ChannelStatus, ChannelStatusKind } from "./channelStatus";
|
||||
|
||||
const dot: Record<ChannelStatusKind, string> = {
|
||||
connected: "bg-[var(--mu-ok)]",
|
||||
connecting: "bg-[var(--mu-warn)] mu-blink",
|
||||
ready: "bg-[var(--mu-text-lo)]",
|
||||
disconnected: "bg-[var(--mu-err)]",
|
||||
unconfigured: "bg-[var(--mu-text-lo)]",
|
||||
unknown: "bg-[var(--mu-text-lo)]",
|
||||
@@ -10,9 +12,10 @@ const dot: Record<ChannelStatusKind, string> = {
|
||||
};
|
||||
|
||||
/** The one honest place an adapter's connection state is rendered, so list page,
|
||||
* detail page and overview can never drift apart: 已连接 / 未连接 / 状态未知.
|
||||
* `showHint` off renders the compact form for dense surfaces (table row, card) —
|
||||
* the surrounding banner already carries the failure reason there. */
|
||||
* detail page and overview can never drift apart: 已连接 / 连接中 / 等待接入 /
|
||||
* 未连接 / 状态未知. `showHint` off renders the compact form for dense surfaces
|
||||
* (table row, card) — there the hint (e.g. the retry reason behind「连接中」) rides
|
||||
* along as a hover title instead of taking a line of its own. */
|
||||
export function ChannelStatusTag({
|
||||
status,
|
||||
showHint = true,
|
||||
@@ -24,7 +27,7 @@ export function ChannelStatusTag({
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<span className={`inline-flex flex-wrap items-center gap-2 ${className}`}>
|
||||
<Tag tone={status.tone}>
|
||||
<Tag tone={status.tone} title={status.hint ?? undefined}>
|
||||
<span className={`inline-block h-1.5 w-1.5 rounded-full ${dot[status.kind]}`} />
|
||||
{status.label}
|
||||
</Tag>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
adminChannelOpen,
|
||||
adminDeleteChannelAccount,
|
||||
adminDeleteEmptyChannelAccounts,
|
||||
type ChannelActionResult,
|
||||
} from "../../../lib/api";
|
||||
import { useAuditStore } from "../../audit";
|
||||
|
||||
@@ -19,6 +20,35 @@ export interface ChannelActionDeps {
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
/** 连接/断开返回后要说给主人的那句话。
|
||||
*
|
||||
* **只看响应,不看请求是否成功**:请求 200 只说明后端受理了这次调用,不说明渠道真的
|
||||
* 连上了 —— ws+client 的首连失败刻意非致命(OneBot 实现可能比 Cyrene 后启动),此时
|
||||
* 后端回 `connected:false` + `pending:true` + `reason`。把这句写死成「已连接」,
|
||||
* 正是 2026-10-07 那次 UI 撒谎的根因。断开同理:报后端回的真实连接状态,而不是假定
|
||||
* 「调用成功就一定断了」。
|
||||
*
|
||||
* 纯函数、与 React 解耦,因此「响应 connected:false + pending:true ⇒ 提示里没有
|
||||
* 『已连接』」可以在 node 侧被直接断言(见 channelActions.test.ts)。 */
|
||||
export function connectDisconnectNotice(
|
||||
connect: boolean,
|
||||
who: string,
|
||||
res?: ChannelActionResult,
|
||||
): string {
|
||||
if (!connect) {
|
||||
return res?.connected === true ? `${who} 仍未断开(后端报告连接仍存在)` : `已断开 ${who}`;
|
||||
}
|
||||
if (res?.connected === true) return `已连接 ${who}`;
|
||||
if (res?.pending === true) {
|
||||
return res.reason
|
||||
? `${who} 正在连接(${res.reason})`
|
||||
: `${who} 正在连接(尚未连上,已在后台重试)`;
|
||||
}
|
||||
if (res?.ready === true) return `${who} 已就绪,等待对端接入`;
|
||||
// 既没连上、也没有可解释的状态面:如实说「尚未连上」——请求被受理不等于连上了。
|
||||
return `${who} 尚未连上(连接请求已受理,可稍后在列表中查看状态)`;
|
||||
}
|
||||
|
||||
/** 与 React 解耦的渠道动作集合(连接/断开/删除账号/删除无 id 条目)。 */
|
||||
export interface ChannelActions {
|
||||
/** 连接/断开。`accountId` 给出时**只**作用于该账号(多账号平台逐条操作);
|
||||
@@ -42,7 +72,7 @@ export function createChannelActions(deps: ChannelActionDeps): ChannelActions {
|
||||
// 回显**实际被驱动**的适配器:多账号平台上请求里的平台名说明不了「连的是哪一条」,
|
||||
// 后端把解析结果放在 resolved 里,这里如实说出来。
|
||||
const who = res?.resolved ?? name;
|
||||
setNotice(`${connect ? "已连接" : "已断开"} ${who}`);
|
||||
setNotice(connectDisconnectNotice(connect, who, res));
|
||||
reload();
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
|
||||
@@ -23,12 +23,26 @@ export interface ChannelConfigSaveResult {
|
||||
/** Result of POST /admin/channels/open|close. `resolved` is the adapter that was
|
||||
* actually driven (`qq-obv11:9616b27a`), which is what the panel must show —
|
||||
* the request's `name` alone never tells a multi-account platform which account
|
||||
* was touched. */
|
||||
* was touched.
|
||||
*
|
||||
* `connected` 说的是**现在通不通**,不是「这次请求成没成功」:ws+client 的首连失败
|
||||
* 刻意非致命(OneBot 实现可能比 Cyrene 后启动),因此 `Connect` 返回 nil 并不等于连上了。
|
||||
* `pending`/`ready`(后端可选,契约 v0.68)进一步说明「没连上是哪种没连上」:
|
||||
*
|
||||
* - `pending: true` → 已在后台重试、当前未连上 ⇒ 提示须是「正在连接(reason)」,
|
||||
* **绝不是**「已连接」,也不是一句静态的「未连接」;
|
||||
* - `ready: true` → 已就绪、等待对端接入(server 模式)⇒ 同样不是失败;
|
||||
* - `reason` → 未连上的原因(已脱敏)。
|
||||
*
|
||||
* 三者缺省 = 该适配器没有这条状态面 ⇒ 只看 `connected`。 */
|
||||
export interface ChannelActionResult {
|
||||
name: string;
|
||||
resolved?: string;
|
||||
accountId?: string;
|
||||
connected: boolean;
|
||||
pending?: boolean;
|
||||
ready?: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** open/close 的请求体。**accountId 缺省时逐字维持旧契约 `{name}`**(老后端/老客户端
|
||||
|
||||
@@ -40,6 +40,19 @@ export interface InstalledPlugin {
|
||||
export interface AdminChannelRecord {
|
||||
name: string;
|
||||
connected: boolean;
|
||||
/** 适配器自报的**连接态三态**(后端可选键,契约 v0.68;见 internal/services/
|
||||
* gateway/admin_channel_connect_outcome.go)。`connected:false` 至少有两种互不相同
|
||||
* 的现状,缺了它就只能被画成一句静态的「未连接」:
|
||||
*
|
||||
* - `pending` → 已在后台重试、当前未连上 ⇒ 面板读作「连接中」(`reason` 说明原因);
|
||||
* - `ready` → 已就绪、等待对端接入(server 模式监听已绑定)⇒ 不是失败。
|
||||
*
|
||||
* 两者都**缺省** = 该适配器没有这条状态面(绝大多数平台)⇒ 面板退回只看 `connected`,
|
||||
* 绝不凭空编一个状态。 */
|
||||
pending?: boolean;
|
||||
ready?: boolean;
|
||||
/** 未连上时的原因(已脱敏,绝不含凭据)。只有 `pending` 时才会出现。 */
|
||||
reason?: string;
|
||||
/** Configured accounts for this channel's platform (token masked). */
|
||||
accounts?: AdminChannelAccount[];
|
||||
}
|
||||
@@ -80,6 +93,12 @@ export interface AdminChannelAccount {
|
||||
* 缺省(undefined)= **状态未知**(该渠道没有独立账号状态面,或尚未探到结果),
|
||||
* 面板画「账号状态未知」,**绝不**画成「账号离线」。 */
|
||||
accountOnline?: boolean;
|
||||
/** 该账号适配器自报的连接态三态(可选,见 `AdminChannelRecord.pending`)。三者缺省 =
|
||||
* 该适配器没有这条状态面 ⇒ 退回只看 `connected`。 */
|
||||
pending?: boolean;
|
||||
ready?: boolean;
|
||||
/** 未连上时的原因(已脱敏)。只有 `pending` 时才会出现。 */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** The weixin-iLink scan-login QR payload (GET /admin/channels/{platform}/login-qr).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package adapter
|
||||
|
||||
// 连接**就绪三态**的取值与判定:一次 Connect 返回之后,适配器到底处于哪一种状态。
|
||||
//
|
||||
// 为什么必须三态而不是一个 bool:`Connect` 返回 nil 只说明「这次调用没有致命失败」,
|
||||
// 它至少覆盖三件互不相同的事——
|
||||
//
|
||||
// ConnectStateConnected —— 真连上了(client 拨号成功 / HTTP 传输已装配就绪);
|
||||
// ConnectStatePending —— 已进入后台重试、当前还没连上(OneBot 实现比 Cyrene 后
|
||||
// 启动是常态,首连失败刻意非致命);
|
||||
// ConnectStateReady —— 已绑定监听、就绪等待对端接入(ws+server 模式)。
|
||||
//
|
||||
// pending 与 ready 的 IsConnected() 都是 false,但它们**不是**「未连接」这样一个静态
|
||||
// 结论:前者是「正在连接」,后者是「就绪等接入」。用一个布尔去覆盖三态,管理面板就只能
|
||||
// 编一个说法——编出来的正是 2026-10-07 那个「点了连接就弹已连接,而卡片一直显示未连接」
|
||||
// 的谎。口径属于适配器(它才知道自己的 transport × mode 意味着什么),网关只做映射
|
||||
// (internal/services/gateway 的 connectOutcomeAware,经类型断言探测,核心契约不扩展)。
|
||||
//
|
||||
// 设计依据:DevDocs/design/08-渠道接入.md §1(PlatformAdapter 契约)与 §4.1(四端
|
||||
// transport × mode);立场与 design/26 §2.6「状态面必须区分 WS 连着与账号在线」一致 ——
|
||||
// 一个布尔说不了两件事,更说不了三件。
|
||||
//
|
||||
// 与 SessionState* / AccountStatus 同一口径:核心 PlatformAdapter 契约**刻意不扩展**,
|
||||
// 需要这条状态面的适配器自行实现该方法,网关用类型断言探测;没实现的适配器行为逐字不变。
|
||||
|
||||
const (
|
||||
// ConnectStateConnected 表示真连上:会话已建立(client 拨号成功),或传输已装配
|
||||
// 就绪而无可观察的长连接(HTTP)。此时 IsConnected() 应为 true。
|
||||
ConnectStateConnected = "connected"
|
||||
// ConnectStatePending 表示已进入后台重试、当前尚未连上。它既不是配置错误,也不是
|
||||
// 「未连接」,而是「连接中」,Reason 应说明原因(脱敏,绝不含凭据)。
|
||||
ConnectStatePending = "pending"
|
||||
// ConnectStateReady 表示已就绪、等待对端接入:server 模式已绑定监听,但还没有
|
||||
// 对端连上来。IsConnected() 此时为 false,却**不是失败**,因此没有 Reason。
|
||||
ConnectStateReady = "ready"
|
||||
)
|
||||
|
||||
// ConnectSignals 是判定三态所需的**全部**输入。用结构体而不是几个相邻的布尔参数,
|
||||
// 一是让调用点自解释,二是避免两个 bool 被接反(那会静默地骗过所有测试)。
|
||||
type ConnectSignals struct {
|
||||
// Started 报告 Connect 是否已成功启动该适配器;false 表示尚未启动,无从判断。
|
||||
Started bool
|
||||
// Connected 报告当前是否有一条活的会话。
|
||||
Connected bool
|
||||
// Retrying 报告未连上时后台是否有重试循环在跑(ws+client 有一条;HTTP 没有会话
|
||||
// 可重连,不算)。
|
||||
Retrying bool
|
||||
// Listening 报告适配器是否已绑定监听、在等对端接入(server 模式)。
|
||||
Listening bool
|
||||
// Reason 是最近一次失败的原因(**已脱敏**);没有失败或已连上时为空串。
|
||||
Reason string
|
||||
}
|
||||
|
||||
// ConnectStateOf 是**纯函数**:由信号判定三态与原因。它不读时钟、不触网、不加锁,
|
||||
// 因此三态可以逐条单测(见 connect_outcome_test.go);适配器侧只负责在同一把锁下把
|
||||
// 信号读齐。
|
||||
//
|
||||
// 判定顺序(自上而下,先匹配者胜):
|
||||
//
|
||||
// !Started → ("", "") —— 未启动的适配器无从判断:回「无意见」而不是
|
||||
// 随便认一个状态,调用方据此让可选键整键缺席。
|
||||
// Connected → ConnectStateConnected —— 有活会话就是真连上,先于其它分支。
|
||||
// Retrying → ConnectStatePending —— 尚未连上但重试在跑,这才是「连接中」。
|
||||
// Listening → ConnectStateReady —— 监听已就绪、等对端接入,不是失败。
|
||||
// 否则 → ("", "") —— 已启动、未连上、也没有可解释的就绪面:**不编**
|
||||
// 状态,让调用方退回只看 IsConnected()。
|
||||
//
|
||||
// 只有 pending 会带 Reason:connected 没有任何坏消息要说,ready 本来就不是坏消息。
|
||||
func ConnectStateOf(sig ConnectSignals) (state, reason string) {
|
||||
switch {
|
||||
case !sig.Started:
|
||||
return "", ""
|
||||
case sig.Connected:
|
||||
return ConnectStateConnected, ""
|
||||
case sig.Retrying:
|
||||
return ConnectStatePending, sig.Reason
|
||||
case sig.Listening:
|
||||
return ConnectStateReady, ""
|
||||
default:
|
||||
return "", ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// 本文件只测连接态三态的**纯判定**(ConnectStateOf):它不读时钟、不触网、不加锁,
|
||||
// 因此三态可以逐条单测。适配器侧的落点(qq_obv11 的 ConnectOutcome 与脱敏)见
|
||||
// plugins/channels/qq_obv11/connect_outcome_test.go,网关侧的映射见
|
||||
// internal/services/gateway/admin_channel_connect_outcome_test.go。
|
||||
package adapter
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestConnectStateOfNotStartedHasNoOpinion 断言未启动的适配器**不编状态**:回空串,
|
||||
// 调用方据此退回只看 IsConnected(),而不是随便认一个。
|
||||
func TestConnectStateOfNotStartedHasNoOpinion(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, sig := range []ConnectSignals{
|
||||
{},
|
||||
{Connected: true},
|
||||
{Retrying: true, Reason: "boom"},
|
||||
{Listening: true},
|
||||
} {
|
||||
if state, reason := ConnectStateOf(sig); state != "" || reason != "" {
|
||||
t.Errorf("ConnectStateOf(%+v) = (%q,%q), want 无意见", sig, state, reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectStateOfConnectedWins 断言真连上优先于其它分支(监听与重试信号同时为真
|
||||
// 也不该改变结论),且**不**带原因 —— 「已连接」旁边不该挂一条过期的失败原因。
|
||||
func TestConnectStateOfConnectedWins(t *testing.T) {
|
||||
t.Parallel()
|
||||
state, reason := ConnectStateOf(ConnectSignals{
|
||||
Started: true, Connected: true, Retrying: true, Listening: true, Reason: "stale failure",
|
||||
})
|
||||
if state != ConnectStateConnected || reason != "" {
|
||||
t.Fatalf("ConnectStateOf = (%q,%q), want (%q,\"\")", state, reason, ConnectStateConnected)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectStateOfRetryingIsPending 断言「未连上但重试在跑」是 pending 且带上原因。
|
||||
// 这正是 2026-10-07 事故里被写成「已连接」(或静态「未连接」)的那一格。
|
||||
func TestConnectStateOfRetryingIsPending(t *testing.T) {
|
||||
t.Parallel()
|
||||
state, reason := ConnectStateOf(ConnectSignals{
|
||||
Started: true, Retrying: true, Reason: "dial tcp 127.0.0.1:10301: connection refused",
|
||||
})
|
||||
if state != ConnectStatePending {
|
||||
t.Fatalf("state = %q, want %q", state, ConnectStatePending)
|
||||
}
|
||||
if reason != "dial tcp 127.0.0.1:10301: connection refused" {
|
||||
t.Fatalf("reason = %q, want the failure reason verbatim", reason)
|
||||
}
|
||||
// 三个取值互不相同:把 pending 写成 connected 是这次要根除的谎。
|
||||
if ConnectStatePending == ConnectStateConnected || ConnectStatePending == ConnectStateReady {
|
||||
t.Fatal("pending 的取值必须与 connected / ready 都不同")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectStateOfListeningIsReady 断言「已绑定监听、等对端接入」是 ready 而不是
|
||||
// pending:它不是失败,因此**没有**原因可说(哪怕结构体里带着一条陈旧的 reason)。
|
||||
func TestConnectStateOfListeningIsReady(t *testing.T) {
|
||||
t.Parallel()
|
||||
state, reason := ConnectStateOf(ConnectSignals{
|
||||
Started: true, Listening: true, Reason: "should not be surfaced",
|
||||
})
|
||||
if state != ConnectStateReady || reason != "" {
|
||||
t.Fatalf("ConnectStateOf = (%q,%q), want (%q,\"\")", state, reason, ConnectStateReady)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectStateOfUnexplainedStaysSilent 断言「已启动、未连上、也没有可解释的就绪面」
|
||||
// 时**不编**状态:既不说「正在连接」(不知道有没有在连),也不说「就绪」。
|
||||
func TestConnectStateOfUnexplainedStaysSilent(t *testing.T) {
|
||||
t.Parallel()
|
||||
if state, reason := ConnectStateOf(ConnectSignals{Started: true, Reason: "orphan"}); state != "" || reason != "" {
|
||||
t.Fatalf("ConnectStateOf = (%q,%q), want 无意见", state, reason)
|
||||
}
|
||||
}
|
||||
@@ -157,6 +157,20 @@ type MemoryTidyTuning struct {
|
||||
// 两端都没配 ⇒ 代码默认 23:00-07:00。
|
||||
QuietStartHour *int `json:"quietStartHour"`
|
||||
QuietEndHour *int `json:"quietEndHour"`
|
||||
|
||||
// TempTtlSec 是**临时记忆**的存活秒数(第五态 `defer` 写进 `expires_at` 的
|
||||
// TTL,design/23 §18.1 / design/03 §9.6)。nil = 没配 ⇒ 代码默认
|
||||
// **604800 秒(7 天)**;写非正数 ⇒ 坏值,回落代码默认 + WARN
|
||||
// (一个 ≤0 的 TTL 会让每条临时记忆**落库即过期**,与「不过期」恰好相反)。
|
||||
//
|
||||
// 🔴 **它在 `.env` 里没有出处,也刻意不新增**(`.env` 已冻结,design/13
|
||||
// §2.1):所以这一键的实际生效链是「**面板 > 代码默认**」——「面板 > `.env` >
|
||||
// 代码默认」里的中间那一档在 TTL 上是**结构性为空**,不是漏配。
|
||||
//
|
||||
// **指针 + `omitempty`** 的理由与 AutoApply 同源、但更强一层:nil = 没配,
|
||||
// 而没配时该键从**播种文件**里**整键省略**(播种文档明禁写 `null` —— 一份
|
||||
// 带 null 的配置对主人是噪音)。只有面板/人工显式写了才出现这个键。
|
||||
TempTtlSec *int `json:"tempTtlSec,omitempty"`
|
||||
}
|
||||
|
||||
// AutoApplyOff 报告自动档是否被这份配置**显式关掉**。nil(没配)返回 false ——
|
||||
|
||||
@@ -59,7 +59,7 @@ const (
|
||||
var memoryTidySeedKeys = []string{
|
||||
"autoApply", "autoApplyPerRound", "ingestGate", "t1Judge",
|
||||
"t1PerWindow", "t1WindowSec", "intervalSec", "quietIntervalSec",
|
||||
"quietStartHour", "quietEndHour",
|
||||
"quietStartHour", "quietEndHour", "tempTtlSec",
|
||||
}
|
||||
|
||||
// MemoryTidySeed 是一次播种的可诊断产物(口径对齐 materialize.go 的 Result:
|
||||
@@ -189,6 +189,13 @@ func memoryTidySeedComplete(seed MemoryTidyTuning) error {
|
||||
if seed.QuietEndHour == nil {
|
||||
missing = append(missing, "quietEndHour")
|
||||
}
|
||||
// tempTtlSec 同样要求非 nil:它是 `*int` + `omitempty`,**nil 会让该键从播种
|
||||
// 文件里消失**,于是「播种文件与当时的生效值逐字一致」这句话就不再成立 ——
|
||||
// 文件少一个键、读回来却是代码默认值,主人打开文件无从判断到底配没配。
|
||||
// 快照侧(memory.TidyTuningSnapshot)恒填它,因此这条不会拦住正常路径。
|
||||
if seed.TempTtlSec == nil {
|
||||
missing = append(missing, "tempTtlSec")
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("runtimeconfig: memory tidy seed missing keys: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
@@ -213,5 +220,12 @@ func memoryTidySeedComplete(seed MemoryTidyTuning) error {
|
||||
if *seed.QuietEndHour < 0 || *seed.QuietEndHour > 23 {
|
||||
return fmt.Errorf("runtimeconfig: memory tidy seed quietEndHour = %d (want 0..23)", *seed.QuietEndHour)
|
||||
}
|
||||
// tempTtlSec(临时记忆 TTL,design/03 §9.6)的**非 nil 要求**见上面 missing
|
||||
// 那一段(理由:nil 会让键从文件里消失,而播种要「与生效值逐字一致」)。
|
||||
// 这里再加一道值域:≤0 的 TTL 会让每条临时记忆**落库即过期**(与「不过期」
|
||||
// 恰好相反),播出去等于播了一份自己读回都要 WARN 的配置。
|
||||
if *seed.TempTtlSec <= 0 {
|
||||
return fmt.Errorf("runtimeconfig: memory tidy seed tempTtlSec = %d (want > 0)", *seed.TempTtlSec)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -24,9 +24,14 @@ import (
|
||||
// 用例全部落在**临时目录**(`t.TempDir()`):生产那份 `data/config/memory-tidy.json`
|
||||
// 是主人手工建的,播种逻辑只在文件缺失时触发,用例不许依赖它存在与否。
|
||||
|
||||
// completeSeedTuning 造一份「十键齐全」的播种内容。值刻意都**不是**代码默认值
|
||||
// (autoApply=false / 3600 / 10800),这样「播的是调用方给的快照」与「播了一份硬编码
|
||||
// 的默认值」在断言里一眼可分。
|
||||
// completeSeedTuning 造一份「十一键齐全」的播种内容。值刻意都**不是**代码默认值
|
||||
// (autoApply=false / 3600 / 10800 / tempTtlSec=259200),这样「播的是调用方给的
|
||||
// 快照」与「播了一份硬编码的默认值」在断言里一眼可分。
|
||||
//
|
||||
// 它必须给出**全部** `memoryTidySeedKeys`(含 tempTtlSec):该键带 `omitempty`,
|
||||
// nil 时会被整键省略 —— 那正是「没配 ⇒ 用代码默认」的正常形态,但本用例要验的是
|
||||
// 「键齐全时逐一落盘」,因此这里显式给值(见 TestSeedMemoryTidy_SeedsMissingFile
|
||||
// 的键集合断言)。
|
||||
func completeSeedTuning() MemoryTidyTuning {
|
||||
return MemoryTidyTuning{
|
||||
AutoApply: boolSeedPtr(false),
|
||||
@@ -39,6 +44,7 @@ func completeSeedTuning() MemoryTidyTuning {
|
||||
QuietIntervalSec: intSeedPtr(10800),
|
||||
QuietStartHour: intSeedPtr(23),
|
||||
QuietEndHour: intSeedPtr(7),
|
||||
TempTtlSec: intSeedPtr(259200),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -278,3 +280,54 @@ func TestLoadMemoryTidyTuning_ScheduleKeys(t *testing.T) {
|
||||
t.Fatalf("intervalSec 写成字符串应当返回 ErrInvalidMemoryTidy,得到 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadMemoryTidyTuning_TempTtlSec 锁定**临时记忆 TTL**这一键(design/03 §9.6:
|
||||
// 默认 7 天 = 604800 秒,放 `$DATA_DIR/config/memory-tidy.json`,**零新 env 键**)。
|
||||
//
|
||||
// 三条口径与既有指针键同源、但多一条 `omitempty`:
|
||||
//
|
||||
// ① 没写 ⇒ nil(「没配」⇒ 上层用代码默认,**不是**「配成 0」);
|
||||
// ② 写正数 ⇒ 原值读出;
|
||||
// ③ 写 0 ⇒ **仍是「有值」**(0 由 memory 侧判为坏值并回落 + WARN,本层不拦 ——
|
||||
// 与 intervalSec 同一口径:读取层只管形状,好坏由归一化层判);
|
||||
// ④ nil 时**序列化整键省略**(`omitempty`):播种文档明禁写 `null`,一份带 null
|
||||
// 的配置对主人是噪音。
|
||||
func TestLoadMemoryTidyTuning_TempTtlSec(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// ① 没写 ⇒ nil。
|
||||
bare, err := LoadMemoryTidyTuning(writeMemoryTidyFile(t, `{"autoApply": true}`))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMemoryTidyTuning(bare): %v", err)
|
||||
}
|
||||
if bare.TempTtlSec != nil {
|
||||
t.Fatalf("没写 tempTtlSec 应当是 nil(= 用代码默认),得到 %v", *bare.TempTtlSec)
|
||||
}
|
||||
|
||||
// ② 写正数 ⇒ 原值。
|
||||
got, err := LoadMemoryTidyTuning(writeMemoryTidyFile(t, `{"tempTtlSec": 259200}`))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMemoryTidyTuning(tempTtlSec): %v", err)
|
||||
}
|
||||
if got.TempTtlSec == nil || *got.TempTtlSec != 259200 {
|
||||
t.Fatalf("tempTtlSec = %v, want 259200", got.TempTtlSec)
|
||||
}
|
||||
|
||||
// ③ 写 0 ⇒ 仍是「有值」(好坏由 memory 侧的归一化判)。
|
||||
zero, err := LoadMemoryTidyTuning(writeMemoryTidyFile(t, `{"tempTtlSec": 0}`))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMemoryTidyTuning(zero): %v", err)
|
||||
}
|
||||
if zero.TempTtlSec == nil || *zero.TempTtlSec != 0 {
|
||||
t.Fatalf("显式写 0 的 tempTtlSec 应当被读出来(好坏由上层判),得到 %v", zero.TempTtlSec)
|
||||
}
|
||||
|
||||
// ④ nil ⇒ 序列化时整键省略(不会写出 `"tempTtlSec": null`)。
|
||||
raw, err := json.Marshal(MemoryTidyTuning{})
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal: %v", err)
|
||||
}
|
||||
if strings.Contains(string(raw), "tempTtlSec") {
|
||||
t.Fatalf("nil 的 tempTtlSec 不该被序列化出来(omitempty),得到 %s", raw)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 本文件是**提取留存规则闸**(design/03 §10,即 design/23 §15.1 D2 点名的
|
||||
// 「另立规格」)的 A 级实现:**纯规则 + 词项匹配**,对一条**刚落库的**新记忆判
|
||||
// 「它是不是结构上显然不值得长期留存」(design/03 §10.5 的反例清单 N1~N5)。
|
||||
//
|
||||
// 它回应主人的两条原话:「**收窄提取器,确实要给他一个主动式的记忆留存规则,
|
||||
// 不能想到什么都往记忆里写**」与「**临时记忆到期删除**」。
|
||||
//
|
||||
// 🔴 落点硬约束(design/03 §10.3):**不许新造第二条写路径**。本闸**不新开入口**,
|
||||
// 而是接进既有的唯一写路径 —— `judgeIngest`(preinsert_judge.go)在**候选比对
|
||||
// 之前**先过一遍这里,命中即返回与 design/23 §18.1 第五态**同一枚举 `defer`、
|
||||
// 同一落点 `expires_at`**。于是「提取得宽」由**同一条**入库路径收敛,不存在
|
||||
// 绕过闸的写口;命中者**不进 T1**(§10.6:规则闸拦下的不进 T1,避免「模型推翻
|
||||
// 规则」的架构回归)。
|
||||
//
|
||||
// 判据为什么必须是纯函数而不是提示词(design/03 §10.2):现行全仓唯一的留存判据
|
||||
// 是一句自然语言(`llmextractor.go` 的 extractSystemPrompt),那**违反 AGENTS §9
|
||||
// 「判据靠元数据,不靠提示词」**,且**不可验收**(提示词产出是自由文本,测试只能
|
||||
// 断言「模型有没有听话」)。B 级提示词加固仍在做(正例 + 反例清单进 prompt,
|
||||
// 见 llmextractor.go),但**验收防线在本文件**。
|
||||
//
|
||||
// 🔴 **fail-safe 方向 = 宁可多留,不可误弃**(design/23 §18.3)。因此每一条规则都
|
||||
// 写得**保守**:宁可有噪声漏过闸(那条走 T1 / 长期层),也不把一条真记忆判成临时
|
||||
// 层(到期会被删)。落实为两道**护栏**:
|
||||
//
|
||||
// 1. **正向清单一票否决**:内容里出现任何「长期事实 / 稳定偏好 / 关系里程碑 /
|
||||
// 项目约定」的信号(复用 tidyHabitMarkers 的「持久」词族 + 本文件的正向词),
|
||||
// 直接放行 —— 「主人喜欢用喵打招呼」虽然短,但它有「喜欢」,不是寒暄;
|
||||
// 2. **显式「记住」一票否决**:内容里出现「记住 / 记下 / 别忘了」时放行 ——
|
||||
// 主人明确要求记的东西,闸不许替他改成临时记忆。
|
||||
//
|
||||
// 一处**刻意不实现**(如实登记,不是遗漏):design/03 §10.5 的 **N6(助手自己的
|
||||
// 输出 / 发言人归属)**。它是**来源属性**(这句话是谁说的),不是内容属性;本闸
|
||||
// 只拿到落库条目(结构化溯源里没有「谁说的」这一位),无法机器判定。归属由 B 级
|
||||
// prompt 的负例承担(「不要记助手自己说过的话」),上游提取侧若要硬判,需另立
|
||||
// 结构化字段 —— 那超出本切片授权。**N7(不确定推断)**同理交给 T1(§10.5 明写
|
||||
// 「拿不准交第五态」),不进本闸。
|
||||
|
||||
// retentionRule 是一条提取留存反例规则的匹配结果(留痕/日志用;`retentionNone`
|
||||
// 表示「不是显然不值得记的」)。
|
||||
type retentionRule string
|
||||
|
||||
const (
|
||||
// retentionNone 表示没有任何反例规则命中 ⇒ 照常走既有判定(可能长期留存)。
|
||||
retentionNone retentionRule = ""
|
||||
// retentionChatter 对 design/03 §10.5 的 **N1 寒暄 / 致谢**。
|
||||
retentionChatter retentionRule = "n1_chatter"
|
||||
// retentionEmotion 对应 **N2 情绪波动**。
|
||||
retentionEmotion retentionRule = "n2_emotion"
|
||||
// retentionOneOffAsk 对应 **N3 一次性琐事**(祈使 + 无持续实体)。
|
||||
retentionOneOffAsk retentionRule = "n3_one_off_ask"
|
||||
// retentionTimelyData 对应 **N4 时效性数据**(当前时间 / 天气)。
|
||||
retentionTimelyData retentionRule = "n4_timely_data"
|
||||
// retentionOneOffTask 对应 **N5 一次性任务指令**。
|
||||
retentionOneOffTask retentionRule = "n5_one_off_task"
|
||||
)
|
||||
|
||||
// retentionLowValueReason 是第五态在**留痕 `reason` 列**上的编码(而不是上面那些
|
||||
// 规则码)。它是刻意的:design/23 §18.4 把 `reason` 的闭集钉成
|
||||
// `{t0_low_value, t1_defer, …}` —— 面板与运维按它分「谁判的 defer」,而**具体命中
|
||||
// 哪条反例规则**属于更细的观测面,进日志、不进留痕列(留痕表的列宽与语义不改)。
|
||||
const retentionLowValueReason = "t0_low_value"
|
||||
|
||||
// 规则的**保守阈值**:只对「短到装不下一个事实」的内容开火。
|
||||
const (
|
||||
// retentionMaxChatterRunes 是「只可能是寒暄」的长度上限(rune 计,不是字节:
|
||||
// 中文 1 rune = 3 字节,按字节算会把阈值放大三倍)。
|
||||
retentionMaxChatterRunes = 16
|
||||
// retentionMaxEmotionRunes 是纯情绪陈述的长度上限。
|
||||
retentionMaxEmotionRunes = 24
|
||||
// retentionMaxImperativeRunes 是一次性指令的长度上限。它比前两者宽:
|
||||
// 「帮我把 D 盘那个文档转成 pdf」这类真·一次性任务本来就长一些。
|
||||
retentionMaxImperativeRunes = 40
|
||||
)
|
||||
|
||||
// judgeExtractionRetention 对一条新记忆的正文施加提取留存规则(design/03 §10.5
|
||||
// 的 N1~N5)。命中返回该规则码(调用方据此判 `defer`),否则返回 retentionNone。
|
||||
//
|
||||
// 它**不读任何状态、不写任何东西、零模型调用**:入参只有正文,出参只有规则码,
|
||||
// 因此在写路径上是微秒级且可完全单测(design/03 §10.3 A 级的「零成本」要求)。
|
||||
func judgeExtractionRetention(content string) retentionRule {
|
||||
text := normalizeRetentionText(content)
|
||||
if text == "" {
|
||||
// 空 / 纯空白内容在 Add 的 normalize 就被拒了,走不到这里;真走到了也只
|
||||
// 放行(**fail-safe**:闸的职责是「拦住显然不值得记的」,不是「替上游
|
||||
// 判断该不该存在」—— 判成 defer 才是替它做决定)。
|
||||
return retentionNone
|
||||
}
|
||||
if len([]rune(text)) > retentionMaxImperativeRunes {
|
||||
// 长内容一律放行:它已经装得下可检索的事实,闸只拦「显然不值得记」。
|
||||
return retentionNone
|
||||
}
|
||||
// 护栏①:显式「记住」是主人的直接指令,闸不许替他改判。
|
||||
if containsAny(text, retentionRememberDirectives()) {
|
||||
return retentionNone
|
||||
}
|
||||
// 护栏②:任何「持久」信号(习惯 / 稳定偏好 / 约定 / 长期事实)一票放行。
|
||||
if containsAny(text, tidyHabitMarkers()) || containsAny(text, retentionDurableMarkers()) {
|
||||
return retentionNone
|
||||
}
|
||||
// **情绪先于寒暄**是刻意的:「心情不错」既是情绪陈述、又含致谢词表里的
|
||||
// 「不错」——先判情绪才能给出更具体的规则码(两条规则都判 defer,差别只在
|
||||
// 「为什么」,而那正是留痕/日志要回答的)。
|
||||
if rule := judgeRetentionEmotion(text); rule != retentionNone {
|
||||
return rule
|
||||
}
|
||||
if rule := judgeRetentionChatter(text); rule != retentionNone {
|
||||
return rule
|
||||
}
|
||||
if rule := judgeRetentionTimelyData(text); rule != retentionNone {
|
||||
return rule
|
||||
}
|
||||
return judgeRetentionOneOff(text)
|
||||
}
|
||||
|
||||
// judgeRetentionChatter 施加 **N1**:短且只由寒暄/致谢词构成的内容。
|
||||
//
|
||||
// 「短」是这条规则的**安全阀**:寒暄的真实形态就是一句话里没有别的信息,
|
||||
// 一旦它长到装得下事实,就不该由本闸判(交 T1 / 长期层)。
|
||||
func judgeRetentionChatter(text string) retentionRule {
|
||||
if len([]rune(text)) > retentionMaxChatterRunes {
|
||||
return retentionNone
|
||||
}
|
||||
if !containsAny(text, retentionChatterWords()) {
|
||||
return retentionNone
|
||||
}
|
||||
// 带数字的短句一律放行:「7 点」「3 月 5 日」这类是事实的载体,
|
||||
// 哪怕句子再短(design/23 §5.2-2 把数值当硬门,同一个取向)。
|
||||
if hasRetentionDigit(text) {
|
||||
return retentionNone
|
||||
}
|
||||
return retentionChatter
|
||||
}
|
||||
|
||||
// judgeRetentionEmotion 施加 **N2**:短的情绪陈述(「今天好累」「心情不错」)。
|
||||
func judgeRetentionEmotion(text string) retentionRule {
|
||||
if len([]rune(text)) > retentionMaxEmotionRunes {
|
||||
return retentionNone
|
||||
}
|
||||
if !containsAny(text, retentionEmotionWords()) {
|
||||
return retentionNone
|
||||
}
|
||||
if hasRetentionDigit(text) {
|
||||
return retentionNone
|
||||
}
|
||||
return retentionEmotion
|
||||
}
|
||||
|
||||
// judgeRetentionTimelyData 施加 **N4**:时效性查询(当前时间 / 天气)。
|
||||
//
|
||||
// 它的词项是**整句形态**而不是单词(「几点」「天气」),因为「天气」单独出现可能
|
||||
// 是主人对天气的**稳定偏好**(「主人怕冷,阴天心情差」)—— 那种句子带持久信号,
|
||||
// 已被护栏②放行;这里只吃「现在几点」「明天天气怎么样」这类纯查询。
|
||||
func judgeRetentionTimelyData(text string) retentionRule {
|
||||
if containsAny(text, retentionTimelyQueries()) {
|
||||
return retentionTimelyData
|
||||
}
|
||||
return retentionNone
|
||||
}
|
||||
|
||||
// judgeRetentionOneOff 施加 **N3 / N5**:一次性的祈使请求(「帮我订个 7 点的闹钟」
|
||||
// 「把这个文件转成 pdf」)。
|
||||
//
|
||||
// 判据是「**以祈使词开头或含一次性动作词**」而不是「含动词」:后者会把
|
||||
// 「主人喜欢把这个文件转成 pdf」(稳定偏好)也拦下 —— 那种句子带持久信号,
|
||||
// 已被护栏②放行。
|
||||
func judgeRetentionOneOff(text string) retentionRule {
|
||||
if hasRetentionPrefix(text, retentionAskPrefixes()) {
|
||||
return retentionOneOffAsk
|
||||
}
|
||||
if containsAny(text, retentionOneOffActions()) {
|
||||
return retentionOneOffTask
|
||||
}
|
||||
return retentionNone
|
||||
}
|
||||
|
||||
// normalizeRetentionText 规范化正文供词项匹配:转小写(对 ASCII 词如 `hi` 有用,
|
||||
// 中文不受影响)、把连续空白折叠成**一个空格**、去掉结尾的标点。
|
||||
//
|
||||
// 空白**折叠**而不是**删除**是刻意的:删光会让相邻两个英文单词粘成一个词,
|
||||
// 于是 `as hi` 里冒出子串 `hi` 而被误判成寒暄 —— 折叠则保住词边界。
|
||||
// 词项表里因此同时列了 `转成 pdf` 与 `转成pdf` 两种写法。
|
||||
func normalizeRetentionText(content string) string {
|
||||
text := strings.ToLower(strings.TrimSpace(content))
|
||||
text = strings.Join(strings.Fields(text), " ")
|
||||
return strings.TrimRight(text, "。!?!?.,,、~~ ")
|
||||
}
|
||||
|
||||
// containsAny 报告 text 是否含 markers 里任一词项。
|
||||
func containsAny(text string, markers []string) bool {
|
||||
for _, m := range markers {
|
||||
if m != "" && strings.Contains(text, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hasRetentionPrefix 报告 text 是否以 prefixes 里任一词开头。
|
||||
func hasRetentionPrefix(text string, prefixes []string) bool {
|
||||
for _, p := range prefixes {
|
||||
if p != "" && strings.HasPrefix(text, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hasRetentionDigit 报告 text 是否含阿拉伯数字(数值是事实的载体,带数值即放行)。
|
||||
func hasRetentionDigit(text string) bool {
|
||||
for _, r := range text {
|
||||
if r >= '0' && r <= '9' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 下列词表一律是**函数**而不是包级变量(AGENTS §10 禁包级全局变量;
|
||||
// 同 tidySystemSessions / tidyHabitMarkers 的手法)。
|
||||
|
||||
// retentionRememberDirectives 是「主人明确要求记住」的信号(护栏①)。
|
||||
func retentionRememberDirectives() []string {
|
||||
return []string{"记住", "记下", "别忘了", "不要忘", "别忘记"}
|
||||
}
|
||||
|
||||
// retentionDurableMarkers 是正向清单(design/03 §10.4)的词族里、**不被
|
||||
// tidyHabitMarkers 覆盖**的那些:长期事实 / 关系里程碑 / 项目约定 / 能力边界。
|
||||
func retentionDurableMarkers() []string {
|
||||
return []string{
|
||||
// 长期事实
|
||||
"生日", "纪念日", "住", "职业", "工作", "家庭", "家人", "孩子", "父母",
|
||||
"过敏", "忌口", "健康状况", "疾病", "吃药", "身高", "体重", "年龄",
|
||||
// 关系里程碑 / 约定
|
||||
"约定", "承诺", "答应", "说好", "暗号", "口令", "称呼",
|
||||
// 项目约定
|
||||
"项目", "迁移号", "端口", "路径", "命名", "编号", "约定俗成",
|
||||
// 能力边界(主人明确的「不要做 X」)
|
||||
"不要做", "别做", "禁止", "不许",
|
||||
// 稳定偏好(补 tidyHabitMarkers 之外的)
|
||||
"口味", "爱好", "讨厌", "不喜欢", "偏爱", "嗜好",
|
||||
}
|
||||
}
|
||||
|
||||
// retentionChatterWords 是 N1 的寒暄 / 致谢词表。
|
||||
func retentionChatterWords() []string {
|
||||
return []string{
|
||||
"谢谢", "多谢", "感谢", "辛苦", "麻烦你了", "早上好", "早安", "中午好",
|
||||
"下午好", "晚上好", "晚安", "你好", "您好", "在吗", "收到", "好的",
|
||||
"好呀", "好嘞", "嗯嗯", "哈哈", "嘿嘿", "呵呵", "么么", "拜拜", "再见",
|
||||
"加油", "不错", "真棒", "太棒了", "hi", "hello", "hey", "thx", "thanks",
|
||||
}
|
||||
}
|
||||
|
||||
// retentionEmotionWords 是 N2 的情绪词表。
|
||||
func retentionEmotionWords() []string {
|
||||
return []string{
|
||||
"好累", "累了", "开心", "高兴", "难过", "不开心", "焦虑", "压力大",
|
||||
"心情", "疲惫", "难受", "无聊", "郁闷", "沮丧", "生气", "委屈",
|
||||
"烦死", "好烦", "烦躁", "兴奋", "紧张", "害怕", "孤单", "寂寞",
|
||||
}
|
||||
}
|
||||
|
||||
// retentionTimelyQueries 是 N4 的时效性**查询整句**词表(不是单词,理由见
|
||||
// judgeRetentionTimelyData)。
|
||||
func retentionTimelyQueries() []string {
|
||||
return []string{
|
||||
"现在几点", "几点了", "现在时间", "当前时间",
|
||||
"今天天气", "明天天气", "天气怎么样", "天气如何", "天气怎样",
|
||||
"温度多少", "多少度", "气温多少",
|
||||
}
|
||||
}
|
||||
|
||||
// retentionAskPrefixes 是 N3 的祈使开头词表。
|
||||
func retentionAskPrefixes() []string {
|
||||
return []string{
|
||||
"帮我", "请帮", "麻烦帮", "帮个忙", "提醒我", "叫我", "替我",
|
||||
"给我订", "给我买", "帮我查", "帮我搜", "帮我订", "帮我买",
|
||||
}
|
||||
}
|
||||
|
||||
// retentionOneOffActions 是 N5 的一次性动作词表(位置不限 —— 它们描述的是
|
||||
// 「转一次文件」这类有始有终的动作,而不是可以长期成立的偏好)。
|
||||
func retentionOneOffActions() []string {
|
||||
return []string{
|
||||
"转成pdf", "转成 pdf", "转换成", "截图", "打开这个", "关掉这个",
|
||||
"搜一下", "查一下", "翻译一下", "订个闹钟", "设个闹钟",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件是**提取留存规则闸**(extract_retention.go,design/03 §10)的验收用例集:
|
||||
// 正例(值得长期记 ⇒ 一条都不许被拦)+ 反例(显然不值得记 ⇒ 必须被拦成第五态)
|
||||
// 各远超 3 条,并逐条钉住**命中哪一条反例规则**(N1~N5)。
|
||||
//
|
||||
// 为什么正例与反例**一样重要**:本闸判错的方向不对称 —— 漏判只是「库里多一条
|
||||
// 噪声」(有 T1 与整理兜底),误判却会把一条真记忆丢进临时层(到期删除)。
|
||||
// design/23 §18.3 的 fail-safe 方向是「**宁可多留,不可误弃**」,所以「正例必须
|
||||
// 放行」是比「反例必须拦下」更硬的要求。
|
||||
//
|
||||
// 「闸真在起作用」而不是「恰好如此」的证据是 TestJudgeIngestDefersLowValueItem:
|
||||
// 它在**同一个判定链**上既断言低价值条目拿到 `defer`+`t0_low_value`,又断言它
|
||||
// **不进灰区**(于是 T1 一个 token 都花不出去);把闸摘掉时那条断言会立刻变红
|
||||
// (判成 create 且灰区非空)。
|
||||
|
||||
// retentionPositiveCases 是正向清单(design/03 §10.4)的脱敏样本与**两道护栏**
|
||||
// 的样本:它们必须**全部**放行(retentionNone)。
|
||||
func retentionPositiveCases() []struct {
|
||||
name string
|
||||
content string
|
||||
} {
|
||||
return []struct {
|
||||
name string
|
||||
content string
|
||||
}{
|
||||
{"长期事实", "主人住在杭州"},
|
||||
{"稳定偏好", "主人喜欢喝冰美式来提神"},
|
||||
{"习惯", "主人常在深夜与助手聊天,有熬夜习惯"},
|
||||
{"关系里程碑", "主人与助手约定验收暗号喵喵七号"},
|
||||
{"项目约定", "迁移号约定从 0023 起"},
|
||||
{"能力边界", "主人说过不要在群里提她的工作"},
|
||||
{"健康约束", "主人对花生过敏"},
|
||||
{"带数值的偏好", "主人每天要喝 2 杯咖啡"},
|
||||
// 护栏①:显式「记住」是主人的直接指令,短句也不许被拦。
|
||||
{"显式记住(短)", "记住主人周日去看牙医"},
|
||||
// 护栏②:短寒暄词出现,但整句带持久信号 ⇒ 放行。
|
||||
{"寒暄词+持久信号", "主人喜欢用喵打招呼"},
|
||||
// 长度安全阀:寒暄词出现,但句子长到装得下事实 ⇒ 放行。
|
||||
{"长句里的致谢", "谢谢你今天特地提醒我这件事,真是帮了大忙了"},
|
||||
}
|
||||
}
|
||||
|
||||
// retentionNegativeCases 是反例清单(design/03 §10.5)的脱敏样本,逐条钉住
|
||||
// 命中的规则码。
|
||||
func retentionNegativeCases() []struct {
|
||||
name string
|
||||
content string
|
||||
rule retentionRule
|
||||
} {
|
||||
return []struct {
|
||||
name string
|
||||
content string
|
||||
rule retentionRule
|
||||
}{
|
||||
{"N1 致谢", "谢谢", retentionChatter},
|
||||
{"N1 寒暄", "早上好呀", retentionChatter},
|
||||
{"N1 晚安", "晚安~", retentionChatter},
|
||||
{"N2 情绪", "今天好累", retentionEmotion},
|
||||
{"N2 情绪(带标点)", "心情不错!", retentionEmotion},
|
||||
{"N4 时效查询", "现在几点了", retentionTimelyData},
|
||||
{"N4 天气", "明天天气怎么样", retentionTimelyData},
|
||||
{"N3 一次性琐事", "帮我订个 7 点的闹钟", retentionOneOffAsk},
|
||||
{"N3 一次性提醒", "提醒我明天把快递取回来", retentionOneOffAsk},
|
||||
{"N5 一次性任务", "把这个文件转成 pdf", retentionOneOffTask},
|
||||
{"N5 一次性查询", "查一下明天的航班", retentionOneOffTask},
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractionRetention_PositivesAreNeverDeferred 钉住正向清单一票放行:
|
||||
// 任何一条正例被判成 defer(= 进临时层、到期删除)都是**记忆丢失**,
|
||||
// 因此这里逐条断言 retentionNone。
|
||||
func TestExtractionRetention_PositivesAreNeverDeferred(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range retentionPositiveCases() {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if rule := judgeExtractionRetention(tc.content); rule != retentionNone {
|
||||
t.Fatalf("正例 %q 被拦成 %q —— 那是把真记忆丢进临时层(fail-safe 方向被违反)",
|
||||
tc.content, rule)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractionRetention_NegativesAreDeferred 钉住反例清单:每条都必须命中
|
||||
// 它该命中的规则码(而不是「反正被拦了」——规则码要能解释**为什么**)。
|
||||
func TestExtractionRetention_NegativesAreDeferred(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range retentionNegativeCases() {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if rule := judgeExtractionRetention(tc.content); rule != tc.rule {
|
||||
t.Fatalf("反例 %q 命中 %q, want %q", tc.content, rule, tc.rule)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractionRetention_UpperCaseASCII 钉住 ASCII 寒暄词(中英混排):
|
||||
// 规范化会把 `HI` / `Thanks` 折成小写再匹配。
|
||||
func TestExtractionRetention_UpperCaseASCII(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, content := range []string{"HI", "Thanks", "hello~"} {
|
||||
if rule := judgeExtractionRetention(content); rule != retentionChatter {
|
||||
t.Fatalf("%q 命中 %q, want %q(ASCII 词应先折小写)", content, rule, retentionChatter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestJudgeIngestDefersLowValueItem 是**接线**断言:留存规则闸真的接在判定链上,
|
||||
// 命中的新条目拿到第五态 `defer` + 留痕原因码 `t0_low_value`,且**不进灰区**
|
||||
// (design/03 §10.6:规则闸拦下的不进 T1)。
|
||||
func TestJudgeIngestDefersLowValueItem(t *testing.T) {
|
||||
t.Parallel()
|
||||
item := habitItem("谢谢啦", tidyTestTime(t), 0.9)
|
||||
// 故意给一条「相似候选」:它本该进灰区,但留存规则先命中 ⇒ 灰区必须为空,
|
||||
// 于是 T1 一个 token 都花不出去。
|
||||
cands := []ingestCandidate{{item: habitItem("谢谢", tidyTestTime(t), 0.9), score: 0.9, mode: tidyRecallLexical}}
|
||||
v := judgeIngest(item, cands)
|
||||
if v.Decision != IngestDefer {
|
||||
t.Fatalf("低价值条目 decision = %q, want %q", v.Decision, IngestDefer)
|
||||
}
|
||||
if v.Reason != retentionLowValueReason {
|
||||
t.Fatalf("低价值条目 reason = %q, want %q", v.Reason, retentionLowValueReason)
|
||||
}
|
||||
if len(v.Gray) != 0 {
|
||||
t.Fatalf("低价值条目仍收集了 %d 条灰区候选 —— T1 会被白调一次(§10.6 不许)", len(v.Gray))
|
||||
}
|
||||
}
|
||||
|
||||
// TestJudgeIngestKeepsValuableItemAsCreate 是**变异用例**的对偶:同一条判定链上,
|
||||
// 值得长期留的条目仍然走既有四态(这里是 create),证明闸只拦「显然不值得记」。
|
||||
func TestJudgeIngestKeepsValuableItemAsCreate(t *testing.T) {
|
||||
t.Parallel()
|
||||
item := habitItem(fixtureHabitShort, tidyTestTime(t), 0.9)
|
||||
v := judgeIngest(item, nil)
|
||||
if v.Decision != IngestCreate {
|
||||
t.Fatalf("正常条目 decision = %q, want %q(闸不许误伤)", v.Decision, IngestCreate)
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,40 @@ import (
|
||||
// 以便 parseCandidates 确定性地反序列化。溯源(source_conversation)
|
||||
// 刻意不在其中:它由持久化层盖真实 session/message id,绝不由模型生成
|
||||
// (裁决 04.4.3,见 source.go)。
|
||||
const extractSystemPrompt = `你是记忆提取器。从对话中提取值得长期记住的记忆项(用户偏好、习惯、约定、重要事件)。
|
||||
//
|
||||
// 2026-10-13(design/03 §10.3 的 **B 级:提示词加固**):prompt 里补上
|
||||
// **正向清单一句话 + 反例清单一句话 + 明确允许返回 `[]`**。回应主人原话
|
||||
// 「**收窄提取器,确实要给他一个主动式的记忆留存规则,不能想到什么都往记忆里
|
||||
// 写**」。
|
||||
//
|
||||
// 🔴 为什么提示词**只是辅助**(design/03 §10.2):提示词产出是自由文本,**不可
|
||||
// 验收**(测试只能断言「模型有没有听话」),且判据落在提示词上违反 AGENTS §9
|
||||
// 「判据靠元数据,不靠提示词」。**真正的防线是 A 级规则闸**(extract_retention.go
|
||||
// → judgeIngest,纯函数、可单测、在唯一写路径上);这里的两份清单是**同源口径的
|
||||
// 第二道**,让模型与规则闸朝同一个方向使劲,而不是各写各的。
|
||||
//
|
||||
// 「允许返回 `[]`」是 Mem0 官方口径(design/23 §2:官方原话「Include examples
|
||||
// that should produce []」)—— 不写这一句,模型会为了「有产出」而硬凑。
|
||||
const extractSystemPrompt = `你是记忆提取器。从对话中提取**值得长期记住**的记忆项。
|
||||
|
||||
只保留这些(正向清单):
|
||||
- 长期事实:身份、住址、职业、家庭成员、健康约束(过敏、忌口、长期用药)。
|
||||
- 稳定偏好:口味、作息、喜欢什么、讨厌什么。
|
||||
- 关系里程碑:约定、承诺、共同经历、纪念日、称呼习惯。
|
||||
- 项目约定:编号规则、暗号、路径、命名约定。
|
||||
- 能力边界:主人明确说过的「不要做 X」。
|
||||
|
||||
下面这些**不要提取**(反例清单):
|
||||
- 寒暄与致谢:「谢谢」「早上好」「辛苦啦」「晚安」。
|
||||
- 情绪波动:「今天好累」「心情不错」。
|
||||
- 一次性琐事与任务指令:「帮我订个 7 点的闹钟」「把这个文件转成 pdf」。
|
||||
- 时效性数据:当前时间、天气、「现在几点了」。
|
||||
- 助手自己说过的话 —— 只提取主人这边的长期事实与偏好。
|
||||
- 不确定的推断(「他可能喜欢 X」):拿不准就不要提取。
|
||||
|
||||
**允许返回空数组 []**:一轮对话里没有值得长期记住的内容时,正确做法就是回
|
||||
[],不要为了凑数而提取。
|
||||
|
||||
只输出一个 JSON 数组,不要任何其他文字。每个元素形如:
|
||||
{"content":"记忆内容","scope":"user|persona|project","kind":"relationship|world_event","tags":["标签"],"confidence":0.9,"project_id":"可选项目id"}
|
||||
去噪去重:只保留值得长期记住的,忽略寒暄与临时信息。`
|
||||
|
||||
@@ -35,6 +35,14 @@ import (
|
||||
// 调用还要先过**全局限速**(默认 120 次/小时,preinsert_t1_limit.go):
|
||||
// 超限即跳过 T1、回落 create + `t1_throttled`。
|
||||
// **同步入库闸(preinsert_gate.go)不碰 T1**,写入路径因此仍是零模型调用。
|
||||
// 4. **第五态 `defer` 的判定在两层各有一处来源**(design/23 §18.2):
|
||||
// **T0 提取留存规则闸**(`extract_retention.go` → `judgeIngest`,纯规则、
|
||||
// 零模型)拦「结构上显然不值得留」的(`reason=t0_low_value`);**T1 则把
|
||||
// defer 收进同一个闭集**(`merge/modify/keep/defer`,**同一次调用**、
|
||||
// 同一个 prompt、同一套候选编号),只在关系判为「真新」时才问「值不值得
|
||||
// 长期留」(`reason=t1_defer`)。**绝不新增第二次模型调用**。
|
||||
// 两条失败路径都回落 `create`(= 当作长期记住),fail-safe 方向恒为
|
||||
// 「**宁可多留,不可误弃**」(§18.3)。
|
||||
//
|
||||
// 代价(如实登记):极短窗口内库里会多一行待裁定的重复条目,随后被软合并
|
||||
// (status='merged' + merged_into,可 revert,绝不物理删除,design §8)。
|
||||
@@ -55,6 +63,19 @@ const (
|
||||
// IngestCreate 表示确属新条目(互不包含 / 被规则否决 / 没有召回):
|
||||
// 正常新建,不做任何改写(§15 D3-2 T0-3)。
|
||||
IngestCreate IngestDecision = "create"
|
||||
// IngestDefer 是**第五态**(design/23 §18.1):与既有记忆不冲突(真新),
|
||||
// 但不值得长期留存 ⇒ **照常插入**,只把 `expires_at` 写成「现在 + 临时 TTL」
|
||||
// (默认 7 天,见 tempTtlSec)落进**临时层**:到期由读侧时效闸隐藏
|
||||
// (design/03 §9.6)、再由后台清扫物理删除(design/23 §19)。
|
||||
//
|
||||
// 它与其余四态的正交关系(§18.2 的不变式):`defer` ≡「关系判为**真新**
|
||||
// ∧ 价值判为**短期**」。因此它**只在被判为真新时**才可能产生 —— 已被既有
|
||||
// 记忆代表的那条(merge/modify/skip)留不留由既有行决定,与价值判定无关。
|
||||
//
|
||||
// 🔴 仍是**软动作**:只写 `expires_at` 一列,**不删行、不改正文**;回滚 =
|
||||
// 清空该列(§18.4)。选 `defer` 而非 `drop` 是 fail-safe 取向的落点
|
||||
// (§18.3「宁可多留,不可误弃」):最坏后果只是「多留 7 天」。
|
||||
IngestDefer IngestDecision = "defer"
|
||||
)
|
||||
|
||||
// IngestRuleVersion 是入库前审查规则集的版本号,进每条审查留痕(§15 D3-8)。
|
||||
@@ -65,7 +86,11 @@ const (
|
||||
// - `d3.2` 加入 **T1 灰区模型判定**(preinsert_t1.go,design/23 §15.1 D3-2):
|
||||
// 灰区(相似、互不包含、未被规则否决)在模型出口接上时会被改判成
|
||||
// merge/modify,因此 `reason` 上出现了 `t1_*` 族。
|
||||
const IngestRuleVersion = "d3.2"
|
||||
// - `d3.3` 加入 **第五态 `defer`**(design/23 §18.1):T0 提取留存规则闸
|
||||
// (`t0_low_value`)与 T1 的同一次调用(`t1_defer`)都能给出它,落点 =
|
||||
// 照常插入 + 写短 `expires_at`。**升版是硬要求**:同一对条目在 d3.2 下是
|
||||
// `create`、在 d3.3 下可能是 `defer`,不升版新旧留痕无法区分(§18.4)。
|
||||
const IngestRuleVersion = "d3.3"
|
||||
|
||||
// 调优旋钮(ReviewConfig)与它们的默认值常量在 preinsert_config.go。
|
||||
|
||||
@@ -402,6 +427,16 @@ func (r *PreInsertReviewer) applyVerdict(ctx context.Context, v ingestVerdict, i
|
||||
if v.Decision == IngestCreate {
|
||||
return nil, nil
|
||||
}
|
||||
if v.Decision == IngestDefer {
|
||||
// 第五态(design/23 §18.1):条目**早已落库**(异步审查跑在写入之后),
|
||||
// 这里只把 `expires_at` 写成「现在 + 临时 TTL」把它放进临时层。
|
||||
// **不删行、不改正文、不碰任何其它行** ⇒ 回滚 = 清空该列(§18.4)。
|
||||
// 写不进去就如实返回错误(fail-safe:行留在长期层 = 宁可多留)。
|
||||
if err := r.deps.Store.setIngestExpiry(ctx, item.ID, expiryText(r.deferDeadline(now))); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
if v.Absorbed.ID == "" || v.Existing.ID == "" {
|
||||
return nil, fmt.Errorf("memory/review: %s without a matched existing item", v.Decision)
|
||||
}
|
||||
|
||||
@@ -36,6 +36,16 @@ const (
|
||||
// 留痕 `t1_throttled`,记忆一条不丢、一个字不改。
|
||||
defaultT1PerWindow = 120
|
||||
defaultT1Window = 60 * time.Minute
|
||||
|
||||
// tempTtlSec 是**临时记忆**(第五态 `defer`,design/23 §18.1)的默认存活秒数:
|
||||
// **7 天 = 604800 秒**。它是 design/03 §9.6 [替主人定 · Q3] 定死的代码默认值
|
||||
// (设计稿点名的常量名就是 `tempTtlSec = 604800`)。
|
||||
//
|
||||
// 为什么「多久算临时」不进代码死值:7 天是保守默认,而它本质是**主人的口味**
|
||||
// 而不是判据 ⇒ 面板可调,走 `$DATA_DIR/config/memory-tidy.json` 的
|
||||
// `tempTtlSec`(与既有整理旋钮同一个文件、同一套「面板 > 代码默认」优先级,
|
||||
// 见 ApplyReviewTuning)。**`.env` 不加键**(已冻结,design/13 §2.1)。
|
||||
tempTtlSec = 604800
|
||||
)
|
||||
|
||||
// ReviewConfig 携带入库前审查的调优旋钮。零值经 normalize 选取安全默认值。
|
||||
@@ -99,6 +109,16 @@ type ReviewConfig struct {
|
||||
T1PerWindow int
|
||||
// T1Window 是 T1PerWindow 的时间窗。非正用代码默认值 defaultT1Window。
|
||||
T1Window time.Duration
|
||||
// TempTTL 是**临时记忆**的存活时长(design/23 §18.1 第五态 `defer` /
|
||||
// design/03 §9.6):`defer` 命中时,落库行的 `expires_at` = 当时 + TempTTL。
|
||||
// 非正用代码默认值 **7 天**(tempTtlSec = 604800)。
|
||||
//
|
||||
// 🔴 **非正绝不放行**(design/03 §9.6 的明文口径):一个 ≤0 的 TTL 会让每条
|
||||
// defer 记忆**落库即过期**(读侧立刻隐藏、后台清扫下一轮就删)—— 那是坏配置
|
||||
// 而不是「不过期」,与既有 `panelPositiveSec` 同一口径(tidy_tuning.go)。
|
||||
// 因此面板写了坏值时由 ApplyReviewTuning 记 WARN 并**不覆盖**默认值,这里
|
||||
// 再兜一道(任何绕过解析器的构造路径同样拿不到 ≤0)。
|
||||
TempTTL time.Duration
|
||||
}
|
||||
|
||||
// normalizeReviewConfig 用设计稿默认值填充零值字段。
|
||||
@@ -133,5 +153,11 @@ func normalizeReviewConfig(cfg ReviewConfig) ReviewConfig {
|
||||
if cfg.T1Window <= 0 {
|
||||
cfg.T1Window = defaultT1Window
|
||||
}
|
||||
// 临时记忆的 TTL 同样在这里回落(design/03 §9.6:「坏值(≤0)绝不放行」)。
|
||||
// 面板侧的那道 WARN 在 ApplyReviewTuning;这里是把「未归一的 ≤0 直达
|
||||
// 判据」这条静默失败形态堵死 —— 与 T1 限流两项同一教训。
|
||||
if cfg.TempTTL <= 0 {
|
||||
cfg.TempTTL = time.Duration(tempTtlSec) * time.Second
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
)
|
||||
|
||||
// 本文件是**第五态 `defer`**(design/23 §18)与**临时 TTL 配置面**
|
||||
// (design/03 §9.6)的验收用例集。它逐条覆盖任务书的验收点:
|
||||
//
|
||||
// 1. defer 落库带**短** `expires_at`(闸路径 + 异步 T1 路径);
|
||||
// 2. 写进去的 `expires_at` 与读侧时效闸**同一套规范文本**
|
||||
// (定宽 30 字节 UTC —— 写错布局会让闸静默判错,本仓有历史事故);
|
||||
// 3. TTL 坏值(≤0)**绝不放行**:回落代码默认 7 天 + WARN;
|
||||
// 4. `IngestRuleVersion` 升到 `d3.3`(改判定规则必升版,否则新旧留痕无法区分);
|
||||
// 5. defer 是**软动作**:回滚 = 清空 `expires_at`,行与正文一字不动。
|
||||
|
||||
// readIngestExpiry 直读一行记忆的 `expires_at`(该列不在 Store.Get 的读侧面上:
|
||||
// 读侧只拿它做 SQL 判据,不回表 SELECT)。
|
||||
func readIngestExpiry(t *testing.T, store *Store, id string) string {
|
||||
t.Helper()
|
||||
var exp string
|
||||
if err := store.db.QueryRowContext(context.Background(),
|
||||
`SELECT expires_at FROM memory_items WHERE id = ?`, id).Scan(&exp); err != nil {
|
||||
t.Fatalf("read expires_at %s: %v", id, err)
|
||||
}
|
||||
return exp
|
||||
}
|
||||
|
||||
// personaListHas 报告一条记忆是否出现在 ListByPersona(**过时效闸**)的结果里。
|
||||
func personaListHas(t *testing.T, store *Store, personaID, id string) bool {
|
||||
t.Helper()
|
||||
items, err := store.ListByPersona(context.Background(), personaID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByPersona: %v", err)
|
||||
}
|
||||
for _, it := range items {
|
||||
if it.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// newRealClockReviewer 构建一个用**真实墙钟**的审查服务(闸开着)。读侧时效闸比
|
||||
// 的是真实当下,因此「这条临时记忆现在还看得见吗」这类断言必须用真实时钟,否则
|
||||
// 固定夹具时刻(2026-10-01)算出来的有效期早就过去了。
|
||||
func newRealClockReviewer(t *testing.T, store *Store, ttl time.Duration) *PreInsertReviewer {
|
||||
t.Helper()
|
||||
r, err := NewPreInsertReviewer(ReviewConfig{
|
||||
Enabled: true, QueueSize: 8, ScanLimit: 50, Timeout: 5 * time.Second,
|
||||
Threshold: 0.80, TempTTL: ttl,
|
||||
}, ReviewDeps{
|
||||
Store: store,
|
||||
Now: func() time.Time { return time.Now() },
|
||||
Log: testLogger(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewPreInsertReviewer: %v", err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// deferFixtureItem 是一条**留存规则闸必然判 defer** 的条目(短寒暄,同分区、
|
||||
// owner 来源)。
|
||||
func deferFixtureItem(content string, at time.Time) MemoryItem {
|
||||
return gateFixtureItem(content, at)
|
||||
}
|
||||
|
||||
// ttlPanelPtr 造「面板里显式写了 tempTtlSec」的指针(nil 表示键没写)。
|
||||
func ttlPanelPtr(v int) *int { return &v }
|
||||
|
||||
// TestIngestGateDeferWritesShortExpiresAt 是任务书验收点①的**闸路径**:
|
||||
// 低价值条目照常 active 落库(一个字不丢),`expires_at` = 判定时刻 + 临时 TTL,
|
||||
// 留痕是 `defer` / `t0_low_value` / 规则版本 `d3.3`。
|
||||
func TestIngestGateDeferWritesShortExpiresAt(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := newTestStore(t)
|
||||
ttl := time.Hour
|
||||
base := tidyTestTime(t)
|
||||
r := newGateReviewer(t, store, ReviewConfig{TempTTL: ttl}, nil)
|
||||
store.AttachIngestReviewer(r)
|
||||
|
||||
got := seedTidyItem(t, store, deferFixtureItem("谢谢啦", base))
|
||||
|
||||
want := expiryText(base.Add(ttl))
|
||||
if exp := readIngestExpiry(t, store, got.ID); exp != want {
|
||||
t.Fatalf("expires_at = %q, want %q(= 判定时刻 + 临时 TTL)", exp, want)
|
||||
}
|
||||
if exp := readIngestExpiry(t, store, got.ID); len(exp) != storedTimeLen {
|
||||
t.Fatalf("expires_at 宽度 = %d 字节, want %d(读侧是纯文本比较,宽度必须同源)",
|
||||
len(exp), storedTimeLen)
|
||||
}
|
||||
row := readReviewRow(t, store, got.ID)
|
||||
if row.status != tidyStatusActive || row.content != "谢谢啦" {
|
||||
t.Fatalf("defer 行 = %+v, want 仍在册且正文一字不动(软动作)", row)
|
||||
}
|
||||
|
||||
rev := gateRecordFor(t, store, got.ID)
|
||||
if rev.Decision != IngestDefer || rev.Reason != retentionLowValueReason {
|
||||
t.Fatalf("闸留痕 = %+v, want decision=defer reason=%q", rev, retentionLowValueReason)
|
||||
}
|
||||
if rev.RuleVer != "d3.3" {
|
||||
t.Fatalf("闸留痕规则版本 = %q, want d3.3", rev.RuleVer)
|
||||
}
|
||||
if s := r.Stats(); s.GateDeferred != 1 {
|
||||
t.Fatalf("闸计数 = %+v, want gate_deferred=1", s)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIngestGateDeferIsNotEnqueuedForAsyncReview 钉住「defer 已是终局判断」:
|
||||
// 它**不投**异步审查队列 —— 再送语义审查只会把这条临时记忆并进一条长期记忆、
|
||||
// 把临时性抹掉(design/23 §18.1 的落点)。
|
||||
func TestIngestGateDeferIsNotEnqueuedForAsyncReview(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := newTestStore(t)
|
||||
base := tidyTestTime(t)
|
||||
r := newGateReviewer(t, store, ReviewConfig{TempTTL: time.Hour}, nil)
|
||||
store.AttachIngestReviewer(r)
|
||||
|
||||
seedTidyItem(t, store, deferFixtureItem("谢谢啦", base))
|
||||
if n := len(r.queue); n != 0 {
|
||||
t.Fatalf("defer 条目被投进了异步队列(队列长度 %d)—— defer 是终局判断,不该再送审", n)
|
||||
}
|
||||
|
||||
// 对偶:正常条目仍照旧投队列(证明上面那条不是因为队列压根不工作)。
|
||||
seedTidyItem(t, store, deferFixtureItem(fixtureHabitShort, base.Add(time.Minute)))
|
||||
if n := len(r.queue); n != 1 {
|
||||
t.Fatalf("正常条目未被投进异步队列(队列长度 %d, want 1)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestT1DeferWritesExpiresAtOnKeptNewRow 是验收点①的**异步 T1 路径**:T1 在
|
||||
// **同一次调用**里回 `defer` ⇒ 新行保留、正文不动、既有行不动,只写短 TTL。
|
||||
func TestT1DeferWritesExpiresAtOnKeptNewRow(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
existing, fresh := seedT1GrayPair(t, store)
|
||||
ttl := 2 * time.Hour
|
||||
|
||||
judge := &t1JudgeStub{reply: `{"action":"defer","target":0,"reason":"只是一次性的信息"}`}
|
||||
r := newT1Reviewer(t, store, t1GrayEmbedder(), ReviewConfig{TempTTL: ttl}, judge.judge, nil)
|
||||
|
||||
rev, err := r.Review(ctx, fresh)
|
||||
if err != nil {
|
||||
t.Fatalf("Review: %v", err)
|
||||
}
|
||||
if rev.Decision != IngestDefer || rev.Reason != t1ReasonDefer {
|
||||
t.Fatalf("verdict = %+v, want defer + %q", rev, t1ReasonDefer)
|
||||
}
|
||||
if judge.count() != 1 {
|
||||
t.Fatalf("模型调用 = %d 次, want 恰为 1(第五态与关系判定同一次调用)", judge.count())
|
||||
}
|
||||
want := expiryText(tidyTestTime(t).Add(ttl))
|
||||
if exp := readIngestExpiry(t, store, fresh.ID); exp != want {
|
||||
t.Fatalf("expires_at = %q, want %q", exp, want)
|
||||
}
|
||||
row := readReviewRow(t, store, fresh.ID)
|
||||
if row.status != tidyStatusActive || row.content != fixtureT1CodeViaChannel {
|
||||
t.Fatalf("新行 = %+v, want 仍在册且承载自己的原文", row)
|
||||
}
|
||||
if got := readReviewRow(t, store, existing.ID); got.mergeCount != 1 {
|
||||
t.Fatalf("既有行被改了:%+v(defer 只该碰新行的 expires_at 一列)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestT1DeferWithTargetIsReplyInvalid 钉住 §18.2 的不变式「defer ≡ 真新 ∧ 短期」:
|
||||
// 一个**指向候选**的 defer 自相矛盾(既说和它像、又说这条独立)⇒ 判非法回复、
|
||||
// 回落 `create`(**宁可多留**),绝不猜。
|
||||
func TestT1DeferWithTargetIsReplyInvalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
_, fresh := seedT1GrayPair(t, store)
|
||||
|
||||
judge := &t1JudgeStub{reply: `{"action":"defer","target":1,"reason":"自相矛盾"}`}
|
||||
r := newT1Reviewer(t, store, t1GrayEmbedder(), ReviewConfig{TempTTL: time.Hour}, judge.judge, nil)
|
||||
|
||||
rev, err := r.Review(ctx, fresh)
|
||||
if err != nil {
|
||||
t.Fatalf("Review: %v", err)
|
||||
}
|
||||
if rev.Decision != IngestCreate || rev.Reason != t1ReasonReplyInvalid {
|
||||
t.Fatalf("verdict = %+v, want create + %q", rev, t1ReasonReplyInvalid)
|
||||
}
|
||||
if exp := readIngestExpiry(t, store, fresh.ID); exp != "" {
|
||||
t.Fatalf("非法 defer 却写了 expires_at = %q(回落路径必须零改写)", exp)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeferExpiryHiddenByReadGateAndRollbackRestores 把第五态接到**读侧时效闸**
|
||||
// (design/03 §9.6 第 ① 步隐藏)与**回滚**(§18.4:清空 expires_at 即升回长期)上:
|
||||
// 未到期可见 ⇒ 置成过去即隐藏 ⇒ 清空又可见,且 `Get`(刻意不过闸)始终读得到。
|
||||
func TestDeferExpiryHiddenByReadGateAndRollbackRestores(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
r := newRealClockReviewer(t, store, time.Hour)
|
||||
store.AttachIngestReviewer(r)
|
||||
|
||||
got := seedTidyItem(t, store, deferFixtureItem("谢谢啦", time.Now().UTC()))
|
||||
if !personaListHas(t, store, got.PersonaID, got.ID) {
|
||||
t.Fatal("刚落的临时记忆应当在有效期内可见(TTL = 1h)")
|
||||
}
|
||||
|
||||
if err := store.setIngestExpiry(ctx, got.ID, expiryText(time.Now().UTC().Add(-time.Minute))); err != nil {
|
||||
t.Fatalf("setIngestExpiry: %v", err)
|
||||
}
|
||||
if personaListHas(t, store, got.PersonaID, got.ID) {
|
||||
t.Fatal("已到期的临时记忆不该出现在读路径上(时效闸失效)")
|
||||
}
|
||||
if _, err := store.Get(ctx, got.ID); err != nil {
|
||||
t.Fatalf("Get 刻意不过时效闸(撤销/清算要读得到过期行),却报错:%v", err)
|
||||
}
|
||||
|
||||
// 回滚(design/23 §18.4):清空 expires_at ⇒ 升回长期,立即可见。
|
||||
if err := store.setIngestExpiry(ctx, got.ID, ""); err != nil {
|
||||
t.Fatalf("setIngestExpiry(清空): %v", err)
|
||||
}
|
||||
if !personaListHas(t, store, got.PersonaID, got.ID) {
|
||||
t.Fatal("清空 expires_at 后应当升回长期(回滚是无损的)")
|
||||
}
|
||||
if row := readReviewRow(t, store, got.ID); row.content != "谢谢啦" || row.status != tidyStatusActive {
|
||||
t.Fatalf("回滚改了正文/状态:%+v", row)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIngestGatePlanExpiresAtUsesSameLayoutAsTimeGate 是写入/读取**同一套规范文本**
|
||||
// 的钉子(塔在派工里点名要求补的那条)。
|
||||
//
|
||||
// 读侧闸是**纯文本比较**(timegate.go 的 timeGateClause,不碰 SQLite 时间函数),
|
||||
// 所以写侧的布局一旦分叉,闸会**静默判错**:本仓历史事故里,`' '`(0x20) 分隔的
|
||||
// `datetime('now')` 与 `'T'`(0x54) 分隔的列值比较,会让**任何当日行都「大于当下」**。
|
||||
func TestIngestGatePlanExpiresAtUsesSameLayoutAsTimeGate(t *testing.T) {
|
||||
t.Parallel()
|
||||
at := tidyTestTime(t).UTC()
|
||||
|
||||
// 写入侧:defer 计划给出的文本必须就是 expiryText/formatTime 的产出。
|
||||
if got := (ingestGatePlan{ExpiresAt: at}).expiresAt(); got != formatTime(at) {
|
||||
t.Fatalf("plan.expiresAt() = %q, want formatTime 的产出 %q", got, formatTime(at))
|
||||
}
|
||||
if got := (ingestGatePlan{ExpiresAt: at}).expiresAt(); len(got) != storedTimeLen {
|
||||
t.Fatalf("plan.expiresAt() 宽度 = %d, want %d", len(got), storedTimeLen)
|
||||
}
|
||||
// 零值 = 不设过期(长期/日常记忆)⇒ 空串,恒可见。
|
||||
if got := (ingestGatePlan{}).expiresAt(); got != "" {
|
||||
t.Fatalf("零值计划的 expiresAt() = %q, want 空串", got)
|
||||
}
|
||||
|
||||
// 反证:同一瞬间换一种布局,文本序就与时间序脱钩(读侧判据随之失效)。
|
||||
sqliteLayout := at.Format("2006-01-02 15:04:05")
|
||||
if formatTime(at) == sqliteLayout {
|
||||
t.Fatal("两种布局不该相同 —— 那这条反证就没有意义了")
|
||||
}
|
||||
if !(formatTime(at) > sqliteLayout) {
|
||||
t.Fatalf("定宽布局应恒大于空格分隔布局('T' > ' '),实得 %q vs %q", formatTime(at), sqliteLayout)
|
||||
}
|
||||
if sqliteLayout[0:4] != formatTime(at)[0:4] {
|
||||
t.Fatal("两种布局的日期前缀应当相同,否则说明取的时间不是同一瞬间")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIngestRuleVersionIsD33 钉住任务书验收点④:改判定规则必升版
|
||||
// (design/23 §18.4),否则新旧留痕无法区分。
|
||||
func TestIngestRuleVersionIsD33(t *testing.T) {
|
||||
t.Parallel()
|
||||
if IngestRuleVersion != "d3.3" {
|
||||
t.Fatalf("IngestRuleVersion = %q, want d3.3(第五态改变了判定闭集)", IngestRuleVersion)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyReviewTuningTempTTL 钉住验收点③:TTL 的三档优先级与**坏值绝不放行**。
|
||||
//
|
||||
// 三档优先级在 TTL 这一键上实际是「**面板 > 代码默认**」:`.env` 已冻结
|
||||
// (design/13 §2.1),本键在那里**从来就没有出处** —— 中间那一档在结构上是空的,
|
||||
// 这不是漏配(design/03 §9.6 写的就是「面板 > 代码默认」)。
|
||||
func TestApplyReviewTuningTempTTL(t *testing.T) {
|
||||
t.Parallel()
|
||||
const fallbackSec = 604800 // 7 天(tempTtlSec)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
panel *int
|
||||
wantTTL time.Duration
|
||||
wantWarn bool
|
||||
}{
|
||||
{"键没写 ⇒ 代码默认", nil, 7 * 24 * time.Hour, false},
|
||||
{"面板写正数 ⇒ 用面板值", ttlPanelPtr(3600), time.Hour, false},
|
||||
{"面板写 0 ⇒ 坏值回落默认 + WARN", ttlPanelPtr(0), 7 * 24 * time.Hour, true},
|
||||
{"面板写负数 ⇒ 坏值回落默认 + WARN", ttlPanelPtr(-3600), 7 * 24 * time.Hour, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var buf bytes.Buffer
|
||||
log := slog.New(slog.NewTextHandler(&buf, nil))
|
||||
got := normalizeReviewConfig(ApplyReviewTuning(
|
||||
ReviewConfig{}, runtimeconfig.MemoryTidyTuning{TempTtlSec: tc.panel}, log))
|
||||
if got.TempTTL != tc.wantTTL {
|
||||
t.Fatalf("TempTTL = %v, want %v", got.TempTTL, tc.wantTTL)
|
||||
}
|
||||
hasWarn := strings.Contains(buf.String(), "临时记忆 TTL 不可用")
|
||||
if hasWarn != tc.wantWarn {
|
||||
t.Fatalf("WARN 出现 = %t, want %t(日志:%s)", hasWarn, tc.wantWarn, buf.String())
|
||||
}
|
||||
if got.TempTTL <= 0 {
|
||||
t.Fatal("归一化后仍是非正 TTL —— 那会让每条 defer 记忆落库即过期")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 代码默认值本身就是 design/03 §9.6 点名的 604800 秒。
|
||||
if normalizeReviewConfig(ReviewConfig{}).TempTTL != time.Duration(fallbackSec)*time.Second {
|
||||
t.Fatalf("代码默认 TTL = %v, want %ds", normalizeReviewConfig(ReviewConfig{}).TempTTL, fallbackSec)
|
||||
}
|
||||
if tempTtlSec != fallbackSec {
|
||||
t.Fatalf("tempTtlSec = %d, want %d", tempTtlSec, fallbackSec)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewPreInsertReviewerNormalizesBadTempTTL 钉住「坏值不靠调用方自觉」:
|
||||
// 任何绕过解析器、直接把 ≤0 塞进 ReviewConfig 的路径,也在构造期被归一化掉
|
||||
// (与 T1 限流两项同一教训,design/12 §N C.5 G3)。
|
||||
func TestNewPreInsertReviewerNormalizesBadTempTTL(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := newTestStore(t)
|
||||
for _, ttl := range []time.Duration{0, -time.Hour} {
|
||||
r, err := NewPreInsertReviewer(ReviewConfig{Enabled: true, TempTTL: ttl}, ReviewDeps{
|
||||
Store: store, Log: testLogger(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewPreInsertReviewer: %v", err)
|
||||
}
|
||||
if got := r.cfg.TempTTL; got != 7*24*time.Hour {
|
||||
t.Fatalf("ReviewConfig.TempTTL = %v 未回落默认,实得 %v", ttl, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIngestRuleVersionIsNotInTheTidyIdempotencyKey 是**防漂移缝断言**,回答一条
|
||||
// 跨机制的实体问题:`IngestRuleVersion` 由 d3.2 升到 d3.3 之后,**既有 pending 整理
|
||||
// 候选会不会凭幂等键复制出重复行**?
|
||||
//
|
||||
// 答案:**不会**,因为这是两个独立的常量 ——
|
||||
//
|
||||
// IngestRuleVersion ("d3.3") 入库审查(本切片)→ 只进 memory_ingest_reviews.rule_version
|
||||
// TidyRuleVersion ("t0.1") 整理(design/23 §17.6)→ **进整理候选的幂等键**
|
||||
//
|
||||
// 幂等键的算式是 `tidyIdempotencyKey`(tidy_candidate.go):hash(persona_id, scope,
|
||||
// project_id, sorted(source_ids), action, **c.RuleVersion**),而候选的 RuleVersion 由
|
||||
// 构造器写成 TidyRuleVersion。生产库实测(2026-10-13,只读):`memory_tidy_candidates`
|
||||
// 的 64 条 pending 全部 `rule_version='t0.1'`,`memory_ingest_reviews` 里则是
|
||||
// `d3.1` / `d3.2` —— 两张表**从不共享**这一个值。
|
||||
//
|
||||
// design/23 §17.6 记录过的那次「升版 ⇒ 既有候选凭新键复制一份重复行」是
|
||||
// **TidyRuleVersion** 的语义(那也是它当时刻意不升的原因),不是这一个。
|
||||
// 若将来有人把 IngestRuleVersion 接进那个键(或把两个常量合并),本用例会红。
|
||||
func TestIngestRuleVersionIsNotInTheTidyIdempotencyKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
if IngestRuleVersion == TidyRuleVersion {
|
||||
t.Fatalf("两个版本号不该相等(都是 %q):它们分属入库审查与整理两条机制",
|
||||
IngestRuleVersion)
|
||||
}
|
||||
src, err := os.ReadFile("tidy_candidate.go")
|
||||
if err != nil {
|
||||
t.Fatalf("读 tidy_candidate.go: %v", err)
|
||||
}
|
||||
text := string(src)
|
||||
if !strings.Contains(text, "string(c.Action), c.RuleVersion") {
|
||||
t.Fatal("整理候选幂等键的算式变了 —— 请复核 IngestRuleVersion 是否意外进了键")
|
||||
}
|
||||
if strings.Contains(text, "IngestRuleVersion") {
|
||||
t.Fatal("tidy_candidate.go 里出现了 IngestRuleVersion:两个版本号开始互相污染")
|
||||
}
|
||||
if strings.Count(text, "TidyRuleVersion") < 2 {
|
||||
t.Fatal("整理候选的 RuleVersion 不再由 TidyRuleVersion 赋值 —— 幂等键的口径变了")
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,11 @@ const (
|
||||
|
||||
// gateTraceAbsorbed 是闸真的做了决定(落 merged)的留痕原因码。
|
||||
gateTraceAbsorbed = "gate:absorbed"
|
||||
// gateTraceDeferred 是**第五态**在闸侧做决定的留痕原因码(`expires_at` 已写)。
|
||||
// 与 gateTraceAbsorbed 分开:前者写的是 status/merged_into,后者只写
|
||||
// expires_at 一列 —— 「闸当场折回了多少重复」与「闸当场放进了多少临时记忆」
|
||||
// 是两条不同的观测线,加成一个数会让面板读不出分型。
|
||||
gateTraceDeferred = "gate:deferred"
|
||||
// gateTraceBudgetExceeded 是闸被墙钟预算截断的 fail-open 原因码。
|
||||
gateTraceBudgetExceeded = "gate:budget_exceeded"
|
||||
// gateTraceUnavailable 是候选源不可用(读取失败)的 fail-open 原因码。
|
||||
@@ -72,6 +77,14 @@ const (
|
||||
// ingestPlanAbsorbOld 是确定性判据命中且**既有行是子集**(modify):新行正常
|
||||
// insert(它承载更全的内容),既有行置为被合并(design §15 D3-6 的「改写」)。
|
||||
ingestPlanAbsorbOld ingestPlanAction = "absorb_old"
|
||||
// ingestPlanDefer 是**第五态**(design/23 §18.1):真新、但不值得长期留存 ⇒
|
||||
// 行**照常 active 落库**(一个字不丢),只是紧接着把 `expires_at` 写成
|
||||
// 「现在 + 临时 TTL」,使它落进临时层 —— 到期由读侧时效闸隐藏
|
||||
// (design/03 §9.6)、再由后台清扫物理删除(design/23 §19)。
|
||||
//
|
||||
// 它**不投异步审查队列**(Async=false):defer 已经是这条记忆的**终局判断**
|
||||
// (「不值得长期留」),再送语义审查只会把它并进一条长期记忆、把临时性抹掉。
|
||||
ingestPlanDefer ingestPlanAction = "defer"
|
||||
)
|
||||
|
||||
// ingestGatePlan 是同步入库闸的落库计划。零值(Action="")不存在:planIngest
|
||||
@@ -97,6 +110,23 @@ type ingestGatePlan struct {
|
||||
// Elapsed 是闸在写路径上花掉的墙钟。它只进日志与留痕,**不参与任何判定**
|
||||
// (否则同一批数据会有两个结论,幂等性就没了)。
|
||||
Elapsed time.Duration
|
||||
// ExpiresAt 是第五态(defer)要写进新行 `expires_at` 的时刻:**零值 = 不设
|
||||
// 过期**(长期 / 日常记忆,落库为空串,四条列表读路径恒可见)。
|
||||
//
|
||||
// 它是**时刻**而不是「TTL 秒数」:判定的那一刻就把 TTL 换算掉,落库侧因此
|
||||
// 不需要认识任何配置,也不会出现「写的时候又算一次 TTL」的双口径。
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// expiresAt 返回新行落库时的 `expires_at` 文本,与 `insertStatus()` /
|
||||
// `absorbedID()` 是同族:把「计划 → 要写进 INSERT 的值」这一步收在计划自己身上。
|
||||
//
|
||||
// 🔴 **编码必须走 expiryText → formatTime**(定宽 30 字节 UTC),不许另写一种
|
||||
// 布局:读侧时效闸是**纯文本比较**(timegate.go 的 timeGateClause,不碰 SQLite
|
||||
// 时间函数),布局一旦分叉就会**静默判错**(本仓已有历史事故,timegate_test.go
|
||||
// 钉着那条反证)。零值 ⇒ 空串 ⇒ 该行恒可见。
|
||||
func (p ingestGatePlan) expiresAt() string {
|
||||
return expiryText(p.ExpiresAt)
|
||||
}
|
||||
|
||||
// absorbedID 返回应该写进新行 `merged_into` 的存活行 id。
|
||||
@@ -181,6 +211,13 @@ func (r *PreInsertReviewer) planFromVerdict(item MemoryItem, v ingestVerdict) in
|
||||
plan.Survivor = item
|
||||
plan.Absorbed = v.Existing
|
||||
plan.Trace = gateTraceAbsorbed
|
||||
case IngestDefer:
|
||||
// 第五态(design/23 §18.1):行照常 active 落库(一个字不丢、可 revert),
|
||||
// 只把 expires_at 写成「现在 + 临时 TTL」;**不投异步队列**(defer 已是
|
||||
// 终局判断,再送语义审查只会把临时性抹掉)。
|
||||
plan.Action = ingestPlanDefer
|
||||
plan.ExpiresAt = r.deferDeadline(r.now().UTC())
|
||||
plan.Trace = gateTraceDeferred
|
||||
default:
|
||||
// 真新 / 拿不准:正常 insert,并投既有异步队列(用语义召回再判一次)。
|
||||
plan.Action = ingestPlanInsert
|
||||
@@ -189,6 +226,20 @@ func (r *PreInsertReviewer) planFromVerdict(item MemoryItem, v ingestVerdict) in
|
||||
return plan
|
||||
}
|
||||
|
||||
// deferDeadline 把「现在」换算成第五态要写进 `expires_at` 的时刻(现在 + 临时
|
||||
// TTL,design/03 §9.6)。
|
||||
//
|
||||
// TTL 由 `cfg.TempTTL` 决定,**它已经过 normalize**(`NewPreInsertReviewer` 里
|
||||
// normalizeReviewConfig 先跑),因此这里拿到的恒为正 —— 「≤0 的 TTL 让每条记忆
|
||||
// 落库即过期」这条坏配置形态在构造期就被回落掉了(normalizeReviewConfig 兜底 +
|
||||
// ApplyReviewTuning 的 WARN)。
|
||||
//
|
||||
// 闸(同步)与异步审查层**共用这一个函数**:同一份配置、同一种时刻语义,因此
|
||||
// 同一条记忆无论被哪一层判成 defer,拿到的有效期都来自同一个算式。
|
||||
func (r *PreInsertReviewer) deferDeadline(now time.Time) time.Time {
|
||||
return now.UTC().Add(r.cfg.TempTTL)
|
||||
}
|
||||
|
||||
// failOpenPlan 组装 fail-open 计划:照常插入(active)+ 投既有队列 + 留痕。
|
||||
// 「绝不静默丢记忆」不是一句承诺,而是这条路径的结构性结果。
|
||||
func (r *PreInsertReviewer) failOpenPlan(reason string, start time.Time) ingestGatePlan {
|
||||
@@ -298,6 +349,35 @@ func (s *Store) finishIngest(ctx context.Context, item MemoryItem, plan ingestGa
|
||||
if plan.Action == ingestPlanInsert {
|
||||
return
|
||||
}
|
||||
if plan.Action == ingestPlanDefer {
|
||||
// 第五态(design/23 §18.1):新行已经在 Add 里照常插好了(active、正文
|
||||
// 一字不差),这里只把 `expires_at` 补成计划给出的时刻 —— 与
|
||||
// absorb_new / absorb_old 是**同一范式**:INSERT 落行、finishIngest 落
|
||||
// 「那一行的派生列」。
|
||||
//
|
||||
// 为什么不是「INSERT 直接带上 expires_at」:store.go 的 INSERT 读的是
|
||||
// `expiryText(item.ExpiresAt)`(由**调用方**置位),而 defer 的判断发生在
|
||||
// 闸里(`planIngest` 拿的是 item 的**副本**),计划的值够不到那条 INSERT。
|
||||
//
|
||||
// 🔴 **代价与短暂窗口(如实登记,不静默)**:这一态因此多出一笔按主键的
|
||||
// UPDATE(微秒级),且 INSERT 与这笔补写之间有一个**极短的窗口** ——
|
||||
// 若进程恰在此刻中断,该行会以 `expires_at=''`(**永不过期**)留在库里。
|
||||
// 方向是刻意的:它正是 fail-safe 的「**宁可多留,不可误弃**」
|
||||
// (design/23 §18.3)—— 停在长期层只是多占一条记忆,而反向失败
|
||||
// (把该留的判成临时)会丢信息。写失败同样只记 WARN、**不让 Add 失败**。
|
||||
//
|
||||
// 非 defer 的三态**一笔都不多写**:absorb_new / absorb_old 本来就有它们的
|
||||
// 第二笔(合并登记 / 置为被合并),insert 在函数开头就返回了。
|
||||
if err := s.setIngestExpiry(ctx, item.ID, plan.expiresAt()); err != nil {
|
||||
s.log.Warn("memory ingest gate defer failed, item left long-lived",
|
||||
"id", item.ID, "scope", string(item.Scope), "error", err)
|
||||
return
|
||||
}
|
||||
if r := s.ingestReviewer(); r != nil {
|
||||
r.bump(func(st *ReviewStats) { st.GateDeferred++ })
|
||||
}
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
var err error
|
||||
switch plan.Action {
|
||||
|
||||
@@ -16,6 +16,11 @@ package memory
|
||||
// `ingestVerdict.Gray`,交**异步审查层**的 T1 再问一次
|
||||
// (preinsert_t1.go,design/23 §15.1 D3-2)。**本文件恒为零模型调用**。
|
||||
//
|
||||
// 另外在**候选比对之前**插一道**提取留存规则闸**(extract_retention.go,
|
||||
// design/03 §10.5 的 N1~N5):新条目自己就「显然不值得长期留存」时直接给第五态
|
||||
// `defer`(`reason=t0_low_value`),**不进候选比对、不进 T1**。它是纯规则、零模型
|
||||
// 调用,因此不改变上面那句「本文件恒为零模型调用」。
|
||||
//
|
||||
// 两条不可走样:
|
||||
// - **相似度只用来召回候选**,永不参与判定(design/23 §1.2「单阈值双向失效」);
|
||||
// - **规则否决一票否决**,且**绝不跨作用域/跨角色/跨项目**(§5.2-7/8/9 是硬门,
|
||||
@@ -68,6 +73,18 @@ func judgeIngest(item MemoryItem, cands []ingestCandidate) ingestVerdict {
|
||||
if v, ok := judgeExactDuplicate(item, cands); ok {
|
||||
return v
|
||||
}
|
||||
// 提取留存规则闸(design/03 §10.5 的 N1~N5):新条目**自己**就「显然不值得
|
||||
// 长期留存」时直接给第五态 `defer`,**不进候选比对、不进 T1**
|
||||
// (design/23 §10.6:规则闸拦下的不进 T1,避免「模型推翻规则」)。
|
||||
//
|
||||
// 它排在**精确重复之后**是刻意的:已有等价条目时 `skip` 只更新既有行、
|
||||
// **一行都不新增**,比 `defer`(新增一行临时记忆)更省也更干净。
|
||||
if rule := judgeExtractionRetention(item.Content); rule != retentionNone {
|
||||
return ingestVerdict{
|
||||
Decision: IngestDefer, Reason: retentionLowValueReason, Mode: mode,
|
||||
Veto: VetoNone, Gray: nil,
|
||||
}
|
||||
}
|
||||
vetoed, vetoScore := VetoNone, 0.0
|
||||
// 灰区惰性收集:只有真的出现「互不包含且未被否决」的候选才分配,
|
||||
// 同步写入路径上的常见情形(无候选 / 一眼真新)因此零额外分配。
|
||||
|
||||
@@ -17,6 +17,10 @@ type ReviewStats struct {
|
||||
Merged int
|
||||
Modified int
|
||||
Created int
|
||||
// Deferred 是**异步审查层**判出第五态 defer 的条数(T0 规则闸 / T1 同一次
|
||||
// 调用都可能给出它)。它与 Created 分列:两者都留下新行,但只有 defer 带
|
||||
// 短 `expires_at`,它是「临时层新增」的观测量。
|
||||
Deferred int
|
||||
Dropped int
|
||||
Failed int
|
||||
// GateChecked 是**同步入库闸**实际做了一次判定的条数(跳过闸的条目不计)。
|
||||
@@ -26,6 +30,11 @@ type ReviewStats struct {
|
||||
GateAbsorbed int
|
||||
// GateFailed 是闸因预算/候选源不可用而 fail-open 的次数(照常插入 + 留痕)。
|
||||
GateFailed int
|
||||
// GateDeferred 是闸当场判定为**第五态 defer** 并**真的写成了** `expires_at`
|
||||
// 的次数(design/23 §18.1)。它与 GateAbsorbed 分列:「当场折回重复」与
|
||||
// 「当场放进临时层」是两条不同的观测线,加成一个数面板就读不出分型。
|
||||
// 只统计写成功的(写失败时那行停在长期层,不是临时记忆)。
|
||||
GateDeferred int
|
||||
// T1Called 是 T1 实际发出过**模型调用**的次数。每次审查至多 +1
|
||||
// (defaultT1MaxGray 只是把多条灰区候选打包进同一次 prompt,不多发请求)。
|
||||
T1Called int
|
||||
@@ -58,6 +67,10 @@ func (r *PreInsertReviewer) recordDecision(rev IngestReview) {
|
||||
s.Merged++
|
||||
case IngestModify:
|
||||
s.Modified++
|
||||
case IngestDefer:
|
||||
// 第五态单独一档:它与 create 都「留下了新行」,但一个有 TTL、
|
||||
// 一个没有 —— 混进 Created 就看不出「有多少记忆是临时的」。
|
||||
s.Deferred++
|
||||
default:
|
||||
s.Created++
|
||||
}
|
||||
@@ -79,8 +92,10 @@ func (r *PreInsertReviewer) auditWith(verb string, rev IngestReview) {
|
||||
|
||||
// reviewStatsLabel 把计数渲染成一条稳定日志串。
|
||||
func reviewStatsLabel(s ReviewStats) string {
|
||||
return fmt.Sprintf("reviewed=%d skip=%d merge=%d modify=%d create=%d dropped=%d failed=%d "+
|
||||
"gate_checked=%d gate_absorbed=%d gate_failed=%d t1_called=%d t1_decided=%d t1_throttled=%d",
|
||||
s.Reviewed, s.Skipped, s.Merged, s.Modified, s.Created, s.Dropped, s.Failed,
|
||||
s.GateChecked, s.GateAbsorbed, s.GateFailed, s.T1Called, s.T1Decided, s.T1Throttled)
|
||||
return fmt.Sprintf("reviewed=%d skip=%d merge=%d modify=%d create=%d defer=%d dropped=%d failed=%d "+
|
||||
"gate_checked=%d gate_absorbed=%d gate_deferred=%d gate_failed=%d "+
|
||||
"t1_called=%d t1_decided=%d t1_throttled=%d",
|
||||
s.Reviewed, s.Skipped, s.Merged, s.Modified, s.Created, s.Deferred, s.Dropped, s.Failed,
|
||||
s.GateChecked, s.GateAbsorbed, s.GateDeferred, s.GateFailed,
|
||||
s.T1Called, s.T1Decided, s.T1Throttled)
|
||||
}
|
||||
@@ -23,6 +23,11 @@ import (
|
||||
// ingestStatusMerged 是「已被合并」的行状态(design/23 §10 的 status 列)。
|
||||
const ingestStatusMerged = "merged"
|
||||
|
||||
// 🔴 这条内部召回 SQL **刻意只过 status 闸、不过时效闸**(design/03 §11.2 第 3 条,
|
||||
// 先例见 design/23 §10.1 ①;主会话 2026-10-13 裁定「保持不过时效闸」):它比对的是
|
||||
// **正文**,与可见性无关 —— 加闸会让过期但未清扫的临时行退出比对,同一事实遂以
|
||||
// 「过期行 + 新行」并存(正是主人报的「反复新建」)。过期行由后台清扫物理删除
|
||||
// (design/23 §19),**不会复活**。姊妹注释在 tidy_store.go 的 listTidyPartitionItemsSQL。
|
||||
const listReviewCandidatesSQL = `SELECT id, persona_id, role_id, project_id, scope, kind,
|
||||
content, tags, confidence, source_conversation, created_at, updated_at,
|
||||
decay_weight FROM memory_items
|
||||
@@ -46,6 +51,12 @@ const selectMergedIntoSQL = `SELECT merged_into FROM memory_items
|
||||
|
||||
const selectItemStatusSQL = `SELECT status FROM memory_items WHERE id = ?`
|
||||
|
||||
// setIngestExpirySQL 是第五态(design/23 §18.1)的落点:**只写 expires_at 一列**。
|
||||
// 不碰 status / merged_* / content,因此它天然是「软动作」——回滚 = 把该列清空
|
||||
// (§18.4);行一行不删(临时层的物理删除是 design/23 §19 的另一条有审计的
|
||||
// 清扫路径,不在这里)。
|
||||
const setIngestExpirySQL = `UPDATE memory_items SET expires_at = ? WHERE id = ?`
|
||||
|
||||
const insertIngestReviewSQL = `INSERT INTO memory_ingest_reviews
|
||||
(id, item_id, persona_id, scope, project_id, decision, reason, veto, target_id,
|
||||
merged_ids, score, recall_mode, degraded, rule_version, session_id, created_at)
|
||||
@@ -115,6 +126,26 @@ func (s *Store) listReviewCandidates(ctx context.Context, p tidyPartition, exclu
|
||||
return scanItems(rows, "review candidate")
|
||||
}
|
||||
|
||||
// setIngestExpiry 把一条记忆的 `expires_at` 写成 expiryText(**定宽 30 字节 UTC**),
|
||||
// 空白串表示「不设过期」。它是第五态 defer 的**唯一**写口,两处调用:
|
||||
//
|
||||
// - 同步入库闸(preinsert_gate.go 的 finishIngest):INSERT 之后补这一列;
|
||||
// - 异步审查层(preinsert.go 的 applyVerdict):T1 判 defer 时改这一列。
|
||||
//
|
||||
// 🔴 **编码必须与读侧同源**:读侧时效闸是纯文本比较(timegate.go),因此这里
|
||||
// 只许走 expiryText —— 写成别的布局(RFC3339 带 `+08:00`、秒精度、空格分隔)
|
||||
// 会让闸**静默**判错(早八小时的列值可能被判成「大于当下」,B 仓历史事故,
|
||||
// timegate_test.go 钉着那条反证)。
|
||||
func (s *Store) setIngestExpiry(ctx context.Context, id, expiresAt string) error {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return ErrNotFound
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, setIngestExpirySQL, expiresAt, id); err != nil {
|
||||
return fmt.Errorf("memory: set ingest expiry: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// absorbIngestItem 把 absorbed 软合并进 survivor:一个事务里做「标记被吸收 +
|
||||
// 刷新存活行的合并元信息」。两条 UPDATE 都只碰 status/merged_*/merge_count/
|
||||
// confidence/updated_at,**绝不改 content**(design §8 的可回滚口径)。
|
||||
|
||||
@@ -21,8 +21,10 @@ import (
|
||||
//
|
||||
// 五条不可走样(逐条对应本目录既有的不变量,改坏任何一条都是架构偏离):
|
||||
//
|
||||
// 1. **只许回三态 + 理由**:`merge` / `modify` / `keep`。坏 JSON、非法枚举、
|
||||
// 编号越界、任何别的词 —— 一律回落 `create`(保守,**绝不猜**)。
|
||||
// 1. **只许回四态 + 理由**:`merge` / `modify` / `keep` / `defer`。坏 JSON、
|
||||
// 非法枚举、编号越界、任何别的词 —— 一律回落 `create`(保守,**绝不猜**)。
|
||||
// `defer`(第五态,§18.2)还多一条不变式:**target 必须为 0** —— 它声称
|
||||
// 「关系判为真新」,而一个指向某条候选的 defer 自相矛盾,同样判非法回落。
|
||||
// 2. **禁产新内容**:模型只提供「选哪一条 + 什么动作 + 一句理由」,它的正文 /
|
||||
// 摘要 / 改写文本**一个字都不采纳**。`merge` 落地时存活方是既有行(正文
|
||||
// 一字不改);`modify` 落地时存活方是**新行自己的原文**、既有行置为被合并
|
||||
@@ -45,8 +47,9 @@ import (
|
||||
// 厂商协议,也不新增依赖。
|
||||
//
|
||||
// 可观测(§15 D3-8):T1 是否参与、结论是什么,在留痕 `reason` 列上如实可辨
|
||||
// (`t1_merge` / `t1_modify` / `t1_keep` / `t1_reply_invalid` / `t1_unavailable` /
|
||||
// `t1_throttled`),规则版本随之升到 `IngestRuleVersion`。模型的自由文本理由
|
||||
// (`t1_merge` / `t1_modify` / `t1_keep` / `t1_defer` / `t1_reply_invalid` /
|
||||
// `t1_unavailable` / `t1_throttled`),规则版本随之升到 `IngestRuleVersion`
|
||||
// (`d3.3` 起含第五态)。模型的自由文本理由
|
||||
// **既不落库也不落日志**:留痕表按设计不存正文,而模型的理由可能改述正文 ⇒ 一律
|
||||
// 丢弃(它在 prompt 里的唯一作用,是逼模型先表态,从而把动作判得更稳)。
|
||||
|
||||
@@ -64,10 +67,18 @@ const (
|
||||
// 让用量面板能把「灰区判定」与「对话」分开算账。
|
||||
t1Purpose = "memory_t1_judge"
|
||||
|
||||
// T1 允许的动作枚举(模型只许回这三个,别的一律回落 create)。
|
||||
// T1 允许的动作枚举(模型只许回这四个,别的一律回落 create)。
|
||||
//
|
||||
// `defer` 是 2026-10-13 加入的**第五态**(design/23 §18.2)—— 它**不是第二次
|
||||
// 调用**:关系判定(merge/modify/keep)与价值判定(值不值得长期留)被写进
|
||||
// **同一个 prompt**,由**同一次**补全回答。语义上 `defer` ≡「关系判为**真新**
|
||||
// ∧ 价值判为**短期**」,因此它要求 target=0(真新就没得可指),而**当关系
|
||||
// 判成 merge/modify 时价值判定不适用**(这条已被既有记忆代表,留不留由既有行
|
||||
// 决定,§18.2 的「分工」)。
|
||||
t1ActionMerge = "merge"
|
||||
t1ActionModify = "modify"
|
||||
t1ActionKeep = "keep"
|
||||
t1ActionDefer = "defer"
|
||||
|
||||
// T1 的留痕原因码(进 `reason` 列,结构化、可机器判定、与既有码同族)。
|
||||
t1ReasonMerge = "t1_merge"
|
||||
@@ -75,6 +86,10 @@ const (
|
||||
t1ReasonKeep = "t1_keep"
|
||||
t1ReasonReplyInvalid = "t1_reply_invalid"
|
||||
t1ReasonUnavailable = "t1_unavailable"
|
||||
// t1ReasonDefer 是第五态在留痕 `reason` 列上的编码(design/23 §18.4 的闭集)。
|
||||
// 它与 t1_keep 分列:两者都是「关系判为真新」,但只有 defer 会写短
|
||||
// `expires_at` —— 面板要能一眼看出「有多少条被判成了临时」。
|
||||
t1ReasonDefer = "t1_defer"
|
||||
// t1ReasonThrottled 是**全局限速**命中时的原因码(preinsert_t1_limit.go,
|
||||
// design/23 §15.4)。它与上面两个失败码同族、同样只改 `reason`:决定恒
|
||||
// `create`、零改写。与它们的唯一区别是语义 —— 失败是「模型没答上」,
|
||||
@@ -82,7 +97,16 @@ const (
|
||||
t1ReasonThrottled = "t1_throttled"
|
||||
)
|
||||
|
||||
// ErrT1ReplyInvalid 是模型回复解不出三态枚举(或候选编号越界)时的分类错误。
|
||||
// 一处**刻意不实现**(如实登记):design/23 §18.3 的失败原因码
|
||||
// `t1_value_unavailable` / `t1_keep_unavailable`。关系判定与价值判定被写进
|
||||
// **同一次**补全(§18.2 要求「不新增第二次调用」),因此「回复解不开」时**结构上
|
||||
// 无法分辨**是哪一问失败 —— 硬造第二个码只会得到一个自己也不确定的标签。既有
|
||||
// 的 `t1_reply_invalid` / `t1_unavailable` 已经如实描述了那条路径(且 §18.2 对
|
||||
// 「非法 defer」明写的处置就是 `t1_reply_invalid`)。真要分开观测,得先有第二次
|
||||
// 调用,而那正是本条明令禁止的。
|
||||
|
||||
// ErrT1ReplyInvalid 是模型回复解不出四态枚举(或候选编号越界,或 defer 带了
|
||||
// 非 0 的 target)时的分类错误。
|
||||
// 它不是「模型挂了」,而是「这一次的回复不能用」—— 两者的回落结果相同
|
||||
// (create),但留痕原因码不同,运维才分得清该修 prompt 还是该查网络。
|
||||
var ErrT1ReplyInvalid = errors.New("memory/review: t1 reply is not a valid action")
|
||||
@@ -232,6 +256,22 @@ func t1Verdict(reply t1Reply, item MemoryItem, gray []ingestCandidate, base inge
|
||||
base.Gray = nil
|
||||
return base, true
|
||||
}
|
||||
if action == t1ActionDefer {
|
||||
// 第五态(design/23 §18.2):关系判为**真新**、但价值判为**短期** ⇒
|
||||
// 照常保留这条新记忆,只给它一个短 `expires_at`(落临时层)。
|
||||
//
|
||||
// 🔴 **target 必须为 0**(与 keep 同形):§18.2 的不变式是
|
||||
// 「defer ≡ 真新 ∧ 短期」,一个指向某条候选的 defer 自相矛盾(既说
|
||||
// 「和它像」又说「这条独立」)。出现这种回复 ⇒ 判非法、回落 create
|
||||
// (reason=t1_reply_invalid,§18.2 明写的处置),**绝不猜**。
|
||||
if reply.Target != 0 {
|
||||
return ingestVerdict{}, false
|
||||
}
|
||||
base.Decision = IngestDefer
|
||||
base.Reason = t1ReasonDefer
|
||||
base.Gray = nil
|
||||
return base, true
|
||||
}
|
||||
if action != t1ActionMerge && action != t1ActionModify {
|
||||
return ingestVerdict{}, false
|
||||
}
|
||||
@@ -271,32 +311,44 @@ func buildT1Prompt(item MemoryItem, gray []ingestCandidate) []model.Message {
|
||||
}
|
||||
|
||||
// t1SystemPrompt 是 T1 的固定指令。它把三件事写死:
|
||||
// - 只许回三态 + 一句理由(别的词一律不许);
|
||||
// - 只许回**四态** + 一句理由(别的词一律不许);
|
||||
// - **禁产新内容**(不许输出记忆正文/摘要/改写文本);
|
||||
// - 判据与**负例**(design/23 §15 D3-2「负例进 prompt」,Mem0 口径):
|
||||
// 「相似但并列」必须 keep,拿不准必须 keep。
|
||||
//
|
||||
// 2026-10-13 起它同时承载**第五态的价值判定**(design/23 §18.2):prompt 里明写
|
||||
// 「先问关系、只有关系判为真新时才问价值」,四个动作**一次回答** —— 因此
|
||||
// 「第五态与 T1 在同一次调用里问」是这条 prompt 的结构性事实,不是约定:**没有
|
||||
// 第二次模型调用**。
|
||||
//
|
||||
// 负例刻意取自 design/23 §1.3 与 §5.2 的点名反例(红牛 vs 冰美式是「同主题但
|
||||
// 不同事实」,X vs 戒了X 是「新事实覆盖旧事实」),它们是 T1 最容易被相似度
|
||||
// 带偏的两处。
|
||||
const t1SystemPrompt = `你是记忆库的入库审查员。给你一条刚入库的新记忆,以及同一分区里若干条既有记忆候选。
|
||||
判断新记忆与既有候选的关系,只回一个 JSON 对象,不要任何其他文字、不要代码块。
|
||||
要回答的是**两个问题**,但只回一个 JSON 对象,不要任何其他文字、不要代码块。
|
||||
|
||||
动作只许取这三个值之一:
|
||||
第一问(关系):这条新记忆和某条既有候选,是不是在说同一件事?
|
||||
第二问(价值):只有当第一问的答案是「谁都不像、它是新的一件事」时,才再问一句——**它值不值得长期留在记忆库里**?
|
||||
若第一问答的是「和某条候选是同一件事」,第二问就不适用(留不留由那条既有记忆决定),直接回 merge 或 modify。
|
||||
|
||||
动作只许取这四个值之一:
|
||||
- "merge":新记忆与某条既有记忆说的是**同一件事、只是换了措辞**,且新记忆没有那条既有记忆之外的任何新信息 ⇒ 新记忆并入那条既有记忆(既有记忆的原文保持不变)。
|
||||
- "modify":某条既有记忆是**新记忆的不完整版本**(新记忆覆盖它、并含它没有的细节)⇒ 那条既有记忆退役,由新记忆承载。
|
||||
- "keep":其余一切情形(不同事实、不同偏好、不同对象、只是同一主题)⇒ 新记忆独立保留。
|
||||
- "keep":是**新的一件事**,而且值得**长期**记住 ⇒ 新记忆独立长期保留。
|
||||
- "defer":是**新的一件事**,但**不值得长期记住**(只是一次性的、短期的、过了这阵子就没用的信息)⇒ 新记忆先留着,过一阵子自动消失。
|
||||
|
||||
必须遵守的判据:
|
||||
- 只有「同一件事换措辞」才可以 merge;「相似但并列」的两件不同事实必须 keep。反例:"主人喜欢喝红牛来提神" 与 "主人喜欢喝冰美式来提神" 是两种不同的偏好,必须 keep,即便它们很相似。
|
||||
- 一方带否定、另一方没有("X" 与 "已经戒了 X"),那是新事实覆盖旧事实、不是重复,必须 keep。
|
||||
- 描述某一次具体事件、或带具体日期/时间的记忆,不要与长期习惯互相合并,必须 keep。
|
||||
- 只有「同一件事换措辞」才可以 merge;「相似但并列」的两件不同事实必须 keep 或 defer。反例:"主人喜欢喝红牛来提神" 与 "主人喜欢喝冰美式来提神" 是两种不同的偏好,必须 keep,即便它们很相似。
|
||||
- 一方带否定、另一方没有("X" 与 "已经戒了 X"),那是新事实覆盖旧事实、不是重复,必须 keep 或 defer。
|
||||
- 描述某一次具体事件、或带具体日期/时间的记忆,不要与长期习惯互相合并。
|
||||
- 只是「都在说同一个领域」不算同一件事。
|
||||
- **拿不准就 keep**。宁可多留一条,也绝不把两件不同的事并成一条。
|
||||
- **长期事实、稳定偏好、习惯、约定与承诺、关系里程碑、主人的明确要求 = keep**(它们长期有用)。
|
||||
- **只对「这一次」有用的信息 = defer**,例如:一次性的安排或请求、当前临时的状态、时效性数据、转瞬即逝的琐事。
|
||||
- **拿不准就 keep**。宁可多留一条长期记忆,也绝不把两件不同的事并成一条;更不要因为拿不准就把一条真记忆判成临时的。
|
||||
- **绝对不要输出任何记忆正文、摘要或改写文本**,只回动作、候选编号和一句理由。
|
||||
|
||||
输出格式(严格 JSON,无代码块):{"action":"merge 或 modify 或 keep","target":候选编号,"reason":"一句话理由"}
|
||||
keep 时 target 写 0。`
|
||||
输出格式(严格 JSON,无代码块):{"action":"merge 或 modify 或 keep 或 defer","target":候选编号,"reason":"一句话理由"}
|
||||
回 keep 或 defer 时 target 写 0(这两者都表示「不与任何候选合并」)。`
|
||||
|
||||
// parseT1Reply 解析模型的 JSON 回复。被围栏包裹的 ```json 块会被剥离,前后夹带
|
||||
// 的解说文字也被忽略(只取第一个 `{` 到最后一个 `}`)—— 容忍格式噪声,但**不猜**:
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// 本文件是**入库前审查调优面**的解析点:把 `$DATA_DIR/config/memory-tidy.json`
|
||||
// 的面板旋钮叠到 ReviewConfig 上 —— 与 tidy_tuning.go 对 TidyConfig 做的事同源、
|
||||
// 同一条口径(主人 2026-10-07:「**`.env` 只做兜底用** —— 后端自动创建独立的面板
|
||||
// 配置文件之后就走面板配置文件,这些键优先从面板配置上取」)。
|
||||
//
|
||||
// 为什么解析放在 memory 而不是 runtimeconfig:runtimeconfig 只负责**文件形状与
|
||||
// 读取**(它的头注释把这条边界写死了),而「缺位/坏值怎么回落」属于 ReviewConfig
|
||||
// 的归一化语义 —— 与 normalizeReviewConfig 同一层,因此两者可以一起被单测覆盖。
|
||||
//
|
||||
// 为什么不是「读盘时逐键容错」:整份文件解不开(含某键类型写错)时**全部键一起**
|
||||
// 回落,这是本文件家族既有的粒度(见 runtimeconfig.LoadMemoryTidyTuning 与它的
|
||||
// 用例③);本文件处理的是**类型正确但值不可用**(非正数)那一档。
|
||||
|
||||
// ApplyReviewTuning 把面板调优旋钮叠到 ReviewConfig 上,返回叠加后的配置
|
||||
// (**未归一化**:调用方随后交给 NewPreInsertReviewer / normalizeReviewConfig
|
||||
// 填代码默认值,与组合根对 TidyConfig 的既有顺序一致)。
|
||||
//
|
||||
// 目前只有一键,逐键独立,三档优先级是:
|
||||
//
|
||||
// 键 面板(memory-tidy.json) 下一档(.env) 代码默认
|
||||
// tempTtlSec tempTtlSec **无此键**(结构性为空) 604800(7 天)
|
||||
//
|
||||
// 🔴 **中档(`.env`)在结构上是空的**,这是**刻意**的、不是漏配:`.env` 已冻结
|
||||
// (design/13 §2.1「不加、不改、不删」),而「多久算临时」是本次新增的能力,
|
||||
// 它在 `.env` 里**从来就没有键**。因此实际生效链是「面板 > 代码默认」——报回报时
|
||||
// 不许把它写成「面板 > .env > 代码默认」而让读者以为 `.env` 那档还有东西可调。
|
||||
//
|
||||
// 坏值(非正秒数)**绝不写进 cfg**:回落代码默认 + 记一条 WARN。一个 ≤0 的 TTL
|
||||
// 会让每条 defer 记忆**落库即过期**(读侧立刻隐藏、后台清扫下一轮就删)——那是
|
||||
// 坏配置而不是「不过期」,但也不许因此让启动失败(design/03 §9.6)。
|
||||
func ApplyReviewTuning(cfg ReviewConfig, panel runtimeconfig.MemoryTidyTuning, log *slog.Logger) ReviewConfig {
|
||||
log = logger.WithSource(baseTidyLog(log), "memory/review")
|
||||
|
||||
if sec := panelPositiveTTLSec(panel.TempTtlSec, "tempTtlSec", log); sec > 0 {
|
||||
cfg.TempTTL = time.Duration(sec) * time.Second
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// panelPositiveTTLSec 解析**临时记忆 TTL** 旋钮,返回 0 表示「不覆盖」(调用方原样
|
||||
// 留着上一档的值,最终由 normalizeReviewConfig 落到代码默认 7 天)。
|
||||
//
|
||||
// 三种情形分得很清,因为它们该有不同的观测形状(与 tidy_tuning.go 的
|
||||
// panelPositiveSec 逐字同构):
|
||||
//
|
||||
// - `nil`(键没写)⇒ 0,**静默**:没配不是错,正是「代码默认兜底」那一档;
|
||||
// - 非正数(写了 0 或负数)⇒ 0 + **WARN**:它会让每条临时记忆落库即过期
|
||||
// (与「不过期」恰好相反),属于坏配置,必须留下痕迹;
|
||||
// - 正数 ⇒ 原值。
|
||||
func panelPositiveTTLSec(v *int, key string, log *slog.Logger) int {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
if *v <= 0 {
|
||||
log.Warn("memory-tidy.json 的临时记忆 TTL 不可用,回落代码默认(非正 TTL 会让临时记忆落库即过期)",
|
||||
"key", key, "value", *v, "fallback_sec", tempTtlSec)
|
||||
return 0
|
||||
}
|
||||
return *v
|
||||
}
|
||||
@@ -73,6 +73,11 @@ func (s *Store) AddScoped(ctx context.Context, actorID string, item MemoryItem)
|
||||
// 作用域上也始终声明身份边界。是否允许受限上下文(群聊 / 陌生人,design §5)
|
||||
// 接收这些内容仍由更高层策略决定;本方法只施加
|
||||
// 基础读规则。
|
||||
//
|
||||
// 与 ListByPersona/List 同口径:只返回 status='active' 且过**时效闸**
|
||||
// (expires_at / valid_to 均未到期)的行 —— 闸只有这里与 ListByPersona /
|
||||
// ListProject / List 四处,检索路径经 Retriever.visibleCandidates 组合调用
|
||||
// 本方法,因此自动获得同一判据(timegate.go 头注、design/03 §11.2)。
|
||||
func (s *Store) ListScope(ctx context.Context, scope Scope, personaID string) ([]MemoryItem, error) {
|
||||
if strings.TrimSpace(personaID) == "" {
|
||||
return nil, ErrMissingPersona
|
||||
@@ -80,10 +85,12 @@ func (s *Store) ListScope(ctx context.Context, scope Scope, personaID string) ([
|
||||
if !scope.valid() {
|
||||
return nil, ErrInvalidScope
|
||||
}
|
||||
now := timeGateNow()
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind,
|
||||
content, tags, confidence, source_conversation, created_at, updated_at,
|
||||
decay_weight FROM memory_items WHERE scope = ? AND status = ?
|
||||
ORDER BY created_at DESC, rowid DESC`, string(scope), string(tidyStatusActive))
|
||||
decay_weight FROM memory_items WHERE scope = ? AND status = ?`+timeGateClause+`
|
||||
ORDER BY created_at DESC, rowid DESC`,
|
||||
string(scope), string(tidyStatusActive), now, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("memory: list scope items: %w", err)
|
||||
}
|
||||
@@ -96,15 +103,17 @@ func (s *Store) ListScope(ctx context.Context, scope Scope, personaID string) ([
|
||||
// 层级(design §3);这是深检索入口 —— 调用方把它与
|
||||
// projectindex 存储结合,沿 项目索引 → 项目描述 → 章节内容 遍历。项目
|
||||
// 记忆所有角色可读,因此不施加角色过滤。projectID 为空
|
||||
// 返回 ErrMissingProjectID。
|
||||
// 返回 ErrMissingProjectID。与其余三条列表读路径同口径:只返回在册且过
|
||||
// **时效闸**的行(timegate.go、design/03 §9.6/§11.2)。
|
||||
func (s *Store) ListProject(ctx context.Context, projectID string) ([]MemoryItem, error) {
|
||||
if strings.TrimSpace(projectID) == "" {
|
||||
return nil, ErrMissingProjectID
|
||||
}
|
||||
now := timeGateNow()
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind,
|
||||
content, tags, confidence, source_conversation, created_at, updated_at,
|
||||
decay_weight FROM memory_items WHERE project_id = ? AND status = ?
|
||||
ORDER BY created_at DESC, rowid DESC`, projectID, string(tidyStatusActive))
|
||||
decay_weight FROM memory_items WHERE project_id = ? AND status = ?`+timeGateClause+`
|
||||
ORDER BY created_at DESC, rowid DESC`, projectID, string(tidyStatusActive), now, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("memory: list project items: %w", err)
|
||||
}
|
||||
|
||||
@@ -108,6 +108,16 @@ type MemoryItem struct {
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DecayWeight float64
|
||||
|
||||
// ExpiresAt 是**临时记忆**的有效期末(design/03 §9.6、design/23 §18.1)。
|
||||
// 零值 = 不设过期,落库为空串,四条列表读路径恒可见;非零值落库为定宽 UTC
|
||||
// 文本,到期后该行即从读路径消失,直到后台清扫物理删除(design/23 §19)。
|
||||
//
|
||||
// ⚠️ **本字段目前只写不读**:Add 会把它写进 expires_at 列,但读路径一律不
|
||||
// SELECT 该列(闸是纯 SQL,见 timegate.go),故 Get/List* 回来的条目
|
||||
// ExpiresAt 恒为零值。要读回时效需同步 13 列 SELECT 与 scanItem,并连带
|
||||
// tidy/preinsert 的内部召回 SQL —— 那是改扫描口径,不在本切片授权范围。
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// 区分 memory 包各类失败的哨兵错误。每个都在
|
||||
@@ -231,6 +241,11 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
|
||||
// 关系表里,语义增强的失败绝不使记忆写入失败,检索侧也会回退到
|
||||
// 内存余弦(见 Retriever.semanticScores)。被闸判为重复的行**不建索引**:
|
||||
// 它不进召回、也不该占掉语义检索 top-K 的一个位置。
|
||||
//
|
||||
// `item.ExpiresAt`(临时记忆的时效,design/03 §9.6)同在这一处落库为
|
||||
// `expires_at` 列:Add 是全仓唯一 INSERT 路径(design/23 §15.1 D3-1),
|
||||
// 因此时效列的真生产者**只能**在这里 —— 读侧时效闸(timegate.go)比对的
|
||||
// 就是这一列,若写入侧不写出它,闸就退化成恒真的空壳。
|
||||
func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
|
||||
if err := item.normalize(); err != nil {
|
||||
return MemoryItem{}, err
|
||||
@@ -247,12 +262,13 @@ func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
|
||||
plan := s.planIngest(ctx, item)
|
||||
_, err = s.db.ExecContext(ctx, `INSERT INTO memory_items
|
||||
(id, persona_id, role_id, project_id, scope, kind, content, tags, confidence,
|
||||
source_conversation, created_at, updated_at, decay_weight, status, merged_into)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
source_conversation, created_at, updated_at, decay_weight, status, merged_into,
|
||||
expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
item.ID, item.PersonaID, item.RoleID, item.ProjectID, string(item.Scope), string(item.Kind),
|
||||
item.Content, string(tagsJSON), item.Confidence, item.SourceConversation,
|
||||
formatTime(item.CreatedAt), formatTime(item.UpdatedAt), item.DecayWeight,
|
||||
plan.insertStatus(), plan.absorbedID())
|
||||
plan.insertStatus(), plan.absorbedID(), expiryText(item.ExpiresAt))
|
||||
if err != nil {
|
||||
return MemoryItem{}, fmt.Errorf("memory: insert item: %w", err)
|
||||
}
|
||||
@@ -276,6 +292,11 @@ func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
|
||||
|
||||
// Get 返回给定 id 的条目。id 为空或
|
||||
// 不存在时返回 ErrNotFound。
|
||||
//
|
||||
// 🔴 **刻意不过任何读侧闸**(status 与时效都不过;design/03 §11.2 第 3 条):
|
||||
// 撤销软合并必须先读得到被吸收的行,人工核查 / 清算也要先读得到已过期与
|
||||
// 已失效的行 —— 加闸等于把审计与撤销的抓手一并抹掉。要「面向用户的可见集」
|
||||
// 请走 ListByPersona / ListScope / ListProject / List 或 Retriever(那些都过闸)。
|
||||
func (s *Store) Get(ctx context.Context, id string) (MemoryItem, error) {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return MemoryItem{}, ErrNotFound
|
||||
@@ -304,14 +325,21 @@ func (s *Store) Get(ctx context.Context, id string) (MemoryItem, error) {
|
||||
// 只返回**在册**(status='active')条目:整理与入库前审查都只做软动作,
|
||||
// 被合并/被归档的行内容原样留在表里,因此「能看到什么记忆」的每条读路径
|
||||
// 都必须过 status 闸,否则清理过的行照样会被检索到、照样占满面板。
|
||||
//
|
||||
// 同时过**时效闸**(timeGateClause):已过期(expires_at ≤ now)与已失效
|
||||
// (valid_to ≤ now)的行一并不再返回 —— 判据细节与「为什么必须下沉到这里」
|
||||
// 见 timegate.go 与 design/03 §9.6/§11.2。
|
||||
func (s *Store) ListByPersona(ctx context.Context, personaID string) ([]MemoryItem, error) {
|
||||
if strings.TrimSpace(personaID) == "" {
|
||||
return nil, ErrMissingPersona
|
||||
}
|
||||
// 闸的左右操作数同源:两边都由 formatTime 产出定宽 UTC 文本。
|
||||
now := timeGateNow()
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind,
|
||||
content, tags, confidence, source_conversation, created_at, updated_at,
|
||||
decay_weight FROM memory_items WHERE persona_id = ? AND status = ?
|
||||
ORDER BY created_at DESC, rowid DESC`, personaID, string(tidyStatusActive))
|
||||
decay_weight FROM memory_items WHERE persona_id = ? AND status = ?`+timeGateClause+`
|
||||
ORDER BY created_at DESC, rowid DESC`,
|
||||
personaID, string(tidyStatusActive), now, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("memory: list persona items: %w", err)
|
||||
}
|
||||
@@ -329,11 +357,15 @@ func (s *Store) ListByPersona(ctx context.Context, personaID string) ([]MemoryIt
|
||||
// 管理面板的记忆列表里(design/23 §8/§10 的软动作口径)。要看它们走
|
||||
// 审查留痕 `GET /admin/memories/reviews` 或按 id 用 Get —— 后者刻意
|
||||
// 不过 status 闸,因为撤销软合并必须先读得到被吸收的行。
|
||||
//
|
||||
// 同样过**时效闸**:临时记忆到期(expires_at ≤ now)与事实失效
|
||||
// (valid_to ≤ now)的行一并从这里消失(timegate.go、design/03 §9.6)。
|
||||
func (s *Store) List(ctx context.Context) ([]MemoryItem, error) {
|
||||
now := timeGateNow()
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind,
|
||||
content, tags, confidence, source_conversation, created_at, updated_at,
|
||||
decay_weight FROM memory_items WHERE status = ?
|
||||
ORDER BY created_at DESC, rowid DESC`, string(tidyStatusActive))
|
||||
decay_weight FROM memory_items WHERE status = ?`+timeGateClause+`
|
||||
ORDER BY created_at DESC, rowid DESC`, string(tidyStatusActive), now, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("memory: list items: %w", err)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
)
|
||||
|
||||
// 本文件是「启动播种」的**值来源**:把 `$DATA_DIR/config/memory-tidy.json` 十键的
|
||||
// 本文件是「启动播种」的**值来源**:把 `$DATA_DIR/config/memory-tidy.json` 十一键的
|
||||
// **当前生效值**拍成一份完整快照,交给 runtimeconfig.SeedMemoryTidy 落盘。
|
||||
//
|
||||
// 它回应主人 2026-10-07 的两条口径:
|
||||
@@ -24,12 +24,12 @@ import (
|
||||
// 的配置,只是侥幸等价;取归一化后的值,等价性才是结构性的(见
|
||||
// TestTidyTuningSnapshot_PanelRoundTripKeepsEffectiveConfig)。
|
||||
//
|
||||
// 为什么只有一处实现:十键的默认值在 memory 侧各只有一处定义
|
||||
// 为什么只有一处实现:十一键的默认值在 memory 侧各只有一处定义
|
||||
// (tidy_config.go / preinsert_config.go 的 default* 常量),快照直接复用那两个
|
||||
// normalize,**不另写一份默认值** —— 否则播种写下去的就是第二份「差不多」的默认值,
|
||||
// 会与运行期悄悄分叉。
|
||||
//
|
||||
// 十键与它们的生效来源:
|
||||
// 十一键与它们的生效来源:
|
||||
//
|
||||
// 键 生效来源(无面板文件时) 快照值
|
||||
// autoApply DisableAutoApply 的反向(代码默认开) true
|
||||
@@ -38,12 +38,13 @@ import (
|
||||
// t1Judge DisableT1 的反向(默认开) true
|
||||
// t1PerWindow defaultT1PerWindow 120
|
||||
// t1WindowSec defaultT1Window 3600
|
||||
// tempTtlSec ReviewConfig.TempTTL(默认 7 天 = 604800) 归一化值
|
||||
// intervalSec TidyConfig.IntervalSec(env 或 86400) 归一化值
|
||||
// quietIntervalSec 缺省 = intervalSec 归一化值
|
||||
// quietStartHour TidyConfig.QuietStartHour(env 或 23) 归一化值
|
||||
// quietEndHour TidyConfig.QuietEndHour(env 或 7) 归一化值
|
||||
|
||||
// TidyTuningSnapshot 拍下十键的**当前生效值**,返回一份可直接落盘的面板快照。
|
||||
// TidyTuningSnapshot 拍下十一键的**当前生效值**,返回一份可直接落盘的面板快照。
|
||||
//
|
||||
// 入参是**已经叠过面板层的**两个配置(组合根的次序:TidyConfigFromConfig →
|
||||
// ApplyTidyScheduleTuning → 本函数;审查侧同理),本函数只做最后一步归一化并翻成
|
||||
@@ -51,10 +52,11 @@ import (
|
||||
//
|
||||
// - tidy 侧四键(intervalSec / quietIntervalSec / quietStartHour / quietEndHour)
|
||||
// 与自动档两键(autoApply / autoApplyPerRound);
|
||||
// - review 侧四键(ingestGate / t1Judge / t1PerWindow / t1WindowSec):
|
||||
// 这四项在 `.env` 里**没有出处**(`.env` 已冻结),只由面板文件与代码默认决定,
|
||||
// 而播种只发生在**面板文件不存在**时 —— 那正是「零值 + normalizeReviewConfig」
|
||||
// 这一条路径,与运行期(wireMemoryReview 的三个 helper)逐字一致。
|
||||
// - review 侧五键(ingestGate / t1Judge / t1PerWindow / t1WindowSec /
|
||||
// tempTtlSec):这五项在 `.env` 里**没有出处**(`.env` 已冻结),只由面板文件
|
||||
// 与代码默认决定,而播种只发生在**面板文件不存在**时 —— 那正是「零值 +
|
||||
// normalizeReviewConfig」这一条路径,与运行期(wireMemoryReview 的四个 helper)
|
||||
// 逐字一致。
|
||||
//
|
||||
// 两个开关是**反向**字段(DisableX),快照要把它们翻回正向的 JSON 键:文件里的
|
||||
// `"autoApply": true` 才是「没配时到底开不开」的如实陈述。
|
||||
@@ -69,6 +71,9 @@ func TidyTuningSnapshot(tidy TidyConfig, review ReviewConfig) runtimeconfig.Memo
|
||||
quietIntervalSec := tidy.QuietIntervalSec
|
||||
quietStartHour := tidy.QuietStartHour
|
||||
quietEndHour := tidy.QuietEndHour
|
||||
// 临时记忆 TTL(design/03 §9.6):normalize 之后恒为正(7 天 = 604800),
|
||||
// 因此快照写下去的**必然是有效值**,不会播出一份自己读回都要 WARN 的配置。
|
||||
tempTtlSec := int(review.TempTTL.Seconds())
|
||||
|
||||
return runtimeconfig.MemoryTidyTuning{
|
||||
AutoApply: &autoApply,
|
||||
@@ -77,6 +82,7 @@ func TidyTuningSnapshot(tidy TidyConfig, review ReviewConfig) runtimeconfig.Memo
|
||||
T1Judge: &t1Judge,
|
||||
T1PerWindow: review.T1PerWindow,
|
||||
T1WindowSec: int(review.T1Window.Seconds()),
|
||||
TempTtlSec: &tempTtlSec,
|
||||
IntervalSec: &intervalSec,
|
||||
QuietIntervalSec: &quietIntervalSec,
|
||||
QuietStartHour: &quietStartHour,
|
||||
|
||||
@@ -41,6 +41,13 @@ const tidyStatusActive = "active"
|
||||
// 最新在前;rowid 打破 created_at 并列,使同一刻写入的条目顺序稳定
|
||||
// (与 ListByPersona 同一口径)。分区过滤写在 SQL 里,而不是读全表再在内存
|
||||
// 里筛 —— design/23 §6 要求在分区切分层就隔断。
|
||||
//
|
||||
// 🔴 **刻意不过时效闸**(design/03 §11.2「主会话已裁定:保持不过时效闸」,
|
||||
// 同一取舍的先例见 design/23 §10.1 ①):整理比对的是**正文**,与「这条对外
|
||||
// 可不可见」无关;已过期的临时记忆随后由后台清扫物理删除(design/23 §19),
|
||||
// 绝不会被整理「复活」。可见性判据只有一处 —— 四条列表读路径共用 timegate.go
|
||||
// 的 timeGateClause。**不要再给这条 SQL 加时效闸。** 本文件另两条分区口径
|
||||
// 查询(listTidyPartitionsSQL / countTidyPartitionItemsSQL)是同一裁定的实例。
|
||||
const listTidyPartitionItemsSQL = `SELECT id, persona_id, role_id, project_id, scope, kind,
|
||||
content, tags, confidence, source_conversation, created_at, updated_at,
|
||||
decay_weight FROM memory_items
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package memory
|
||||
|
||||
import "time"
|
||||
|
||||
// 本文件是**读侧时效闸**的唯一实现(design/03 §9.6 / §11.2),也是「临时记忆到期
|
||||
// 即从读面消失」链路里的第 ① 步(隐藏):判据就是列值本身,零写入、即时生效。
|
||||
// 第 ② 步(后台物理删除)与第 ③ 步(删除前审计)是另一条显式、有界、可停的
|
||||
// 后台路径,**绝不混进读路径**(design/23 §19.1)。
|
||||
//
|
||||
// 🔴 为什么必须下沉到共享底层、不许写在调用点(design/03 §11.2 死角分析):
|
||||
// 记忆读入口有 8 个(对话注入 owner / 群聊陌生人、`knowledge` 工具、capacity L4
|
||||
// 习惯召回、子代理以 B 身份、admin 面板列表 / 搜索 / RAG),但它们**只走同一批
|
||||
// 底层** —— Store.ListByPersona / ListScope / ListProject / List(面板与直接调用方)
|
||||
// 以及 Retriever.visibleCandidates(它组合调用前两条,因此所有复用检索器的入口
|
||||
// 自动获得同一判据)。闸一旦写在某个调用点上,立刻出现「对话看不见、`knowledge`
|
||||
// 工具看得见」的死角。**因此判据只有这一处,四条列表读路径共用。**
|
||||
//
|
||||
// 🔴 同时登记**两处刻意不过闸**(design/03 §11.2 第 3 条;与 design/23 §10.1 ①
|
||||
// 已立的先例同构):
|
||||
//
|
||||
// 1. 按 id 的 Store.Get(store.go)—— 撤销软合并 / 查看已软删行必须先读得到,
|
||||
// 加闸等于把撤销与审计的抓手抹掉。过期行同理:清算与人工核查要读得到。
|
||||
// 2. 整理与审查的**内部**召回 SQL —— listTidyPartitionItemsSQL(tidy_store.go)
|
||||
// 与 listReviewCandidatesSQL(preinsert_store.go)刻意**只过 status 闸**:
|
||||
// 它们比对的是正文,与「这条对外可不可见」无关;过期行随后由后台清扫删除,
|
||||
// 不会复活(design/03 §11.2「主会话已裁定:保持不过时效闸」)。两处都在
|
||||
// SQL 旁落了原位注释:前者在 tidy_store.go(切片 M1),后者的原位注释由
|
||||
// 切片 M2 落在 preinsert_store.go —— 此处登记,免得分头实现时漏掉。
|
||||
// 3. 审查留痕读侧(`GET /admin/memories/reviews`)—— 它读的是留痕表
|
||||
// memory_ingest_reviews(preinsert_schema.go:29),那张表**没有**
|
||||
// status / expires_at / valid_to 任何一列(结构与可见性无关,只记判定结果),
|
||||
// 因此这里根本没有可加的闸;它是软删/被吸收行的**查看入口**,加闸等于审计自我抹除。
|
||||
//
|
||||
// **新增 / 变更任何记忆读路径时,先问这一句**:它要不要看见已过期(临时层)与
|
||||
// 已软删行?答案写进注释(design/03 §11.2)。
|
||||
|
||||
// timeGateClause 是时效闸的 WHERE 片段,拼在四条列表读路径既有的 status 闸之后、
|
||||
// ORDER BY 之前。判据与 design/03 §9.6 逐字一致:
|
||||
//
|
||||
// (expires_at = '' OR expires_at > ?) AND (valid_to = '' OR valid_to > ?)
|
||||
//
|
||||
// 空串是「从未设过期 / 从未失效」(长期记忆与日常记忆的默认值,见
|
||||
// store_schema.go:27-29 的 DEFAULT 空串),因此空串**必须可见**;只有非空才比较。
|
||||
// 两个 ? 都绑定 timeGateNow() 的返回值。
|
||||
const timeGateClause = ` AND (expires_at = '' OR expires_at > ?) AND (valid_to = '' OR valid_to > ?)`
|
||||
|
||||
// timeGateNow 返回时效闸比较用的「当下」。
|
||||
//
|
||||
// 🔴 口径必须与**写入格式同源**(本仓历史事故,DevDocs/开发习惯与约定.md §7):
|
||||
// 列里存的是 formatTime 产出的定宽 30 字节 UTC 文本(timefmt.go:17-21),所以右边
|
||||
// 的「当下」也必须由同一个 formatTime 产出**同形文本**,两边才落在同一个字母表里、
|
||||
// 文本序才等于时间序。反例是拿 SQLite 的 datetime('now')("YYYY-MM-DD HH:MM:SS",
|
||||
// 空格分隔、无小数秒)去比这些列值:'T'(0x54) 恒大于 ' '(0x20),于是**任何当日行
|
||||
// 都「大于当下」**,过期行反而被判为未过期 —— 时间判断静默错,不报错、不 panic。
|
||||
// 同理不许改写成 datetime(expires_at) 之类的函数比较:那是另一套口径,
|
||||
// 一旦写入格式变化就再次静默分叉。timegate_test.go 里有这条反证的实证。
|
||||
func timeGateNow() string {
|
||||
return formatTime(time.Now())
|
||||
}
|
||||
|
||||
// expiryText 把 MemoryItem.ExpiresAt 编码为要落库的 expires_at 文本,是
|
||||
// 「读侧口径与写入格式同源」在**写入侧**的那一半:零值 = 不设过期
|
||||
// (长期 / 日常记忆),写空串(与 DDL 的默认值一致);非零值走 formatTime。
|
||||
//
|
||||
// 它不做任何判据(过去值不在这里被拒、也不被改写):写入侧不决定可见性,
|
||||
// 一条「出生即过期」的行照常落库,四条列表读路径立刻看不见它,直到后台清扫
|
||||
// 把它删掉;Get 仍读得到(见 store.go 的 Get 注释)。
|
||||
func expiryText(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return formatTime(t)
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 本文件是**读侧时效闸**的回归(design/03 §9.6 / §11.2)。
|
||||
//
|
||||
// 判据:status='active' ∧ (expires_at = '' ∨ expires_at > now) ∧ (valid_to = '' ∨ valid_to > now),
|
||||
// 它必须同时出现在**四条列表读路径**上(Store.ListByPersona / ListScope / ListProject / List),
|
||||
// 因为记忆读入口有 8 个而底层只有这四个;Retriever.visibleCandidates 组合调用其中两条,
|
||||
// 因此检索入口自动继承同一判据(本条亦有测试)。任何一条漏闸 = 过期记忆照样进上下文、
|
||||
// 照样出现在 admin 面板(design §11.2 的死角分析)。
|
||||
|
||||
// setItemExpiryForTest 直接改一行的时效列,模拟整理 / D3 第五态落下的时效裁定,
|
||||
// 或「事实已失效」的 valid_to 标记。走 SQL 而不是走写入路径,是为了让本条只验证
|
||||
// **读侧**:写入侧(Add 真的把 ExpiresAt 落成 expires_at 列)由本文件末条测试单独钉。
|
||||
func setItemExpiryForTest(t *testing.T, s *Store, id, expiresAt, validTo string) {
|
||||
t.Helper()
|
||||
if _, err := s.db.ExecContext(context.Background(),
|
||||
`UPDATE memory_items SET expires_at = ?, valid_to = ? WHERE id = ?`,
|
||||
expiresAt, validTo, id); err != nil {
|
||||
t.Fatalf("set expiry (expires_at=%q valid_to=%q) on %s: %v", expiresAt, validTo, id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// assertReadSet 断言读路径返回的条目集合**恰好**是 want(按 id 比对),
|
||||
// 且 hidden 里的条目一条都没漏进来。按 id 而不是按正文断言:正文可能重复,
|
||||
// id 才是行的身份。
|
||||
func assertReadSet(t *testing.T, name string, got, want, hidden []MemoryItem) {
|
||||
t.Helper()
|
||||
gotIDs := idsOfItems(got)
|
||||
wantIDs := idsOfItems(want)
|
||||
if !sameIDs(gotIDs, wantIDs) {
|
||||
t.Fatalf("%s = %v %v, want %v", name, gotIDs, contentsOf(got), wantIDs)
|
||||
}
|
||||
for _, h := range hidden {
|
||||
for _, id := range gotIDs {
|
||||
if id == h.ID {
|
||||
t.Errorf("%s 返回了已过期 / 已失效行 %q(时效闸失效)", name, h.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStore_读路径_过滤已过期行 逐条走四条列表读路径与检索路径,证明「临时记忆到期」
|
||||
// (expires_at ≤ now)与「事实已失效」(valid_to ≤ now)两种行在每一处都不可见,
|
||||
// 而空 expires_at(长期 / 日常记忆的默认值)与未来的 expires_at / valid_to 照常可见。
|
||||
func TestStore_读路径_过滤已过期行(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
now := time.Now().UTC()
|
||||
|
||||
// 可见组:长期(expires_at 空)、临时未到期(Add 直接给 ExpiresAt)、未失效(valid_to 未来)。
|
||||
longTerm := addItem(t, s, MemoryItem{
|
||||
PersonaID: "persona.a", Content: "长期-角色", Scope: ScopePersona, Kind: KindRelationship,
|
||||
})
|
||||
tempAlive := addItem(t, s, MemoryItem{
|
||||
PersonaID: "persona.a", Content: "临时-未到期-角色", Scope: ScopePersona, Kind: KindRelationship,
|
||||
ExpiresAt: now.Add(time.Hour),
|
||||
})
|
||||
tempAliveUser := addItem(t, s, MemoryItem{
|
||||
PersonaID: "persona.a", Content: "临时-未到期-用户", Scope: ScopeUser, Kind: KindWorldEvent,
|
||||
ExpiresAt: now.Add(time.Hour),
|
||||
})
|
||||
validToFuture := addItem(t, s, MemoryItem{
|
||||
PersonaID: "persona.a", Content: "未失效-项目", Scope: ScopeProject,
|
||||
Kind: KindWorldEvent, ProjectID: "proj.1",
|
||||
})
|
||||
|
||||
// 不可见组:临时已到期(写入侧落库的过去值)、valid_to 已过、项目域的临时已到期。
|
||||
tempExpired := addItem(t, s, MemoryItem{
|
||||
PersonaID: "persona.a", Content: "临时-已过期-角色", Scope: ScopePersona, Kind: KindRelationship,
|
||||
ExpiresAt: now.Add(-time.Hour),
|
||||
})
|
||||
validToPast := addItem(t, s, MemoryItem{
|
||||
PersonaID: "persona.a", Content: "已失效-用户", Scope: ScopeUser, Kind: KindWorldEvent,
|
||||
})
|
||||
tempExpiredProject := addItem(t, s, MemoryItem{
|
||||
PersonaID: "persona.a", Content: "临时-已过期-项目", Scope: ScopeProject,
|
||||
Kind: KindWorldEvent, ProjectID: "proj.1",
|
||||
})
|
||||
|
||||
setItemExpiryForTest(t, s, validToPast.ID, "", formatTime(now.Add(-time.Hour)))
|
||||
setItemExpiryForTest(t, s, tempExpiredProject.ID, formatTime(now.Add(-time.Hour)), "")
|
||||
setItemExpiryForTest(t, s, validToFuture.ID, "", formatTime(now.Add(time.Hour)))
|
||||
|
||||
ownVisible := []MemoryItem{longTerm, tempAlive, tempAliveUser, validToFuture}
|
||||
hidden := []MemoryItem{tempExpired, validToPast, tempExpiredProject}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
read func() ([]MemoryItem, error)
|
||||
want []MemoryItem
|
||||
}{
|
||||
{
|
||||
name: "ListByPersona",
|
||||
read: func() ([]MemoryItem, error) { return s.ListByPersona(ctx, "persona.a") },
|
||||
want: ownVisible,
|
||||
},
|
||||
{
|
||||
name: "List",
|
||||
read: func() ([]MemoryItem, error) { return s.List(ctx) },
|
||||
want: ownVisible,
|
||||
},
|
||||
{
|
||||
name: "ListScope/user",
|
||||
read: func() ([]MemoryItem, error) { return s.ListScope(ctx, ScopeUser, "persona.a") },
|
||||
want: []MemoryItem{tempAliveUser},
|
||||
},
|
||||
{
|
||||
name: "ListScope/project",
|
||||
read: func() ([]MemoryItem, error) { return s.ListScope(ctx, ScopeProject, "persona.a") },
|
||||
want: []MemoryItem{validToFuture},
|
||||
},
|
||||
{
|
||||
name: "ListProject",
|
||||
read: func() ([]MemoryItem, error) { return s.ListProject(ctx, "proj.1") },
|
||||
want: []MemoryItem{validToFuture},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := tc.read()
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tc.name, err)
|
||||
}
|
||||
assertReadSet(t, tc.name, got, tc.want, hidden)
|
||||
})
|
||||
}
|
||||
|
||||
// 检索路径:visibleCandidates 是候选集本身(Retrieve/Inject 与所有复用 Retriever 的
|
||||
// 入口都只从它取候选),闸加在它组合调用的 ListByPersona + ListScope 上,
|
||||
// 因此这里必须一条过期行都没有。
|
||||
r := NewRetriever(s)
|
||||
cands, err := r.visibleCandidates(ctx, "persona.a")
|
||||
if err != nil {
|
||||
t.Fatalf("visibleCandidates: %v", err)
|
||||
}
|
||||
assertReadSet(t, "visibleCandidates", cands, ownVisible, hidden)
|
||||
|
||||
results, err := r.Retrieve(ctx, "persona.a", "", 20)
|
||||
if err != nil {
|
||||
t.Fatalf("Retrieve: %v", err)
|
||||
}
|
||||
if len(results) != len(ownVisible) {
|
||||
t.Fatalf("Retrieve = %d 条 (%v), want %d", len(results), idsOf(results), len(ownVisible))
|
||||
}
|
||||
found := make([]MemoryItem, 0, len(results))
|
||||
for _, res := range results {
|
||||
found = append(found, res.Item)
|
||||
}
|
||||
assertReadSet(t, "Retrieve", found, ownVisible, hidden)
|
||||
|
||||
injected, err := r.Inject(ctx, "persona.a", "", 20)
|
||||
if err != nil {
|
||||
t.Fatalf("Inject: %v", err)
|
||||
}
|
||||
for _, h := range hidden {
|
||||
if strings.Contains(injected, h.Content) {
|
||||
t.Errorf("Inject 文本里出现了已过期 / 已失效行 %q(这就是「任何地方都调得到」的反面)", h.Content)
|
||||
}
|
||||
}
|
||||
for _, v := range ownVisible {
|
||||
if !strings.Contains(injected, v.Content) {
|
||||
t.Errorf("Inject 文本缺少在有效期内的 %q", v.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStore_Get_不过时效闸 是反向护栏:按 id 的 Get 刻意**不过** status 闸与时效闸。
|
||||
// 撤销软合并要读得到被吸收的行(design/23 §8 的「无损可逆」),清算与人工核查要读得到
|
||||
// 已过期 / 已失效的行(design/03 §11.2 第 3 条)。若有人「顺手」给 Get 也加闸,本条会红。
|
||||
func TestStore_Get_不过时效闸(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
now := time.Now().UTC()
|
||||
|
||||
tempExpired := addItem(t, s, MemoryItem{
|
||||
PersonaID: "persona.a", Content: "临时-已过期-角色", Scope: ScopePersona, Kind: KindRelationship,
|
||||
ExpiresAt: now.Add(-time.Hour),
|
||||
})
|
||||
validToPast := addItem(t, s, MemoryItem{
|
||||
PersonaID: "persona.a", Content: "已失效-用户", Scope: ScopeUser, Kind: KindWorldEvent,
|
||||
})
|
||||
setItemExpiryForTest(t, s, validToPast.ID, "", formatTime(now.Add(-time.Hour)))
|
||||
|
||||
for _, want := range []MemoryItem{tempExpired, validToPast} {
|
||||
got, err := s.Get(ctx, want.ID)
|
||||
if err != nil {
|
||||
t.Errorf("Get(%s) err = %v, want readable(撤销 / 审计的抓手)", want.ID, err)
|
||||
continue
|
||||
}
|
||||
if got.Content != want.Content {
|
||||
t.Errorf("Get(%s).Content = %q, want %q", want.ID, got.Content, want.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStore_Add_落库expires_at列 钉住写入侧:Add 是全仓唯一 INSERT 路径,
|
||||
// 时效列的**真生产者**只能是它 —— 若 Add 不写这一列,读侧时效闸就退化成恒真空壳
|
||||
// (expires_at 永远为空 ⇒ 过期行永远可见)。零值必须落空串(= 不设过期),
|
||||
// 非零值必须走与读侧闸同一个 formatTime。
|
||||
func TestStore_Add_落库expires_at列(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
expiry := time.Date(2026, 10, 20, 3, 20, 5, 830978900, time.UTC)
|
||||
withExpiry := addItem(t, s, MemoryItem{
|
||||
PersonaID: "persona.a", Content: "临时-带时效", Scope: ScopePersona,
|
||||
Kind: KindRelationship, ExpiresAt: expiry,
|
||||
})
|
||||
withoutExpiry := addItem(t, s, MemoryItem{
|
||||
PersonaID: "persona.a", Content: "长期-无时效", Scope: ScopePersona, Kind: KindRelationship,
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
id string
|
||||
want string
|
||||
}{
|
||||
{"非零 ExpiresAt", withExpiry.ID, formatTime(expiry)},
|
||||
{"零值 ExpiresAt", withoutExpiry.ID, ""},
|
||||
} {
|
||||
var raw string
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT expires_at FROM memory_items WHERE id = ?`, tc.id).Scan(&raw); err != nil {
|
||||
t.Fatalf("%s: read raw expires_at: %v", tc.name, err)
|
||||
}
|
||||
if raw != tc.want {
|
||||
t.Errorf("%s: expires_at = %q, want %q", tc.name, raw, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTimeGate_比较口径与写入格式同源 是本仓历史事故的回归(DevDocs/开发习惯与约定.md §7):
|
||||
// 「SQLite 时间比较用错格式(带 T/Z 的串比 datetime('now'))会把『今天全部』当成『最近一小时』」。
|
||||
//
|
||||
// 闸的处理方式写死为「两边都由 formatTime 产出同形文本,文本序即时间序」,本条把这件事钉三下:
|
||||
// 1. 闸的比较操作数与列值同源(同一个 formatTime,定宽 30 字节 UTC,可被 parseTime 解回);
|
||||
// 2. 同源文本的**文本序 == 时间序**(这是整个闸成立的前提);
|
||||
// 3. 跨格式比较会**系统性判错**(用 SQLite 字面量实证 'T' > ' ' 的陷阱),
|
||||
// 所以任何把闸改成 datetime('now') 之类的第二套口径的改动,都会先在这条上露馅。
|
||||
func TestTimeGate_比较口径与写入格式同源(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
gateNow := timeGateNow()
|
||||
if len(gateNow) != storedTimeLen {
|
||||
t.Fatalf("timeGateNow() = %q (%d 字节), want %d 字节(必须与列值同宽)",
|
||||
gateNow, len(gateNow), storedTimeLen)
|
||||
}
|
||||
back, err := parseTime(gateNow)
|
||||
if err != nil {
|
||||
t.Fatalf("timeGateNow() 不能被 parseTime 解回: %v", err)
|
||||
}
|
||||
if reformatted := formatTime(back); reformatted != gateNow {
|
||||
t.Fatalf("timeGateNow() 与列值格式不同源:再格式化得 %q,不等于 %q", reformatted, gateNow)
|
||||
}
|
||||
|
||||
// 文本序 == 时间序(两个都由 formatTime 产出的值)。
|
||||
earlier := time.Date(2026, 10, 13, 11, 0, 0, 0, time.UTC)
|
||||
later := earlier.Add(time.Nanosecond)
|
||||
if !(formatTime(earlier) < formatTime(later)) {
|
||||
t.Fatalf("formatTime(%v) = %q 不早于 formatTime(%v) = %q —— 文本序已不等于时间序",
|
||||
earlier, formatTime(earlier), later, formatTime(later))
|
||||
}
|
||||
|
||||
// 同源比较:11:00 的列值小于 12:00 的当下 ⇒ 判为「已过期」。
|
||||
var sameSource bool
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT '2026-10-13T11:00:00.000000000Z' < ?`, formatTime(earlier.Add(time.Hour))).
|
||||
Scan(&sameSource); err != nil {
|
||||
t.Fatalf("same-source comparison: %v", err)
|
||||
}
|
||||
if !sameSource {
|
||||
t.Fatal("同源文本比较失效:11:00 的列值未被判为早于 12:00 的当下")
|
||||
}
|
||||
|
||||
// 跨格式比较(历史事故的机制):SQLite 的 datetime('now') 形如
|
||||
// "YYYY-MM-DD HH:MM:SS",第 11 个字节是空格(0x20),而列值第 11 个字节是 'T'(0x54)。
|
||||
// 于是**同一 UTC 日**内,任何列值都「大于当下」—— 早一小时的过期行反而被判为未过期。
|
||||
var crossFormat bool
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT '2026-10-13T11:00:00.000000000Z' > datetime('2026-10-13 12:00:00')`).
|
||||
Scan(&crossFormat); err != nil {
|
||||
t.Fatalf("cross-format comparison: %v", err)
|
||||
}
|
||||
if !crossFormat {
|
||||
t.Fatalf("陷阱实证失效:跨格式比较不再系统性判错('T' vs ' ')," +
|
||||
"请重新核对 timegate.go 的口径与 DevDocs/开发习惯与约定.md §7")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTimeGate_闸不接受第二套时间口径 是结构性护栏:闸的 WHERE 片段里只允许出现
|
||||
// 参数化比较(?),不得出现 SQLite 的时间函数 —— 一旦出现 datetime(...)/julianday(...)
|
||||
// 之类,就有第二套口径,写入格式或 SQLite 行为一变就再次静默分叉(历史事故正是如此)。
|
||||
func TestTimeGate_闸不接受第二套时间口径(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, fn := range []string{"datetime(", "julianday(", "strftime(", "unixepoch(", "date("} {
|
||||
if strings.Contains(timeGateClause, fn) {
|
||||
t.Errorf("timeGateClause 含 SQLite 时间函数 %q:闸必须只做「同源定宽文本」的参数化比较(%s)",
|
||||
fn, timeGateClause)
|
||||
}
|
||||
}
|
||||
if got := strings.Count(timeGateClause, "?"); got != 2 {
|
||||
t.Errorf("timeGateClause 的占位符 = %d, want 2(expires_at 与 valid_to 各一个)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExpiryText_零值落空串 钉住写入侧编码:零值 = 不设过期,必须落空串(列默认值),
|
||||
// 而不是 formatTime(零值)(那会写成 "0001-01-01T00:00:00.000000000Z",反而把长期记忆
|
||||
// 变成「早已过期」—— 长期记忆会整体从读面消失)。
|
||||
func TestExpiryText_零值落空串(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := expiryText(time.Time{}); got != "" {
|
||||
t.Errorf("expiryText(零值) = %q, want 空串(不设过期)", got)
|
||||
}
|
||||
at := time.Date(2026, 10, 20, 3, 20, 5, 0, time.UTC)
|
||||
got := expiryText(at)
|
||||
if want := formatTime(at); got != want {
|
||||
t.Errorf("expiryText(%v) = %q, want %q", at, got, want)
|
||||
}
|
||||
if len(got) != storedTimeLen {
|
||||
t.Errorf("expiryText(%v) 长度 = %d, want %d", at, len(got), storedTimeLen)
|
||||
}
|
||||
}
|
||||
|
||||
// idsOfItems 取条目 id,仅用于失败信息与集合比对。
|
||||
func idsOfItems(items []MemoryItem) []string {
|
||||
out := make([]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
out = append(out, it.ID)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// sameIDs 报告两个**已排序**的 id 切片是否逐项相等。
|
||||
func sameIDs(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -143,6 +143,10 @@ type Options struct {
|
||||
// (channels.json → 环境变量回退)。非 nil 时桥接器支持热重载;
|
||||
// 为 nil 时桥接器仅支持启动期装配(Reload 返回 ErrNilFactory)。
|
||||
Factory PlatformFactory
|
||||
// Channels 是能力注册表的窄端口(见 ChannelRegistry):热重载把新适配器写回
|
||||
// 同名条目,使管理面板读到的永远是当前实例。为 nil 时热重载照常,只是面板
|
||||
// 仍读旧实例(首次同步记一次 WARN)。
|
||||
Channels ChannelRegistry
|
||||
// ChannelPersona 非 nil 时解析某个渠道账号绑定的角色
|
||||
// (channels.json 的 ChannelAccount.PersonaID,design/22 §2 方案 b):调用时传入
|
||||
// 平台与本地账号 id,账号未绑定角色时返回 ""。
|
||||
@@ -253,6 +257,10 @@ type Bridge struct {
|
||||
started bool
|
||||
closed bool
|
||||
factory PlatformFactory
|
||||
// channels 是热重载同步能力注册表的窄端口(reload.go 的 replaceChannelEntry /
|
||||
// dropChannelEntry);为 nil 时面板读到的是保存前的旧实例,首次同步记一次 WARN。
|
||||
channels ChannelRegistry
|
||||
channelsWarnOnce sync.Once
|
||||
// channelPersona 从生效配置解析账号的渠道级角色
|
||||
// (design/22 §2 方案 b);adapterPersona 按已连接的适配器缓存解析结果,
|
||||
// 使入站路径永不重读 channels.json。二者
|
||||
@@ -353,6 +361,7 @@ func New(opts Options) (*Bridge, error) {
|
||||
readCancels: make(map[string]context.CancelFunc),
|
||||
gates: make(map[string]*adapter.FlowGate),
|
||||
factory: opts.Factory,
|
||||
channels: opts.Channels,
|
||||
channelPersona: opts.ChannelPersona,
|
||||
adapterPersona: make(map[string]string),
|
||||
documentsCfg: opts.DocumentStore,
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// PlatformFactory 依据平台当前生效的账号配置
|
||||
@@ -27,6 +28,20 @@ type PlatformFactory interface {
|
||||
Build(platform string) ([]adapter.PlatformAdapter, error)
|
||||
}
|
||||
|
||||
// ChannelRegistry 是能力注册表的**窄端口**,只暴露热重载需要同步的两件事:
|
||||
// 把某个名字的渠道条目换成新适配器、把已消失的账号条目删掉。桥接器用它让管理面板
|
||||
// (渠道列表 / 在线状态 / open·close 解析)读到的永远是**当前**适配器实例,而不必
|
||||
// 依赖 capability/registry 的具体类型 —— 注册表实现与桥接器因此保持解耦。
|
||||
//
|
||||
// 未接线(Options.Channels 为 nil)时桥接器照常热重载,但注册表条目会停在被保存前
|
||||
// 的那个实例上:面板显示「未连接」、点「连接」驱动的是旧实例。此时只记一次 WARN。
|
||||
type ChannelRegistry interface {
|
||||
// ReplaceChannel 用同名的新适配器覆盖(或新增)渠道条目。
|
||||
ReplaceChannel(ch sdk.Channel) error
|
||||
// RemoveChannel 删除该名字的渠道条目。
|
||||
RemoveChannel(name string) error
|
||||
}
|
||||
|
||||
// ErrNilFactory 表示请求重载时未注入平台工厂
|
||||
// (没有热重载配置来源)。
|
||||
var ErrNilFactory = errors.New("channelbridge: nil platform factory")
|
||||
@@ -112,16 +127,19 @@ func (b *Bridge) Reload(platform string) error {
|
||||
}
|
||||
for _, a := range newAdapters {
|
||||
b.connectAndRun(a)
|
||||
// 紧跟上线之后同步能力注册表:新实例先连上,面板才改为读它,
|
||||
// 避免「注册表已指向新实例、它却还没连」的窗口。
|
||||
b.replaceChannelEntry(a)
|
||||
}
|
||||
for _, o := range old {
|
||||
if newNames[o.Name()] {
|
||||
// 同名账号被重建后的适配器替换:停止旧的读取
|
||||
// 循环,但保留注册表条目——它现在指向新适配器。
|
||||
// 同名账号被重建后的适配器替换:只停旧实例的读取循环,
|
||||
// 注册表条目已由 replaceChannelEntry 指向新实例。
|
||||
b.cancelAndDisconnect(o, oldCancels[o.Name()])
|
||||
continue
|
||||
}
|
||||
// 账号被彻底移除(或更名):停止它并删除其注册表
|
||||
// 条目,后续 snapshot 便不再看到它。
|
||||
// 条目,后续 snapshot 与面板便不再看到它。
|
||||
b.removeAdapter(o, oldCancels[o.Name()])
|
||||
}
|
||||
b.log.Info("channel reloaded", "platform", platform, "adapters", len(newAdapters))
|
||||
@@ -148,10 +166,55 @@ func (b *Bridge) adapterSnap(platform string) ([]adapter.PlatformAdapter, map[st
|
||||
return list, cancels
|
||||
}
|
||||
|
||||
// replaceChannelEntry 把刚上线的适配器写回能力注册表。注册表里的渠道条目只在启动
|
||||
// 时写过一次,而热重载重建出来的是同名的新实例:不同步的话,管理面板(渠道列表、
|
||||
// 账号在线状态、open/close 的目标解析)读到的仍是保存前的**旧实例** —— 卡片显示
|
||||
// 「未连接」,点「连接」拨的是旧配置里的地址(design/04 §3.1)。
|
||||
//
|
||||
// 同步失败只记日志、绝不返回错误:面板保存不该被注册表同步拖垮。
|
||||
func (b *Bridge) replaceChannelEntry(a adapter.PlatformAdapter) {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
name := a.Name()
|
||||
if !b.IsConnected(name) {
|
||||
// 没上线就不动注册表:换成连不上的实例只会让面板的在线状态更失真。
|
||||
b.log.Warn("channel registry left unchanged: adapter not connected", "channel", name)
|
||||
return
|
||||
}
|
||||
if b.channels == nil {
|
||||
b.warnChannelRegistryUnwired()
|
||||
return
|
||||
}
|
||||
if err := b.channels.ReplaceChannel(a); err != nil {
|
||||
b.log.Warn("channel registry replace failed", "channel", name, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// dropChannelEntry 在账号被移除(或更名)后删掉它的注册表条目,使管理面板不再列出
|
||||
// 这条已经不存在的账号。同 replaceChannelEntry:失败只记日志。
|
||||
func (b *Bridge) dropChannelEntry(name string) {
|
||||
if b.channels == nil {
|
||||
b.warnChannelRegistryUnwired()
|
||||
return
|
||||
}
|
||||
if err := b.channels.RemoveChannel(name); err != nil {
|
||||
b.log.Warn("channel registry remove failed", "channel", name, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// warnChannelRegistryUnwired 只在首次同步时提醒一次:未注入 Channels 端口时热重载
|
||||
// 照常进行(适配器确实换了),只是面板读到的是保存前的旧实例。
|
||||
func (b *Bridge) warnChannelRegistryUnwired() {
|
||||
b.channelsWarnOnce.Do(func() {
|
||||
b.log.Warn("channel registry not wired: admin panel keeps the pre-reload adapter instance")
|
||||
})
|
||||
}
|
||||
|
||||
// cancelAndDisconnect 停止适配器的读取循环并拆除其会话。
|
||||
// 它绝不改动注册表 map:替换同名适配器的调用方
|
||||
// 希望(新)条目保留,而移除账号的调用方使用
|
||||
// removeAdapter。
|
||||
// 它绝不改动桥接器自己的适配器表与能力注册表:替换同名适配器
|
||||
// 的调用方希望(新)条目保留,而移除账号的调用方使用
|
||||
// removeAdapter(它会连注册表条目一起清掉)。
|
||||
func (b *Bridge) cancelAndDisconnect(o adapter.PlatformAdapter, c context.CancelFunc) {
|
||||
if c != nil {
|
||||
c()
|
||||
@@ -174,5 +237,6 @@ func (b *Bridge) removeAdapter(o adapter.PlatformAdapter, c context.CancelFunc)
|
||||
delete(b.readCancels, name)
|
||||
b.mu.Unlock()
|
||||
b.forgetAdapterPersona(name)
|
||||
b.dropChannelEntry(name)
|
||||
b.log.Info("channel (reload) removed", "channel", name)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package channelbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// 本文件钉住渠道热重载的**注册表同步**(design/04 §3.1):能力注册表里的渠道条目只在
|
||||
// 启动时写过一次,而热重载重建出来的是同名**新实例**。不同步的话,管理面板(渠道卡片、
|
||||
// 账号在线状态、open/close 目标解析)读到的仍是保存前的旧实例——卡片永远「未连接」,
|
||||
// 点「连接」驱动的是旧配置里的那把(拨旧 URL)。
|
||||
|
||||
// errRegistryDown 表示注册表同步失败(被测代码必须吞掉它,只记日志)。
|
||||
var errRegistryDown = errors.New("registry unavailable")
|
||||
|
||||
// errConnectDown 表示新适配器拨号失败。
|
||||
var errConnectDown = errors.New("dial refused")
|
||||
|
||||
// recordingChannelRegistry 是 ChannelRegistry 的记录型实现:按注册名保存**接口值**,
|
||||
// 使断言能按指针同一性判定新旧实例(新旧名字相同,比名字必然假绿)。
|
||||
type recordingChannelRegistry struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]sdk.Channel
|
||||
removed []string
|
||||
replaceErr error
|
||||
removeErr error
|
||||
}
|
||||
|
||||
func newRecordingRegistry(preset ...sdk.Channel) *recordingChannelRegistry {
|
||||
reg := &recordingChannelRegistry{entries: make(map[string]sdk.Channel, len(preset))}
|
||||
for _, ch := range preset {
|
||||
reg.entries[ch.Name()] = ch
|
||||
}
|
||||
return reg
|
||||
}
|
||||
|
||||
func (r *recordingChannelRegistry) ReplaceChannel(ch sdk.Channel) error {
|
||||
if r.replaceErr != nil {
|
||||
return r.replaceErr
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.entries[ch.Name()] = ch
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *recordingChannelRegistry) RemoveChannel(name string) error {
|
||||
if r.removeErr != nil {
|
||||
return r.removeErr
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.removed = append(r.removed, name)
|
||||
delete(r.entries, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// entry 返回当前条目(不存在则 nil),模拟注册表的读侧。
|
||||
func (r *recordingChannelRegistry) entry(name string) sdk.Channel {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.entries[name]
|
||||
}
|
||||
|
||||
// removedNames 返回所有被要求删除的名字。
|
||||
func (r *recordingChannelRegistry) removedNames() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]string(nil), r.removed...)
|
||||
}
|
||||
|
||||
var _ ChannelRegistry = (*recordingChannelRegistry)(nil)
|
||||
|
||||
// downAdapter 是 Connect 必失败的适配器,用来钉住「新实例没上线就不动注册表」。
|
||||
type downAdapter struct {
|
||||
*fakeAdapter
|
||||
err error
|
||||
}
|
||||
|
||||
func (d *downAdapter) Connect(context.Context) error { return d.err }
|
||||
|
||||
// newReloadBridgeWithCaps 与 newReloadBridge 同形,额外注入注册表窄端口。
|
||||
func newReloadBridgeWithCaps(t *testing.T, factory PlatformFactory, caps ChannelRegistry) *Bridge {
|
||||
t.Helper()
|
||||
b, err := New(Options{
|
||||
Loop: &stubChatter{res: &loop.Result{Text: "hi"}},
|
||||
Sessions: &stubSessions{sess: session.Session{SessionID: "s", Owner: true, MemWritable: true}},
|
||||
Owner: &user.OwnerProfile{},
|
||||
Factory: factory,
|
||||
Channels: caps,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { b.Stop(context.Background()) })
|
||||
return b
|
||||
}
|
||||
|
||||
// TestReloadPublishesNewAdapterInstanceToRegistry 是本次修复的主判据:重载后注册表里
|
||||
// 同名条目必须指向**新实例**,否则面板显示「未连接」、点「连接」驱动旧实例。
|
||||
func TestReloadPublishesNewAdapterInstanceToRegistry(t *testing.T) {
|
||||
t.Parallel()
|
||||
old := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "old"}}
|
||||
factory := &stubFactory{}
|
||||
// 启动注册(wire_channels 的职责)写进注册表的就是这个旧实例,重载要覆盖的正是它。
|
||||
reg := newRecordingRegistry(sdk.Channel(old))
|
||||
b := newReloadBridgeWithCaps(t, factory, reg)
|
||||
b.Add(old)
|
||||
b.Start()
|
||||
|
||||
next := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "new"}}
|
||||
factory.adpts = []adapter.PlatformAdapter{next}
|
||||
if err := b.Reload("kook"); err != nil {
|
||||
t.Fatalf("Reload: %v", err)
|
||||
}
|
||||
|
||||
got := reg.entry("kook:conf0")
|
||||
if got == nil {
|
||||
t.Fatal("重载后注册表里没有 kook:conf0:面板会列出「账号已配置但没有已注册的适配器」")
|
||||
}
|
||||
// 指针同一性判据:新旧实例的 Name() 相同,比名字必然假绿。
|
||||
if got != sdk.Channel(next) {
|
||||
t.Fatalf("注册表条目 = %#v, want 新实例", got)
|
||||
}
|
||||
if got == sdk.Channel(old) {
|
||||
t.Fatal("注册表仍指向旧实例:卡片会永远显示未连接,点「连接」驱动的是旧 URL 的那把")
|
||||
}
|
||||
if !got.IsConnected() {
|
||||
t.Fatal("注册表里的条目未连接:面板会显示未连接")
|
||||
}
|
||||
if !b.IsConnected("kook:conf0") || old.IsConnected() {
|
||||
t.Fatalf("桥接器应当只让新实例在线:new=%v old=%v", b.IsConnected("kook:conf0"), old.IsConnected())
|
||||
}
|
||||
}
|
||||
|
||||
// TestReloadRemovesDroppedAccountFromRegistry:账号被删除(或更名)后,它的注册表条目
|
||||
// 必须一并消失,否则面板会继续列出一条已经不存在的账号。同时钉住「同名重建的账号绝不
|
||||
// 被按名字删除」——否则会误删刚写进去的新实例。
|
||||
func TestReloadRemovesDroppedAccountFromRegistry(t *testing.T) {
|
||||
t.Parallel()
|
||||
dropped := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "t0"}}
|
||||
kept := &fakeAdapter{name: "kook:conf1", cfg: map[string]any{"bot_token": "t1"}}
|
||||
factory := &stubFactory{}
|
||||
reg := newRecordingRegistry(sdk.Channel(dropped), sdk.Channel(kept))
|
||||
b := newReloadBridgeWithCaps(t, factory, reg)
|
||||
b.Add(dropped)
|
||||
b.Add(kept)
|
||||
b.Start()
|
||||
|
||||
nextKept := &fakeAdapter{name: "kook:conf1", cfg: map[string]any{"bot_token": "t1b"}}
|
||||
factory.adpts = []adapter.PlatformAdapter{nextKept}
|
||||
if err := b.Reload("kook"); err != nil {
|
||||
t.Fatalf("Reload: %v", err)
|
||||
}
|
||||
|
||||
if reg.entry("kook:conf0") != nil {
|
||||
t.Fatal("被移除账号的注册表条目还在:面板会列出一条不存在的账号")
|
||||
}
|
||||
if got := reg.entry("kook:conf1"); got != sdk.Channel(nextKept) {
|
||||
t.Fatalf("保留账号的条目 = %#v, want 新实例", got)
|
||||
}
|
||||
for _, name := range reg.removedNames() {
|
||||
if name == "kook:conf1" {
|
||||
t.Fatal("同名重建的账号被按名字删掉了:会误删刚写回的新实例")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestReloadWithoutRegistryPortKeepsAdapterRunning:未注入端口时保持旧行为 —— 热重载
|
||||
// 照常成功、新实例照常上线,只是不同步注册表(不得因为缺端口而 panic 或中断重载)。
|
||||
func TestReloadWithoutRegistryPortKeepsAdapterRunning(t *testing.T) {
|
||||
t.Parallel()
|
||||
old := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "old"}}
|
||||
factory := &stubFactory{}
|
||||
b := newReloadBridgeWithCaps(t, factory, nil)
|
||||
b.Add(old)
|
||||
b.Start()
|
||||
|
||||
next := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "new"}}
|
||||
factory.adpts = []adapter.PlatformAdapter{next}
|
||||
if err := b.Reload("kook"); err != nil {
|
||||
t.Fatalf("未接线注册表不该让 Reload 失败:%v", err)
|
||||
}
|
||||
if !next.IsConnected() || !b.IsConnected("kook:conf0") {
|
||||
t.Fatal("未接线注册表时新适配器仍应照常上线")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReloadSurvivesRegistryFailures:注册表同步失败只记日志,绝不中断重载、绝不返回
|
||||
// 错误——面板保存不能被注册表拖垮。
|
||||
func TestReloadSurvivesRegistryFailures(t *testing.T) {
|
||||
t.Parallel()
|
||||
dropped := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "t0"}}
|
||||
kept := &fakeAdapter{name: "kook:conf1", cfg: map[string]any{"bot_token": "t1"}}
|
||||
factory := &stubFactory{}
|
||||
reg := newRecordingRegistry(sdk.Channel(dropped), sdk.Channel(kept))
|
||||
reg.replaceErr = errRegistryDown
|
||||
reg.removeErr = errRegistryDown
|
||||
b := newReloadBridgeWithCaps(t, factory, reg)
|
||||
b.Add(dropped)
|
||||
b.Add(kept)
|
||||
b.Start()
|
||||
|
||||
nextKept := &fakeAdapter{name: "kook:conf1", cfg: map[string]any{"bot_token": "t1b"}}
|
||||
factory.adpts = []adapter.PlatformAdapter{nextKept}
|
||||
if err := b.Reload("kook"); err != nil {
|
||||
t.Fatalf("注册表同步失败不该让 Reload 失败:%v", err)
|
||||
}
|
||||
if !b.IsConnected("kook:conf1") || !nextKept.IsConnected() {
|
||||
t.Fatal("同步失败时新适配器仍应照常上线")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReloadLeavesRegistryAloneWhenNewAccountFailsToConnect:新实例没上线(拨号失败)
|
||||
// 就不写注册表——换成连不上的实例只会让面板的在线状态更失真;无关账号的条目不受影响。
|
||||
func TestReloadLeavesRegistryAloneWhenNewAccountFailsToConnect(t *testing.T) {
|
||||
t.Parallel()
|
||||
old := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "old"}}
|
||||
down := &downAdapter{fakeAdapter: &fakeAdapter{name: "kook:conf1", cfg: map[string]any{"bot_token": "new"}}, err: errConnectDown}
|
||||
factory := &stubFactory{}
|
||||
reg := newRecordingRegistry(sdk.Channel(old))
|
||||
b := newReloadBridgeWithCaps(t, factory, reg)
|
||||
b.Add(old)
|
||||
b.Start()
|
||||
|
||||
nextOld := &fakeAdapter{name: "kook:conf0", cfg: map[string]any{"bot_token": "old2"}}
|
||||
factory.adpts = []adapter.PlatformAdapter{nextOld, down}
|
||||
if err := b.Reload("kook"); err != nil {
|
||||
t.Fatalf("单个适配器连不上不该让 Reload 失败:%v", err)
|
||||
}
|
||||
if reg.entry("kook:conf1") != nil {
|
||||
t.Fatal("没连上的新实例不该写进注册表")
|
||||
}
|
||||
if got := reg.entry("kook:conf0"); got != sdk.Channel(nextOld) {
|
||||
t.Fatalf("已上线账号的条目 = %#v, want 新实例", got)
|
||||
}
|
||||
}
|
||||
@@ -23,11 +23,19 @@ type PluginSummary struct {
|
||||
// NeedsRescan/LastError(v0.39)报告适配器需要主人重新授权
|
||||
// (见 admin_channels_rescan.go 中的 rescanAware);二者仅在适配器
|
||||
// 确实要求时才出现,否则一律省略。
|
||||
//
|
||||
// Pending/Ready/Reason(v0.68)报告 `connected=false` 时**到底是哪种没连上**
|
||||
// (见 admin_channel_connect_outcome.go 中的 connectOutcomeAware):pending =
|
||||
// 已在后台重试、当前未连上(面板读作「连接中」),ready = 已就绪、等待对端接入。
|
||||
// 三者同样只在适配器确实自报时才出现 —— 没有这条状态面的平台逐字节不受影响。
|
||||
type ChannelSummary struct {
|
||||
Name string `json:"name"`
|
||||
Connected bool `json:"connected"`
|
||||
NeedsRescan bool `json:"needsRescan,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
Pending bool `json:"pending,omitempty"`
|
||||
Ready bool `json:"ready,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Accounts []AdminChannelAccount `json:"accounts,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package gateway
|
||||
|
||||
// 本文件承载管理面读取**适配器连接态三态**的那一层:把适配器的 ConnectOutcome
|
||||
// 映射成面板与线上契约能用的形状,使「点了连接之后到底怎么样了」不再只有一个布尔。
|
||||
//
|
||||
// 为什么需要它:`Connect` 返回 nil 只说明「这次调用没有致命失败」。ws+client 的首连
|
||||
// 失败刻意非致命(OneBot 实现可能比 Cyrene 后启动),此时 IsConnected() 为 false ——
|
||||
// 它与「监听已就绪等对端接入」(server 模式,同样 false)在布尔上无从区分,更与
|
||||
// 「配置错、起不来」分不开。网关**绝不**按适配器名或 transport 去猜:口径属于适配器,
|
||||
// 它才知道自己的 transport × mode 意味着什么;网关只做映射。
|
||||
//
|
||||
// 与 accountStatusAware / rescanAware / sessionStatusAware 同一契约风格(见
|
||||
// admin_channel_status.go 与 admin_channels_rescan.go):核心 SDK Channel 契约被刻意
|
||||
// 不扩展,因此没有这条状态面的平台不受任何改动,也不会凭空多出一个字段。
|
||||
|
||||
import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// connectOutcomeAware 是渠道适配器暴露「Connect 之后处于哪种状态」这一可选能力的接口。
|
||||
// 当前由 *qq_obv11.Adapter 实现(ConnectOutcome):它的 ws+client 首连失败非致命、
|
||||
// ws+server 只需监听就绪,两种「没连上」都不是同一件事。
|
||||
//
|
||||
// 返回的 state 是**闭集**取值(internal/channel/adapter 的 ConnectState* 常量):
|
||||
//
|
||||
// "connected" —— 真连上;
|
||||
// "pending" —— 已进入后台重试、当前未连上,reason 说明原因;
|
||||
// "ready" —— 已绑定监听、就绪等对端接入,不是失败;
|
||||
// "" —— 无意见(尚未启动),调用方退回只看 IsConnected()。
|
||||
//
|
||||
// 闭集而不是布尔,是因为「没连上」至少还有两种互不相同的现状,而它们对主人的意义
|
||||
// 完全不同:一种是「还在连」,一种是「就等对面来连」。
|
||||
type connectOutcomeAware interface {
|
||||
// ConnectOutcome 报告连接态;state 取值见上,reason 在未连上时说明原因
|
||||
// (如实的中文/技术短句,**绝不含凭据**)。
|
||||
ConnectOutcome() (state string, reason string)
|
||||
}
|
||||
|
||||
// asConnectOutcomeAware 报告已注册的渠道值是否暴露连接态三态。与 asRescanAware /
|
||||
// asAccountStatusAware 同一套路:注册时会把适配器包进 cmd/cyrene 的 namedAdapter ——
|
||||
// 一个内嵌 adapter.PlatformAdapter 的未导出结构体 —— 它只提升接口的方法,因此具体
|
||||
// 适配器多出的能力只能通过拆开被内嵌字段才能抵达。直接的适配器值(测试)按原样接受。
|
||||
func asConnectOutcomeAware(v any) (connectOutcomeAware, bool) {
|
||||
if co, ok := v.(connectOutcomeAware); ok {
|
||||
return co, true
|
||||
}
|
||||
inner, ok := unwrapNamedAdapter(v)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
co, ok := inner.(connectOutcomeAware)
|
||||
return co, ok
|
||||
}
|
||||
|
||||
// channelConnectOutcome 是连接态三态在**线上**的投影:三态里只有两种「没连上」需要
|
||||
// 额外说明(真连上已经由 connected 布尔说完),reason 只在 pending 时非空。
|
||||
//
|
||||
// 刻意用可选键而不是把三态塞进 connected:connected 的语义已被老客户端当作「连上了
|
||||
// 没有」在读,改写它等于改契约;新增可选键则谁都不吃亏 —— 零值的三态投影让
|
||||
// pending / ready / reason **整键缺席**,与 NeedsRescan / LastError 同一口径。
|
||||
type channelConnectOutcome struct {
|
||||
// Pending 表示「已在后台重试、当前未连上」——面板须把它读作「连接中」,
|
||||
// 绝不是「未连接」,也绝不是「已连接」。
|
||||
Pending bool
|
||||
// Ready 表示「已就绪、等待对端接入」——同样不是失败。
|
||||
Ready bool
|
||||
// Reason 是未连上时的原因(已脱敏),仅在 Pending 时可能非空。
|
||||
Reason string
|
||||
}
|
||||
|
||||
// channelConnectStateOf 报告某渠道适配器当前的连接态:connected 布尔 + 需要额外说明的
|
||||
// 三态投影。
|
||||
//
|
||||
// 适配器实现了 connectOutcomeAware ⇒ **以它为准**:闭集三态优先于 IsConnected() 的
|
||||
// 单个布尔(pending 与 ready 的 IsConnected() 都是 false,但两者都不是「未连接」这样
|
||||
// 一个静态结论)。没实现,或返回空串「无意见」⇒ 逐字退回今天的行为(只看
|
||||
// IsConnected(),三态投影为零值、可选键缺席)。
|
||||
func channelConnectStateOf(ch sdk.Channel) (bool, channelConnectOutcome) {
|
||||
if ch == nil {
|
||||
return false, channelConnectOutcome{}
|
||||
}
|
||||
aware, ok := asConnectOutcomeAware(ch)
|
||||
if !ok {
|
||||
return ch.IsConnected(), channelConnectOutcome{}
|
||||
}
|
||||
state, reason := aware.ConnectOutcome()
|
||||
switch state {
|
||||
case adapter.ConnectStateConnected:
|
||||
return true, channelConnectOutcome{}
|
||||
case adapter.ConnectStatePending:
|
||||
return false, channelConnectOutcome{Pending: true, Reason: reason}
|
||||
case adapter.ConnectStateReady:
|
||||
return false, channelConnectOutcome{Ready: true}
|
||||
default:
|
||||
return ch.IsConnected(), channelConnectOutcome{}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package gateway
|
||||
|
||||
// 本文件锁定**连接态三态**在管理面上的如实转述(2026-10-07 的 UI 撒谎事故):
|
||||
//
|
||||
// - `POST /admin/channels/open` 之后 **不许**无条件报 connected=true;
|
||||
// 适配器自报「重试中」时必须是 connected=false + pending + reason;
|
||||
// - 适配器自报「就绪等接入」(server 模式)时是 connected=false + ready,
|
||||
// 既不是「已连接」,也不是一句静态的「未连接」;
|
||||
// - 没实现这条状态面的平台(绝大多数)响应逐字节不变 —— 可选键整键缺席。
|
||||
//
|
||||
// 断言打在**线上响应**上(不是内部函数),因为「面板读到什么」才是这件事的全部。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
// outcomeChannel 是一个会自报连接态三态的假适配器:Connect 不致命(照常返回
|
||||
// nil),真实状态与原因完全由测试给定,因此「网关有没有照实转述」是可断言的事实。
|
||||
type outcomeChannel struct {
|
||||
name string
|
||||
state string
|
||||
reason string
|
||||
connected atomic.Bool
|
||||
connects atomic.Int32
|
||||
}
|
||||
|
||||
func (c *outcomeChannel) Name() string { return c.name }
|
||||
func (c *outcomeChannel) IsConnected() bool { return c.connected.Load() }
|
||||
func (c *outcomeChannel) HealthCheck(context.Context) error { return nil }
|
||||
func (c *outcomeChannel) ConnectOutcome() (string, string) { return c.state, c.reason }
|
||||
|
||||
func (c *outcomeChannel) Connect(context.Context) error {
|
||||
c.connects.Add(1)
|
||||
// 「真连上」才置位;pending / ready 都必须保持未连上 —— 这正是三态要区分的。
|
||||
c.connected.Store(c.state == "connected")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *outcomeChannel) Disconnect(context.Context) error {
|
||||
c.connected.Store(false)
|
||||
return nil
|
||||
}
|
||||
|
||||
// openChannelData 发起一次 open 并解出 data 对象(原始 map,好断言「键在不在」)。
|
||||
func openChannelData(t *testing.T, ch *outcomeChannel) map[string]any {
|
||||
t.Helper()
|
||||
caps := capreg.New()
|
||||
if err := caps.RegisterChannel(ch); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/channels/open",
|
||||
map[string]any{"name": ch.name}, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("open status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &out); err != nil {
|
||||
t.Fatalf("decode open data: %v (body=%s)", err, body)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestChannelOpenReportsConnectedTruthfully 场景①:适配器自报真连上 ⇒ connected=true,
|
||||
// 且三个可选键一律缺席(没有坏消息要额外说,老客户端形状不变)。
|
||||
func TestChannelOpenReportsConnectedTruthfully(t *testing.T) {
|
||||
t.Parallel()
|
||||
out := openChannelData(t, &outcomeChannel{name: "qq-obv11", state: "connected"})
|
||||
if out["connected"] != true {
|
||||
t.Fatalf("connected = %v, want true", out["connected"])
|
||||
}
|
||||
for _, key := range []string{"pending", "ready", "reason"} {
|
||||
if _, present := out[key]; present {
|
||||
t.Fatalf("%s 不该出现在「真连上」的响应里: %v", key, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelOpenReportsPendingWithReason 场景②(本次事故的正题):适配器自报
|
||||
// 「已在后台重试、当前未连上」时,响应**绝不能**是 connected=true —— 那正是面板弹出
|
||||
// 「已连接」而卡片一直显示「未连接」的根源。必须如实给出 connected=false + pending
|
||||
// + reason,且 resolved/name 等既有键原样保留。
|
||||
func TestChannelOpenReportsPendingWithReason(t *testing.T) {
|
||||
t.Parallel()
|
||||
const reason = "qq-obv11: dial onebot ws ws://localhost:10301/: dial tcp: connection refused"
|
||||
out := openChannelData(t, &outcomeChannel{name: "qq-obv11", state: "pending", reason: reason})
|
||||
|
||||
if out["connected"] != false {
|
||||
t.Fatalf("connected = %v, want false(重试中不许写成已连接)", out["connected"])
|
||||
}
|
||||
if out["pending"] != true {
|
||||
t.Fatalf("pending = %v, want true(面板据此显示「正在连接」而不是「未连接」)", out["pending"])
|
||||
}
|
||||
if out["reason"] != reason {
|
||||
t.Fatalf("reason = %v, want %q(必须把「为什么没连上」如实交出去)", out["reason"], reason)
|
||||
}
|
||||
if _, present := out["ready"]; present {
|
||||
t.Fatalf("ready 不该与 pending 同时出现: %v", out)
|
||||
}
|
||||
if out["name"] != "qq-obv11" || out["resolved"] != "qq-obv11" {
|
||||
t.Fatalf("既有键被破坏: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelOpenReportsReady 场景③:ws+server 已绑定监听、还没有对端接入。
|
||||
// IsConnected() 同样是 false,但它**不是失败**,因此报 ready 而不是 pending,
|
||||
// 也不带 reason(没有坏消息可说)。
|
||||
func TestChannelOpenReportsReady(t *testing.T) {
|
||||
t.Parallel()
|
||||
out := openChannelData(t, &outcomeChannel{name: "qq-obv11", state: "ready"})
|
||||
if out["connected"] != false {
|
||||
t.Fatalf("connected = %v, want false(监听就绪 ≠ 已连上)", out["connected"])
|
||||
}
|
||||
if out["ready"] != true {
|
||||
t.Fatalf("ready = %v, want true(就绪等接入不是失败)", out["ready"])
|
||||
}
|
||||
for _, key := range []string{"pending", "reason"} {
|
||||
if _, present := out[key]; present {
|
||||
t.Fatalf("%s 不该出现在「就绪等接入」的响应里: %v", key, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelOpenWithoutOutcomeKeepsLegacyShape 守住「不动别人」:没实现连接态三态
|
||||
// 的适配器(绝大多数平台)响应与今天逐字节一致 —— 三个可选键整键缺席,只有既有四键。
|
||||
func TestChannelOpenWithoutOutcomeKeepsLegacyShape(t *testing.T) {
|
||||
t.Parallel()
|
||||
caps := capreg.New()
|
||||
only := &spyChannel{name: "kook:3fc8"}
|
||||
if err := caps.RegisterChannel(only); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/channels/open",
|
||||
map[string]any{"name": "kook"}, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &out); err != nil {
|
||||
t.Fatalf("decode: %v (body=%s)", err, body)
|
||||
}
|
||||
if len(out) != 3 || out["connected"] != true {
|
||||
t.Fatalf("data = %v, want exactly {name,resolved,connected:true}", out)
|
||||
}
|
||||
for _, key := range []string{"pending", "ready", "reason"} {
|
||||
if _, present := out[key]; present {
|
||||
t.Fatalf("%s 不该出现(适配器没有这条状态面): %v", key, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelCloseIsNotOptimistic 守住断开路径同样不乐观:报**真实的** IsConnected()。
|
||||
// 一个「Disconnect 返回 nil 但其实没断开」的适配器必须被照实报成 connected=true,
|
||||
// 而不是假定调用成功就等于断开了。
|
||||
func TestChannelCloseIsNotOptimistic(t *testing.T) {
|
||||
t.Parallel()
|
||||
stuck := &outcomeChannel{name: "qq-obv11", state: "pending", reason: "still retrying"}
|
||||
stuck.connected.Store(true) // 假装会话还活着,Disconnect 也没能拆掉它
|
||||
stuckDisconnect := &stubbornChannel{outcomeChannel: stuck}
|
||||
caps := capreg.New()
|
||||
if err := caps.RegisterChannel(stuckDisconnect); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/channels/close",
|
||||
map[string]any{"name": "qq-obv11"}, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("close status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &out); err != nil {
|
||||
t.Fatalf("decode: %v (body=%s)", err, body)
|
||||
}
|
||||
if out["connected"] != true {
|
||||
t.Fatalf("connected = %v, want true(实测仍未断开就必须照实报)", out["connected"])
|
||||
}
|
||||
if _, present := out["pending"]; present {
|
||||
t.Fatalf("关闭路径不该投影三态: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// stubbornChannel 是一个 Disconnect 不真正断开连接的假适配器:用来证明关闭路径读的
|
||||
// 是真实 IsConnected(),而不是「调用成功 ⇒ 一定断开了」。
|
||||
type stubbornChannel struct {
|
||||
*outcomeChannel
|
||||
}
|
||||
|
||||
func (c *stubbornChannel) Disconnect(context.Context) error { return nil }
|
||||
|
||||
// TestChannelListExposesConnectOutcome 守住列表侧:面板卡片的状态来自
|
||||
// GET /admin/channels,因此三态必须在那里也如实出现(平台行与账号行都要),
|
||||
// 否则点完连接刷新一次列表,「连接中」又会变回一句静态的「未连接」。
|
||||
func TestChannelListExposesConnectOutcome(t *testing.T) {
|
||||
t.Parallel()
|
||||
const reason = "qq-obv11: dial onebot ws ws://localhost:10301/: dial tcp: connection refused"
|
||||
ch := &outcomeChannel{name: "qq-obv11:9616b27a", state: "pending", reason: reason}
|
||||
store := channelsStoreOf(t, "qq-obv11",
|
||||
config.ChannelAccount{ID: "9616b27a", Config: map[string]any{"mode": "client"}})
|
||||
caps := capreg.New()
|
||||
if err := caps.RegisterChannel(ch); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps, Channels: store})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("list status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var rows []struct {
|
||||
Name string `json:"name"`
|
||||
Connected bool `json:"connected"`
|
||||
Pending bool `json:"pending"`
|
||||
Reason string `json:"reason"`
|
||||
Accounts []struct {
|
||||
ID string `json:"id"`
|
||||
Connected *bool `json:"connected"`
|
||||
Pending bool `json:"pending"`
|
||||
Reason string `json:"reason"`
|
||||
} `json:"accounts"`
|
||||
}
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &rows); err != nil {
|
||||
t.Fatalf("decode list: %v (body=%s)", err, body)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("rows = %d, want 1 (body=%s)", len(rows), body)
|
||||
}
|
||||
row := rows[0]
|
||||
if row.Connected || !row.Pending || row.Reason != reason {
|
||||
t.Fatalf("平台行 = %+v, want connected=false pending=true reason=%q", row, reason)
|
||||
}
|
||||
if len(row.Accounts) != 1 {
|
||||
t.Fatalf("accounts = %d, want 1", len(row.Accounts))
|
||||
}
|
||||
acct := row.Accounts[0]
|
||||
if acct.ID != "9616b27a" || acct.Connected == nil || *acct.Connected || !acct.Pending || acct.Reason != reason {
|
||||
t.Fatalf("账号行 = %+v, want id=9616b27a connected=false pending=true reason=%q", acct, reason)
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,58 @@ func enrichAccountsWithAccountOnline(accts []AdminChannelAccount, by map[string]
|
||||
return enrichAccountBool(accts, by, func(a *AdminChannelAccount, v bool) { a.AccountOnline = &v })
|
||||
}
|
||||
|
||||
// liveAccountConnectOutcomes 收集每个平台各账号的**连接态三态**(适配器自报的
|
||||
// pending / ready / reason),键与 liveAccountConnections 相同(裸适配器名 ⇒ 空账号 id)。
|
||||
//
|
||||
// 只有确实自报了三态的账号才进 map:三态投影为零值(适配器没这条状态面,或它此刻自认
|
||||
// 「真连上 / 无意见」)就缺席 —— 缺席 = 面板退回只看 connected 布尔,绝不凭空编一个
|
||||
// 状态。补一个 false 顶上去,与本次要根除的「编一个状态」同属一类。
|
||||
func (s *Server) liveAccountConnectOutcomes() map[string]map[string]channelConnectOutcome {
|
||||
out := make(map[string]map[string]channelConnectOutcome)
|
||||
if s.caps == nil {
|
||||
return out
|
||||
}
|
||||
for _, cap := range s.caps.List(capreg.KindChannel) {
|
||||
ch, ok := cap.Value.(sdk.Channel)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
_, outcome := channelConnectStateOf(ch)
|
||||
if outcome == (channelConnectOutcome{}) {
|
||||
continue
|
||||
}
|
||||
platform, acct := splitAdapterName(ch.Name())
|
||||
if out[platform] == nil {
|
||||
out[platform] = make(map[string]channelConnectOutcome)
|
||||
}
|
||||
out[platform][acct] = outcome
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// enrichAccountsWithConnectOutcome 把**连接态三态**合并进平台中匹配的账号(按账号 id
|
||||
// 配对)。不在 by 里的账号保持 Pending/Ready/Reason 全空 ⇒ 整键缺席 ⇒ 面板退回只看
|
||||
// connected。入参切片不被修改。
|
||||
func enrichAccountsWithConnectOutcome(
|
||||
accts []AdminChannelAccount,
|
||||
by map[string]channelConnectOutcome,
|
||||
) []AdminChannelAccount {
|
||||
if len(by) == 0 {
|
||||
return accts
|
||||
}
|
||||
out := make([]AdminChannelAccount, 0, len(accts))
|
||||
for _, a := range accts {
|
||||
item := a
|
||||
if o, ok := by[item.ID]; ok {
|
||||
item.Pending = o.Pending
|
||||
item.Ready = o.Ready
|
||||
item.Reason = o.Reason
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// enrichAccountBool 是上面两个「可选布尔状态」装配器的共同实现:只对 by 里出现过的
|
||||
// 账号调用 set(缺席即字段保持 nil = 未知),因此调用方不必各自重写这段三态逻辑 ——
|
||||
// 三态口径只写一遍,谁也别想在其中一处悄悄把「未知」写成 false。
|
||||
|
||||
@@ -135,11 +135,38 @@ func (s *Server) firstConfiguredChannel(name string) (sdk.Channel, bool) {
|
||||
// channelActionResult 是 open/close 的响应体:name 保持请求里的名字(老客户端
|
||||
// 契约不变),resolved 是**实际被驱动**的适配器注册名(多账号平台上它与请求的
|
||||
// 平台名不同,面板据此显示「连的是哪一条」),accountId 在指定时回显。凭据永不出现。
|
||||
func channelActionResult(target channelTarget, resolved string, connected bool) map[string]any {
|
||||
//
|
||||
// 除既有四个键外,另按适配器自报的**连接态三态**(见 admin_channel_connect_outcome.go)
|
||||
// 补三个**可选**键,且取不到就整键缺席(同 NeedsRescan / LastError 口径):
|
||||
//
|
||||
// pending: true —— 已在后台重试、当前未连上 ⇒ 此时 connected=false。面板须读作
|
||||
// 「连接中(reason)」,**不是**「未连接」,更不是「已连接」;
|
||||
// ready: true —— 已就绪、等待对端接入(server 模式)⇒ 同样 connected=false,
|
||||
// 但它不是失败;
|
||||
// reason: "<已脱敏>" —— 未连上时的原因,仅在 pending 时可能出现。
|
||||
//
|
||||
// 真连上时这三个键一律缺席:connected=true 已经把话说完了,再补一个「没有坏消息」的
|
||||
// 空串只会让老客户端多出一个要处理的键。适配器没实现这条状态面时同理 —— 响应与今天
|
||||
// 逐字节一致。
|
||||
func channelActionResult(
|
||||
target channelTarget,
|
||||
resolved string,
|
||||
connected bool,
|
||||
outcome channelConnectOutcome,
|
||||
) map[string]any {
|
||||
out := map[string]any{"name": target.Name, "resolved": resolved, "connected": connected}
|
||||
if target.AccountID != "" {
|
||||
out["accountId"] = target.AccountID
|
||||
}
|
||||
if outcome.Pending {
|
||||
out["pending"] = true
|
||||
}
|
||||
if outcome.Ready {
|
||||
out["ready"] = true
|
||||
}
|
||||
if outcome.Reason != "" {
|
||||
out["reason"] = outcome.Reason
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
|
||||
botByPlatform := s.liveBotIdentity()
|
||||
connectedByPlatform := s.liveAccountConnections()
|
||||
onlineByPlatform := s.liveAccountOnline()
|
||||
outcomeByPlatform := s.liveAccountConnectOutcomes()
|
||||
out := make([]ChannelSummary, 0)
|
||||
if s.caps != nil {
|
||||
for _, cap := range s.caps.List(capreg.KindChannel) {
|
||||
@@ -87,7 +88,17 @@ func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
|
||||
if s.hasChannelSummary(out, platform) {
|
||||
continue
|
||||
}
|
||||
sum := ChannelSummary{Name: platform, Connected: ch.IsConnected()}
|
||||
// 连接态三态(v0.68):`connected=false` 可能只是「正在后台重试」或
|
||||
// 「监听已就绪、等对端接入」,两者都不是「未连接」这样一个静态结论。
|
||||
// 只有适配器确实自报了才写这三个键,否则一律省略(同 NeedsRescan 口径)。
|
||||
connected, outcome := channelConnectStateOf(ch)
|
||||
sum := ChannelSummary{
|
||||
Name: platform,
|
||||
Connected: connected,
|
||||
Pending: outcome.Pending,
|
||||
Ready: outcome.Ready,
|
||||
Reason: outcome.Reason,
|
||||
}
|
||||
// 可选的 rescan 能力(weixin-iLink 会话被拒,v0.39):仅在适配器
|
||||
// 确实要求重新扫码时才报告,因此健康的适配器保持字段省略,
|
||||
// 而不是送出一个编造的值。
|
||||
@@ -98,9 +109,12 @@ func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
if accts, ok := accountsByPlatform[platform]; ok && len(accts) > 0 {
|
||||
sum.Accounts = enrichAccountsWithAccountOnline(
|
||||
enrichAccountsWithConnection(enrichAccountsWithBot(accts, botByPlatform[platform]), connectedByPlatform[platform]),
|
||||
onlineByPlatform[platform],
|
||||
sum.Accounts = enrichAccountsWithConnectOutcome(
|
||||
enrichAccountsWithAccountOnline(
|
||||
enrichAccountsWithConnection(enrichAccountsWithBot(accts, botByPlatform[platform]), connectedByPlatform[platform]),
|
||||
onlineByPlatform[platform],
|
||||
),
|
||||
outcomeByPlatform[platform],
|
||||
)
|
||||
}
|
||||
out = append(out, sum)
|
||||
|
||||
@@ -70,6 +70,14 @@ type AdminChannelAccount struct {
|
||||
// 或尚未探到结果),面板不得画成「离线」,也不得画成「在线」。
|
||||
// 见 design/24 §10.11 与 Docs/api-client.md §2.4(b)。
|
||||
AccountOnline *bool `json:"accountOnline,omitempty"`
|
||||
// Pending/Ready/Reason 是该账号适配器自报的**连接态三态**(可选能力,见
|
||||
// internal/services/gateway/admin_channel_connect_outcome.go):Connected=false 时
|
||||
// 面板据此把「已在后台重试(连接中)」与「已就绪、等待对端接入」从「未连接」里
|
||||
// 分出来。三者缺省 = 该适配器没有这条状态面 ⇒ 面板退回只看 Connected,
|
||||
// 绝不凭空编一个状态。
|
||||
Pending bool `json:"pending,omitempty"`
|
||||
Ready bool `json:"ready,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
// Capabilities 列出该平台适配器暴露的账号级管理操作
|
||||
// (friends/channels/user-chats/message-test),使面板
|
||||
// 渲染正确的每账号卡片。没有专用适配器表面的平台
|
||||
|
||||
@@ -303,7 +303,12 @@ func (s *Server) adminChannelOpen(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
s.auditClaimsAccount(r, "channel.open", "ok", target.AccountID)
|
||||
writeJSON(w, http.StatusOK, channelActionResult(target, ch.Name(), true))
|
||||
// `Connect` 返回 nil 只说明「这次调用没有致命失败」——ws+client 的首连失败刻意
|
||||
// 非致命(OneBot 实现可能比 Cyrene 后启动),ws+server 则只需监听就绪。所以这里
|
||||
// **绝不**硬编码 connected=true:如实取适配器自报的连接态三态,并把「重试中(含
|
||||
// 原因)」与「就绪等接入」作为可选键一并交出(见 admin_channel_connect_outcome.go)。
|
||||
connected, outcome := channelConnectStateOf(ch)
|
||||
writeJSON(w, http.StatusOK, channelActionResult(target, ch.Name(), connected, outcome))
|
||||
}
|
||||
|
||||
// adminChannelClose 断开一个已注册渠道适配器(真实的生命周期调用)。accountId
|
||||
@@ -324,7 +329,11 @@ func (s *Server) adminChannelClose(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
s.auditClaimsAccount(r, "channel.close", "ok", target.AccountID)
|
||||
writeJSON(w, http.StatusOK, channelActionResult(target, ch.Name(), false))
|
||||
// 断开不乐观:报**真实的** IsConnected(),而不是假定「调用成功 ⇒ 一定断开了」。
|
||||
// 关闭路径刻意不投影三态:pending / ready 说的是「连的过程」,而这里要回答的是
|
||||
// 「会话到底还在不在」——那正是 IsConnected() 的语义。
|
||||
writeJSON(w, http.StatusOK,
|
||||
channelActionResult(target, ch.Name(), ch.IsConnected(), channelConnectOutcome{}))
|
||||
}
|
||||
|
||||
// channelTestRequest 是 POST /api/v1/admin/channels/test 的请求体:一个渠道
|
||||
|
||||
@@ -106,9 +106,13 @@ type Adapter struct {
|
||||
httpAPI *obHTTPClient
|
||||
started bool
|
||||
connected bool
|
||||
cancel context.CancelFunc
|
||||
lifeCtx context.Context
|
||||
newConn chan struct{}
|
||||
// lastConnectErr 是最近一次连接失败的**脱敏**原因,供 ConnectOutcome 如实回答
|
||||
// 「为什么没连上」(见 connect_outcome.go)。连上或断开即清空:一条已经过期的
|
||||
// 原因不该继续挂在当前状态旁边。由 mu 保护。
|
||||
lastConnectErr string
|
||||
cancel context.CancelFunc
|
||||
lifeCtx context.Context
|
||||
newConn chan struct{}
|
||||
|
||||
handlerMu sync.RWMutex
|
||||
handler adapter.MessageHandler
|
||||
@@ -297,6 +301,8 @@ func (a *Adapter) Disconnect(ctx context.Context) error {
|
||||
a.ln = nil
|
||||
a.srv = nil
|
||||
a.httpAPI = nil
|
||||
// 断开即清空上一次失败原因:会话已被拆掉,「为什么没连上」不再是当前事实。
|
||||
a.lastConnectErr = ""
|
||||
// 断开即丢弃账号在线状态:新会话的 known=false 由下一次探活重新确立,绝不沿用
|
||||
// 上一轮的陈旧结论(AccountStatus 的诚实口径)。
|
||||
a.account = accountStatus{}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package qq_obv11
|
||||
|
||||
// 本文件承载 qq-obv11 的**连接态三态**:一次 Connect 返回之后,该适配器到底处于
|
||||
// 「真连上 / 正在重试 / 就绪等接入」中的哪一种,以及没连上时的**脱敏原因**。
|
||||
//
|
||||
// 为什么需要它:`Connect` 返回 nil 只说明「这次调用没有致命失败」——ws+client 的首连
|
||||
// 失败是**故意非致命**的(见 ws_client.go 与 reconnect.go:NapCat 等 OneBot 实现可能
|
||||
// 比 Cyrene 后启动)。于是管理面板拿到的 `connected=false` 既可能是「正在后台重试」,
|
||||
// 也可能是「监听已就绪等对端接入」,而它与「配置错、起不来」在布尔上无从区分;面板只能
|
||||
// 编一个说法,编出来的正是 2026-10-07 那个「点了连接就弹已连接」的谎。三态把这三件事
|
||||
// 分开,并且**由适配器自己回答**——只有它知道自己的 transport × mode 意味着什么。
|
||||
//
|
||||
// 设计依据:DevDocs/design/08-渠道接入.md §1(PlatformAdapter 契约)与 §4.1(四端
|
||||
// transport × mode);三态取值与判定顺序定义在 internal/channel/adapter 的
|
||||
// ConnectStateOf(跨平台共用一份口径,别的适配器接上时不会各自走样)。立场与
|
||||
// design/26 §2.6「状态面必须区分 WS 连着与账号在线」一致:一个布尔说不了三件事。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// maskedCredential 是脱敏后的占位符(与全链路可视化的脱敏标记同形,便于一眼认出
|
||||
// 「这里原本是凭据」而不是「这里本来就长这样」)。
|
||||
const maskedCredential = "[redacted]"
|
||||
|
||||
// ConnectOutcome 报告一次 Connect 之后该适配器处于哪种就绪状态。
|
||||
//
|
||||
// state 是闭集取值(adapter.ConnectStateConnected / ConnectStatePending /
|
||||
// ConnectStateReady),空串表示「无意见」(尚未启动,无从判断);reason 是未连上时的
|
||||
// **脱敏**原因,绝不含 access_token。
|
||||
//
|
||||
// 它是可选的适配器能力:网关经类型断言读取(internal/services/gateway 的
|
||||
// connectOutcomeAware),核心 PlatformAdapter 契约刻意不扩展。
|
||||
func (a *Adapter) ConnectOutcome() (state, reason string) {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return adapter.ConnectStateOf(adapter.ConnectSignals{
|
||||
Started: a.started,
|
||||
Connected: a.connected,
|
||||
// 只有 ws+client 有一条后台重试循环(readLoop 复用 connectClient),因此
|
||||
// 「未连上 ⇒ 连接中」这条推论只在它上面成立;HTTP 没有会话可重连。
|
||||
Retrying: a.cfg.Mode == modeClient && a.cfg.Transport != transportHTTP,
|
||||
// 监听已绑定 ⇒ 就绪等对端接入(ws+server;http+server 也有监听器,但它由
|
||||
// Connect 直接置 connected=true,走不到这里)。
|
||||
Listening: a.ln != nil,
|
||||
Reason: a.lastConnectErr,
|
||||
})
|
||||
}
|
||||
|
||||
// connectFailureReason 把一次连接失败整理成适配器可以如实回答的脱敏原因。
|
||||
func (a *Adapter) connectFailureReason(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return sanitizeConnectReason(err.Error(), a.cfg.AccessToken)
|
||||
}
|
||||
|
||||
// sanitizeConnectReason 把一次连接失败的原因整理成可以对外说的短句。三层防线缺一不可:
|
||||
//
|
||||
// 1. trace.RedactText 做形状级兜底与截断(与全链路可视化同一道红线):一个不设上限的
|
||||
// 错误串不该被原样端给面板,而凭据形状(api_key/bearer/JWT/≥6 字符的 token 赋值)
|
||||
// 在这一层就被抹掉 —— 包括 token 已被换掉、我们并不知道明文的那种情况。
|
||||
// 2. 配置里的 access_token 明文整体替换 —— ws_client.go 的 appendQueryToken 会把 token
|
||||
// 拼进拨号 URL,任何把该 URL 写进错误串的实现都会把它带出来;而**短 token** 逃得过
|
||||
// 第 1 层的形状匹配,只有「已知明文」这一层能兜住。
|
||||
// 3. 剥掉 URL 里 access_token 查询参数的值(含内嵌在引号/反括号里的那些)。
|
||||
//
|
||||
// 顺序是刻意的:先形状级、再逐字剥。反过来(先剥参数再交给形状级)会让第 1 层的贪婪
|
||||
// 匹配把我们已经写好的 `access_token=` 参数名一起吃掉,留下更难读懂的一句话。
|
||||
//
|
||||
// 空原因返回空串 —— 「没有原因」与「原因被脱敏成空」是两件事,绝不互相冒充。
|
||||
func sanitizeConnectReason(raw, token string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
raw = trace.RedactText(raw)
|
||||
if token != "" {
|
||||
raw = strings.ReplaceAll(raw, token, maskedCredential)
|
||||
}
|
||||
return maskAccessTokenQuery(raw)
|
||||
}
|
||||
|
||||
// maskAccessTokenQuery 把 access_token 查询参数的值替换成占位符。大小写不敏感,循环
|
||||
// 处理多次出现(同一错误串里可能带多条 URL)。
|
||||
//
|
||||
// 每轮把已处理的前缀搬进 builder、只对**剩余部分**再查:参数名会被原样保留,因此在同一
|
||||
// 段文本上原地重查会永远命中同一个位置(占位符不改变它的存在),那是死循环而不是脱敏。
|
||||
func maskAccessTokenQuery(raw string) string {
|
||||
const key = "access_token="
|
||||
var b strings.Builder
|
||||
rest := raw
|
||||
for {
|
||||
i := strings.Index(strings.ToLower(rest), key)
|
||||
if i < 0 {
|
||||
b.WriteString(rest)
|
||||
return b.String()
|
||||
}
|
||||
start := i + len(key)
|
||||
end := start
|
||||
for end < len(rest) && !isQueryValueDelimiter(rest[end]) {
|
||||
end++
|
||||
}
|
||||
b.WriteString(rest[:start])
|
||||
b.WriteString(maskedCredential)
|
||||
rest = rest[end:]
|
||||
}
|
||||
}
|
||||
|
||||
// isQueryValueDelimiter 报告该字节是否终止一个查询参数值:`&`/`#` 是 URL 分隔符,
|
||||
// 空白与引号、反括号则是内嵌在错误串里的 URL 的常见收尾。
|
||||
func isQueryValueDelimiter(c byte) bool {
|
||||
switch c {
|
||||
case '&', '#', ' ', '\t', '\n', '\r', '"', '\'', ')', '}', ']':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package qq_obv11
|
||||
|
||||
// 本文件测连接态三态的**适配器落点**:ConnectOutcome 如实回答「Connect 之后处于哪种
|
||||
// 状态」,以及没连上时那条原因确实被脱敏(access_token 绝不出现)。
|
||||
//
|
||||
// 为什么必须有它:2026-10-07 管理面板「点连接就弹已连接」的根因,就是适配器只对外给
|
||||
// 一个 IsConnected() 布尔 —— ws+client 的首连失败刻意非致命(NapCat 等可能比 Cyrene
|
||||
// 后启动),于是「正在重试」「监听就绪」「起不来」三件事在布尔上长得一模一样。
|
||||
// 纯判定部分(adapter.ConnectStateOf)见 internal/channel/adapter 的测试。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
)
|
||||
|
||||
// tokenLeakDialer 是一个**故意把 access_token 拼进错误串**的拨号替身:真实世界里任何
|
||||
// 把拨号 URL 写进错误串的实现都会这样(ws_client.go 的 appendQueryToken 会把 token 拼
|
||||
// 进 URL)。它使「脱敏真的接在了存储与日志前面」成为可断言的事实,而不是一句注释。
|
||||
type tokenLeakDialer struct{ rawURL string }
|
||||
|
||||
func (d tokenLeakDialer) dial(context.Context, string, string) (net.Conn, error) {
|
||||
return nil, fmt.Errorf("test: dial %s failed", d.rawURL)
|
||||
}
|
||||
|
||||
// TestSanitizeConnectReasonStripsAccessToken 断言原因串里的 access_token 绝不出现,
|
||||
// 且留下可读的占位符(面板要能看出「这里原本是凭据」,而不是一段乱码)。
|
||||
func TestSanitizeConnectReasonStripsAccessToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
const token = "s3cr3t-token-value"
|
||||
raw := "websocket: dial ws://hook.example/onebot?access_token=" + token + "&self_id=1: connection refused"
|
||||
got := sanitizeConnectReason(raw, token)
|
||||
|
||||
if strings.Contains(got, token) {
|
||||
t.Fatalf("脱敏后仍含 access_token 明文: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, maskedCredential) {
|
||||
t.Errorf("脱敏后没有留下占位符: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeConnectReasonStripsShortToken 断言**短、且紧邻其它参数**的 token 也剥得掉:
|
||||
// 形状级匹配要求赋值后至少 6 个非空白字符,`access_token=x&y` 这种逃得过它,只有
|
||||
// 「已知明文 + 剥查询参数」这两层能兜住 —— 这正是它们存在的理由。
|
||||
//
|
||||
// (参数名有时会被形状级兜底连同值一起吞掉:少一点诊断信息总好过泄露凭据,
|
||||
// 所以这里只断言**凭据绝不出现**,不绑定具体措辞。)
|
||||
func TestSanitizeConnectReasonStripsShortToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
const token = "ab12"
|
||||
for _, raw := range []string{
|
||||
"dial ws://h/p?access_token=ab12&self_id=1 failed",
|
||||
"websocket: dial ws://h/p?access_token=ab12: connection refused",
|
||||
} {
|
||||
got := sanitizeConnectReason(raw, token)
|
||||
if strings.Contains(got, token) {
|
||||
t.Errorf("短 token 泄露: %q → %q", raw, got)
|
||||
}
|
||||
if !strings.Contains(got, maskedCredential) {
|
||||
t.Errorf("脱敏后没有留下占位符: %q → %q", raw, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeConnectReasonStripsUnknownShortQueryToken 钉住第 3 层(剥 `access_token=`
|
||||
// 查询参数)**独有**的那一小段窗口:值**短于 6 个字符、且紧邻空白/引号等终止符**时,
|
||||
// 形状级那条规则(要求赋值后至少 6 个非空白字符)匹配不上,而「已知明文替换」又因为
|
||||
// 这把值**不是**我们配置的 token 而无从下手 —— 两层都落空,只剩这一层能兜住。
|
||||
//
|
||||
// 边界是实测出来的,不是想象的:`access_token=ab12&self_id=1` 这种写法会被形状级
|
||||
// **顺带**吃掉(它的值字符类一直吃到空格为止,于是 ≥6 字符成立),所以那类串钉不住
|
||||
// 第 3 层;本用例特意让短值后面紧跟一个空白,正好落在第 1、2 层都够不着的那一格。
|
||||
// 去掉第 3 层实现时本条必须变红 —— 它是那一层唯一的钉子。
|
||||
func TestSanitizeConnectReasonStripsUnknownShortQueryToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
const leaked = "ab12"
|
||||
raw := "websocket: dial ws://hook.example/onebot?access_token=" + leaked + " rejected during handshake"
|
||||
got := sanitizeConnectReason(raw, "")
|
||||
if strings.Contains(got, leaked) {
|
||||
t.Fatalf("未配置 token 时仍泄露了他处的 access_token 明文: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, maskedCredential) {
|
||||
t.Errorf("脱敏后没有留下占位符: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeConnectReasonEmptyStaysEmpty 断言「没有原因」与「被脱敏成空」不互相冒充。
|
||||
func TestSanitizeConnectReasonEmptyStaysEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := sanitizeConnectReason("", "tok"); got != "" {
|
||||
t.Fatalf("空原因 = %q, want 空串", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectOutcomeClientPendingCarriesSanitizedReason 场景②(事故正题):ws+client
|
||||
// 首连失败 ⇒ Connect 照旧返回 nil(非致命),但 ConnectOutcome 必须如实报 pending
|
||||
// 并带上**脱敏后**的原因;日志里同样不许出现 token。
|
||||
func TestConnectOutcomeClientPendingCarriesSanitizedReason(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const token = "super-secret-token"
|
||||
const rawURL = "ws://127.0.0.1:10301/onebot"
|
||||
store := &testLogStore{}
|
||||
a := NewAdapter(Config{
|
||||
Mode: modeClient,
|
||||
RemoteURL: rawURL,
|
||||
AccessToken: token,
|
||||
DialContext: tokenLeakDialer{rawURL: appendQueryToken(rawURL, token)}.dial,
|
||||
Logger: slog.New(&testLogHandler{store: store}),
|
||||
})
|
||||
a.policy = testReconnectPolicy()
|
||||
|
||||
if err := a.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("Connect() error = %v, want nil(首连失败必须非致命)", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = a.Disconnect(context.Background()) })
|
||||
|
||||
state, reason := a.ConnectOutcome()
|
||||
if state != adapter.ConnectStatePending {
|
||||
t.Fatalf("ConnectOutcome state = %q, want %q(未连上且重试在跑)", state, adapter.ConnectStatePending)
|
||||
}
|
||||
if state == adapter.ConnectStateConnected {
|
||||
t.Fatal("首连失败被报成已连接 —— 这正是面板撒谎的根因")
|
||||
}
|
||||
if reason == "" {
|
||||
t.Fatal("pending 必须带上原因:面板要能把「为什么没连上」如实说出来")
|
||||
}
|
||||
if strings.Contains(reason, token) {
|
||||
t.Fatalf("原因串泄露 access_token: %q", reason)
|
||||
}
|
||||
assertNoTokenLeak(t, store, token)
|
||||
}
|
||||
|
||||
// TestConnectOutcomeClientConnected 场景①:对面在时拨号成功 ⇒ connected,且没有原因
|
||||
// 可挂(「已连接」旁边不该留一条过期的失败消息)。
|
||||
func TestConnectOutcomeClientConnected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv, _ := newFakeOneBotServer(t)
|
||||
defer srv.Close()
|
||||
a := NewAdapter(Config{
|
||||
Mode: modeClient,
|
||||
RemoteURL: "ws://" + strings.TrimPrefix(srv.URL, "http://"),
|
||||
DialContext: (&net.Dialer{}).DialContext,
|
||||
})
|
||||
a.policy = testReconnectPolicy()
|
||||
|
||||
if err := a.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("Connect() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = a.Disconnect(context.Background()) })
|
||||
|
||||
state, reason := a.ConnectOutcome()
|
||||
if state != adapter.ConnectStateConnected || reason != "" {
|
||||
t.Fatalf("ConnectOutcome = (%q,%q), want (%q,\"\")", state, reason, adapter.ConnectStateConnected)
|
||||
}
|
||||
if !a.IsConnected() {
|
||||
t.Fatal("报 connected 却 IsConnected()=false:两个事实面必须一致")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectOutcomeServerReadyThenConnected 场景③:ws+server 绑定监听但没有对端接入时
|
||||
// 是 ready(IsConnected() 同为 false,却**不是失败**);对端反连上来之后变 connected。
|
||||
func TestConnectOutcomeServerReadyThenConnected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a := NewAdapter(Config{Mode: modeServer, ListenAddr: "127.0.0.1:0"})
|
||||
if err := a.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("Connect() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = a.Disconnect(context.Background()) })
|
||||
|
||||
if a.IsConnected() {
|
||||
t.Fatal("还没有对端接入就报 IsConnected()=true")
|
||||
}
|
||||
if state, reason := a.ConnectOutcome(); state != adapter.ConnectStateReady || reason != "" {
|
||||
t.Fatalf("ConnectOutcome = (%q,%q), want (%q,\"\")", state, reason, adapter.ConnectStateReady)
|
||||
}
|
||||
|
||||
client, err := dialWS(context.Background(), nil, "ws://"+a.Addr()+"/", "")
|
||||
if err != nil {
|
||||
t.Fatalf("dial server error: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
if !waitUntil(3*time.Second, a.IsConnected) {
|
||||
t.Fatal("对端接入后仍未报已连接")
|
||||
}
|
||||
if state, _ := a.ConnectOutcome(); state != adapter.ConnectStateConnected {
|
||||
t.Fatalf("对端接入后 ConnectOutcome state = %q, want %q", state, adapter.ConnectStateConnected)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectOutcomeClearsReasonOnSuccess 断言连上时清空失败原因:一条已经过期的原因
|
||||
// 不该继续挂在「已连接」旁边(面板会把过期的与当前的混为一谈)。
|
||||
func TestConnectOutcomeClearsReasonOnSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv, _ := newFakeOneBotServer(t)
|
||||
defer srv.Close()
|
||||
gate := make(chan struct{})
|
||||
dial := &countingDialer{failTimes: 1, gate: gate, delegate: (&net.Dialer{}).DialContext}
|
||||
a := NewAdapter(Config{
|
||||
Mode: modeClient,
|
||||
RemoteURL: "ws://" + strings.TrimPrefix(srv.URL, "http://"),
|
||||
DialContext: dial.dial,
|
||||
})
|
||||
a.policy = testReconnectPolicy()
|
||||
|
||||
if err := a.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("Connect() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = a.Disconnect(context.Background()) })
|
||||
if state, reason := a.ConnectOutcome(); state != adapter.ConnectStatePending || reason == "" {
|
||||
t.Fatalf("首连失败后 = (%q,%q), want pending 且带原因", state, reason)
|
||||
}
|
||||
|
||||
close(gate) // 对面起来了
|
||||
if !waitUntil(3*time.Second, a.IsConnected) {
|
||||
t.Fatal("第 2 次拨号成功却没有自动连上")
|
||||
}
|
||||
state, reason := a.ConnectOutcome()
|
||||
if state != adapter.ConnectStateConnected || reason != "" {
|
||||
t.Fatalf("连上后 = (%q,%q), want connected 且原因为空(过期的原因必须清掉)", state, reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectOutcomeAfterDisconnectHasNoOpinion 断言断开之后适配器「无意见」:会话已经
|
||||
// 拆掉,「连的过程」不再是当前事实,网关据此退回只看 IsConnected()。
|
||||
func TestConnectOutcomeAfterDisconnectHasNoOpinion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a := NewAdapter(Config{
|
||||
Mode: modeClient,
|
||||
RemoteURL: "ws://127.0.0.1:1/",
|
||||
DialContext: (&countingDialer{failTimes: math.MaxInt}).dial,
|
||||
})
|
||||
a.policy = testReconnectPolicy()
|
||||
if err := a.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("Connect() error = %v", err)
|
||||
}
|
||||
if err := a.Disconnect(context.Background()); err != nil {
|
||||
t.Fatalf("Disconnect() error = %v", err)
|
||||
}
|
||||
if state, reason := a.ConnectOutcome(); state != "" || reason != "" {
|
||||
t.Fatalf("Disconnect 后 = (%q,%q), want 无意见", state, reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectOutcomeConfigErrorStaysFatalNotPending 守住「配置错仍然是硬失败」:外层的 Connect 报错路径没被动过,
|
||||
// 因此配置错永远不会走到 ConnectOutcome 这里被说成 pending。
|
||||
func TestConnectOutcomeConfigErrorStaysFatalNotPending(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a := NewAdapter(Config{Mode: modeClient})
|
||||
err := a.Connect(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("缺 remote_url 的 Connect() = nil, want error")
|
||||
}
|
||||
if !errors.Is(err, errClientConfig) {
|
||||
t.Fatalf("Connect() error = %v, want errClientConfig", err)
|
||||
}
|
||||
if state, _ := a.ConnectOutcome(); state != "" {
|
||||
t.Fatalf("未启动的适配器 ConnectOutcome = %q, want 无意见", state)
|
||||
}
|
||||
}
|
||||
@@ -122,9 +122,15 @@ func (s *reconnectState) reset() {
|
||||
s.failures = 0
|
||||
}
|
||||
|
||||
// noteConnectFailure 记录一次 client 模式的连接失败:推进退避,并写一条带来源的
|
||||
// WARN(含 err 与下次重试间隔)。access_token 绝不入日志。
|
||||
// noteConnectFailure 记录一次 client 模式的连接失败:推进退避,把**脱敏后的**原因留在
|
||||
// 适配器上(供 ConnectOutcome 如实回答「为什么没连上」,见 connect_outcome.go),并写一条
|
||||
// 带来源的 WARN(含原因与下次重试间隔)。原因与日志都走同一道脱敏,因此 access_token
|
||||
// 既不落日志、也不进对外文案。
|
||||
func (a *Adapter) noteConnectFailure(err error) {
|
||||
delay := a.reconnect.fail(a.policy)
|
||||
a.log.Warn("onebot connect failed; retrying", "err", err, "retryIn", delay)
|
||||
reason := a.connectFailureReason(err)
|
||||
a.mu.Lock()
|
||||
a.lastConnectErr = reason
|
||||
a.mu.Unlock()
|
||||
a.log.Warn("onebot connect failed; retrying", "err", reason, "retryIn", delay)
|
||||
}
|
||||
@@ -330,6 +330,9 @@ func (a *Adapter) setConn(conn *wsConn) {
|
||||
old := a.conn
|
||||
a.conn = conn
|
||||
a.connected = true
|
||||
// 会话真的活了 ⇒ 上一次失败的原因不再是当前事实,立刻清掉,免得面板把一条已经
|
||||
// 过期的原因挂在「已连接」旁边。
|
||||
a.lastConnectErr = ""
|
||||
a.mu.Unlock()
|
||||
if old != nil && old != conn {
|
||||
_ = old.Close()
|
||||
|
||||
Reference in new issue
Block a user