feat(gateway): 渠道事件查询面 + 面板「渠道事件」页(design/26 E6)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 两个只读端点:GET /admin/channels/events(列表,8 个过滤条件 + 分页钳制)与 .../events/counters(逐类计数,恒带 approx:true 如实标「近似」)
- 诚实边界:>90 天窗口 ⇒ 400(不静默裁剪);未知 platform/档位 ⇒ 400;**未接线 ⇒ 500 channel_events_unavailable**(绝不回空 200 假装「没有事件」);微信平台的空如实说明「该渠道不提供非消息事件」而不借别平台顶替
- 面板:新 section「渠道事件」(计数卡 + 事件行 + 过滤条 + 30s 静默刷新),空/错误态分开且失败显示错误不显示 0
- 组合根接线(主会话补):wire_frontend.go 的 Deps 注入 EventStore: a.channelEventStore()(未接线时为真 nil ⇒ 保留诚实 500)
- 契约不升版(查询面 v0.67 已登记):就地改「未实现 → 已实现 + 端点路径」,并补登记 5 处原先留白的落地值(camelCase / 通用 bad_request / RBAC owner|admin / data.approx / total 语义)
- 顺带把 §5 三个 CHANNEL_EVENT_* env 键由「尚未实现」改「已接线」(E4)
- 变异实测:去掉 90 天上限 ⇒ status 200 want 400;未接线回空 200 ⇒ status 200 want 500
- 债务登记:events.Filter 无 AccountID 列 ⇒ 服务端暂不能按账号筛(列表仍如实下发 accountId),要加需先改 internal/runtime/events
This commit is contained in:
AskaEth committed 2026-10-05 14:38:37 +08:00
1 parent a0abf0199a
commit c8053b1f36
21 files changed
+1805 -30

No files matched your search

+27 -25
View File
@@ -6,7 +6,7 @@
> **旧的超详细版已归档、只读**:`Docs/archive/api-client.v0.65.md`(412 KB,含 v0.28→v0.65 全部历史条目)与 `Docs/archive/api-reference.v0.65.md`(170 KB,后端全量字段级文档)。归档的原因是**结构性臃肿 + 历史条目并列 + 长期漂移**;本文档**不复制**那段历史,需要考古时去归档。
> **维护铁律**:每实现/变更一个 REST 端点、WS 事件、admin API **立即改本文档**;契约变更必须版本化,不破坏既有客户端。
> **准确性 > 详尽度**:宁少写也别写错;**写不了确证的写「未确证」**,见 [§7](#7-在飞切片与未实现未确证)。
> ⚠️ **v0.67 是「契约先行」登记版**:本版新增的三批 —— **语音消息**(播放端点 + 消息 DTO `voice`)、**非消息事件查询面**、**账号在线状态** —— **当前尚未实现**,形状**取自设计文档**(`design/24 §10.11`、`design/26 §4.5` / `§2.6`)**而非代码核对**;已在 §7.2 逐条如实标注。**本文档其余内容仍以代码为真源**。
> ⚠️ **v0.67 是「契约先行」登记版**:本版新增的三批 —— **语音消息**(播放端点 + 消息 DTO `voice`)、**非消息事件查询面**、**账号在线状态** —— 登记时**都还没有实现**,形状**取自设计文档**(`design/24 §10.11`、`design/26 §4.5` / `§2.6`)**而非代码核对**。**已落地**:账号在线状态(`52dac45`)、非消息事件查询面(本切片,E6)—— 见 §2.4;**语音消息尚未实现**。已在 §7.2 逐条如实标注。**本文档其余内容仍以代码为真源**。
> **v0.68 是纯增量版(已实现,非契约先行)**:新增 1 条管理只读端点 `GET /api/v1/admin/config-facts`(配置事实面,见 §2.2 B12),并给 `GET /api/v1/admin/backthink` 的条目加 2 个可选字段(`urgency` / `quietHoursBypassed`,见 §2.2 B8)。**无破坏性变更**,老客户端零影响。
> **v0.69 是缺口修复版(已实现)**:`PUT /api/v1/admin/channels/{platform}/config` 现在**接收并原样落盘**账号顶层 `directMsgPerMin`,`GET /api/v1/admin/channels` 的账号元素也**如实回显**它(§2.2 A5 / §2.3 C28 注)。修的是「面板保存一次渠道配置,就把主人手配的**关闭流控(-1)**静默重置回 `0` = 保护性默认 6 条/分」。**无破坏性变更**(老客户端不带该键,行为与 v0.68 相同)。
> **v0.70 是精确化版(已实现,纯增量)**:`GET /api/v1/admin/backthink` 的 `urgency` **默认就是该轮实际生效的精确档位**(随记录落库),另加可选 `urgencyLowerBound:true` 专门标记**旧行的下限推导值**;实时 `backthink` 帧自本版起也带记录里的 `urgency`(§2.2 B8 / §4.10(b),并更正 §4.10(b) 原「实时帧不带这两键」的表述)。**无破坏性变更**(新键可选,老客户端不读即零影响;`urgency` 的既有取值域与 `quietHoursBypassed` 语义都未改)。
@@ -61,7 +61,7 @@
|---|---|
| `HistoryStore` | `GET /sessions` 回退到渠道会话视图;`POST /sessions`、`GET /sessions/{id}/messages`、`PATCH/DELETE /sessions/{id}`、`POST /sessions/{id}/read`、`PATCH/DELETE /messages/{id}` → `501 not_implemented` |
| 语音服务 | `POST /audio/*` → `500 voice_unavailable`;WS `voice_input` → `error` 帧 `{"message":"voice unavailable"}` |
| 渠道事件存储(`internal/runtime/events`) | `GET /admin/channels/events` 与 `.../events/counters` → `500 channel_events_unavailable`(**v0.67 登记,尚未实现**;绝不回全 0 假汇总,见 §2.4(a)) |
| 渠道事件存储(`internal/runtime/events`) | `GET /admin/channels/events` 与 `.../events/counters` → `500 channel_events_unavailable`(**v0.67 已实现**,见 §2.4(a);绝不回全 0 假汇总) |
| LLM usage 出口 | `GET /admin/llm/usage` → `500 usage_unavailable`(绝不回全 0 假汇总) |
| 活跃 SQLite 连接 | `GET /admin/system/storage` → `500 storage_unavailable` |
| 可观测日志缓冲 | `GET /admin/observability/logs` → `501`(组合根注入前) |
@@ -223,24 +223,26 @@
> - `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 登记、**尚未实现**的管理面(渠道事件查询 + 账号在线状态)
### 2.4 v0.67 登记的管理面(渠道事件查询 + 账号在线状态)—— **(a) 已实现 / (b) 已实现**
> ⚠️ **本节全部内容尚未进 `routes.go`**(截至契约 v0.67 冻结):形状取自设计文档(`design/26 §4.5`、`§2.6`),**不是**代码核对所得。**§2.0 的计数(12 + 11 + 40 = 63)不含本节端点**;实现落地后再回填 §2.1/§2.2 并更新计数。客户端**不要**现在就按本节编程到「已可用」——以 §7.2 的状态为准。
> ⚠️ **本节两项都已落地**(账号在线状态 `52dac45`;渠道事件查询面本切片 E6):形状取自设计文档(`design/26 §4.5`、`§2.6`),落地时已按代码核对。**§2.0 的计数(12 + 11 + 40 = 63)原本不含本节端点**;两个渠道事件端点已登记进 `routes.go`(`owner|admin` 档),下版改文档时应把它们并入 §2.1/§2.2 并更新计数。**本行留在此处仅为与 v0.67 冻结版对照**。
#### (a) 渠道事件(非消息事件)只读查询
#### (a) 渠道事件(非消息事件)只读查询 —— **已实现**
| # | 方法 | 路径 | 要点 |
|---|---|---|---|
| D1 | GET | `/api/v1/admin/channels/events` | 非消息事件列表(**倒序**)。query:`platform` / `tier` / `category` / `channelId` / `since` / `until` / `limit` / `offset`。响应 `{data:{events:[…], total}}`。`since` / `until` 按 **UTC ISO-8601 定宽字符串**做**字典序**比较(与 `GET /admin/llm/usage` 同口径);**跨度上限 90 天**(超 → `400`);`platform` 未知 → `400`。**不含正文、不含任何媒体 URL、不含任何凭据 / `flag`** |
| D2 | GET | `/api/v1/admin/channels/events/counters` | 逐类计数汇总(**近似值**)。query:`platform`。响应 `{data:{counters:[{platform,accountId,tier,category,platformType,count,firstSeenAt,lastSeenAt}]}}`,并带 **`approx:true`** 如实标注「计数为聚合近似值」(计数是**内存聚合 + 周期 flush**,非逐条实时)。⚠️ `approx` 的**嵌套层级**设计未明说,见 §7.4 |
| D2 | GET | `/api/v1/admin/channels/events/counters` | 逐类计数汇总(**近似值**)。query:`platform`。响应 `{data:{approx:true, counters:[{platform,accountId,tier,category,platformType,count,firstSeenAt,lastSeenAt}]}}`,`approx:true` 如实标注「计数为聚合近似值」(计数是**内存聚合 + 周期 flush**,非逐条实时)。**落地口径**:`approx` 与 `counters` **同级**(都在 `data` 内,见 §7.4-11 的落地记录) |
- **RBAC 档位未定**:设计原文写「owner/admin;`viewer` 只读亦可,按既有 RBAC 惯例」—— 两种解释都成立,本契约**不替它二选一**(见 §7.4-12)。落地前**不要**假设 viewer 能读。
- **RBAC 档位已定(落地口径)**:两个端点走 **`owner|admin`**(`writeAuth`),与同族的渠道消息日志面(§2.1 B2/B3、`/admin/channels/messages`)同档 —— 事件行带群 id / 成员 id / 关联消息 id,属私有内容面,不是给 viewer 的监控投影。**viewer 为 `403`**(已进 `rbac_viewer_test.go` 的 `ownerAdminReadRoutes` 覆盖)。
- **参数缺省与错误码(落地口径,见 §7.4-11)**:`limit` ≤0 或未给 ⇒ 用存储层缺省 `100`;超硬上限 ⇒ **钳到 `500`**(不报错,`total` 仍如实给出筛选条件下的总条数);非法整数(如 `limit=abc`)⇒ `400 bad_request`;`since`/`until` 非法 ⇒ `400 bad_request`;`platform` 未知 ⇒ `400 bad_request`;`tier` 非 `t1/t2/t3` ⇒ `400 bad_request`。⚠️ **`category` 不做白名单校验**:类别轴的权威在适配器层且会持续增加,未知类别是一次**精确匹配**(返回空),不是 400。
- **时间窗缺省(落地口径)**:**没给 `since`/`until` 就不注入时间条件**(不是「近 24h」)—— 端点绝不替客户端造一个下界(那正是「静默裁剪」)。只给 `since` 时按「现在」判跨度(超 90 天 ⇒ 400),但**不**把「现在」写进过滤条件。
- **数据源两张表**(`design/26 §4.1`,表名**复数**):`channel_events`(`tier` 为 `t2`/`t3` 的事件与**所有** `unknown` 逐条留存,**参与保留期 GC**)与 `channel_event_counters`(`t1` 与**所有**帧的计数,按 `(platform, account_id, platform_type)` upsert,**不参与 GC**)。
- `tier` 取值域:`t1`(只计数)/ `t2` / `t3`(逐条留存;T3 是「谁对谁做了什么」的定向交互记录)。`category` 取值域(**归一化类别**,设计原文给出 `heartbeat` / `lifecycle` / `join` / `leave` / `recall` / `ban` / `admin` / `friend_request` / `group_request` / `poke` / `reaction` / `button` / `vote` / `item` / `link` / `unknown`,并在末位带 `…` ⇒ **非穷举**,客户端**未知取值原样展示、不报错**)。
- `events[]` 元素 = 一条 `channel_events` 行的投影。设计只给到**落库列**:`id` / `platform` / `account_id` / `tier` / `category` / `platform_type` / `channel_id` / `channel_type` / `group_id` / `actor_id` / `actor_role`(`owner`/`member`/`system`/`unknown`,结构化解析所得)/ `target_id` / `self_related` / `message_id` / `summary` / `created_at` / `updated_at`。`actor_id`/`target_id` 是 **id 不是昵称**;`summary` 是**固定文案**(如「群成员加入」,**不含任何平台原文**)。⚠️ **元素的 JSON 键名设计未逐字给出**,见 §7.4-9 —— 客户端**现在不要硬编码**。
- **未接线存储时**:两个端点都回 **`500 channel_events_unavailable`**(对齐 `usage_unavailable` / `storage_unavailable` 的诚实边界口径,**绝不回全 0 假汇总**)。
- `events[]` 元素 = 一条 `channel_events` 行的投影。**落地口径(见 §7.4-9):键名一律 camelCase,逐字如下** —— `id` / `platform` / `accountId` / `tier` / `category` / `platformType` / `channelId` / `channelType` / `groupId` / `actorId` / `actorRole`(`owner`/`member`/`system`/`unknown`,结构化解析所得)/ `targetId` / `selfRelated`(bool)/ `messageId` / `summary` / `createdAt` / `updatedAt`(后两者是 **UTC RFC3339**,与 `llm/usage` 的时间回显同口径)。`actorId`/`targetId` 是 **id 不是昵称**;`summary` 是**固定文案**(如「群成员加入」,**不含任何平台原文**)。无数据时 `events` 是 `[]`(不是 `null`)。
- **未接线存储时**:两个端点都回 **`500 channel_events_unavailable`**(对齐 `usage_unavailable` / `storage_unavailable` 的诚实边界口径,**绝不回全 0 假汇总**)。读库失败同样是 `500 channel_events_unavailable`(错误文案不同)。
- 三个新 env 键(`design/26 §4.4`,已登记进 §5):`CHANNEL_EVENT_RETENTION`(默认 `720h`)、`CHANNEL_EVENT_GC_INTERVAL`(默认 `1h`)、`CHANNEL_EVENT_COUNTER_FLUSH`(默认 `30s`)。
- ⚠️ **微信渠道不提供非消息事件**:查询面 / 面板对该渠道**如实显示空 + 说明文字**,绝不伪造、绝不用别的渠道事件顶替。
- ⚠️ **微信渠道不提供非消息事件**:查询面 / 面板对该渠道**如实显示空 + 说明文字**,绝不伪造、绝不用别的渠道事件顶替(落地时已有测试钉住:`platform=weixin-ilink` 的响应里绝不出现别的平台的事件)。
#### (b) `GET /api/v1/admin/channels` 的账号项:`accountOnline`
@@ -419,13 +421,13 @@
| 413 | `payload_too_large`、`document_too_large` | 上传/文档超限 |
| 415 | `unsupported_media_type` | 图片/文档类型拒绝 |
| 429 | `rate_limited` | 限流(框架级) |
| 500 | `internal`、`chat_unavailable`、`chat_failed`、`voice_unavailable`、`stt_failed`、`tts_failed`、`tts_empty`、`voice_clone_failed`、`usage_unavailable`、`storage_unavailable`、`channel_events_unavailable`(**v0.67 登记、尚未实现**)、`refresh_unavailable`、`sessions_failed`、`messages_failed`、`session_failed`、`message_failed`、`document_upload_failed`、`login_failed` | 内部错误或依赖未接线(**绝不伪装成功**) |
| 500 | `internal`、`chat_unavailable`、`chat_failed`、`voice_unavailable`、`stt_failed`、`tts_failed`、`tts_empty`、`voice_clone_failed`、`usage_unavailable`、`storage_unavailable`、`channel_events_unavailable`(**v0.67 已实现**,见 §2.4(a))、`refresh_unavailable`、`sessions_failed`、`messages_failed`、`session_failed`、`message_failed`、`document_upload_failed`、`login_failed` | 内部错误或依赖未接线(**绝不伪装成功**) |
| 501 | `not_implemented` | HistoryStore 等未注入 |
| 503 | `timeout`、`chat_dispatch_failed`、`catalog_unavailable` | 派发/上游不可用 |
> **未确证**:`conflict`(409) 的通用码存在(框架级 `ErrConflict`),但除了 `chat_busy` 之外,本文档未逐一确证还有哪些端点会带它。
> **⚠️ `416` 不是错误信封(v0.67 登记,尚未实现)**:`GET /api/v1/media/{name}` 的 `Range` 语法非法或范围不可满足时,**由 Go 标准库直写** `416`(`text/plain` + `Content-Range: bytes */<size>`),**不套** `{"error":{…}}` ⇒ 客户端**不要**拿 `error.code` 去解析它(见 §4.5)。这是字节流端点的既有事实(`/audio/speech` 的 `200`、`/documents/{name}` 的 `200` 同样不是 JSON 信封),**不是**本版新引入的怪癖。
> **`400` 的渠道事件用法(v0.67 登记,尚未实现)**:`GET /admin/channels/events` 对 `platform` 未知、`since`/`until` 跨度 > 90 天都回 `400`(**具体 `code` 名设计未给出**,见 §7.4-11)。
> **`400` 的渠道事件用法(v0.67 已实现,见 §2.4(a))**:`GET /admin/channels/events` 对 `platform` 未知、`tier` 非法、`since`/`until` 跨度 > 90 天、`since`/`until` 时间点非法、`limit`/`offset` 非整数都回 `400`(**落地用的 `code` 是通用的 `bad_request`**,见 §7.4-11)。
### 4.2 分页与游标
@@ -763,9 +765,9 @@
| `SUBAGENT_TIMEOUT_SEC` | `600` | 单个子代理整次运行时限 |
| `MCP_SERVERS_JSON` | 空 | MCP server 注册表(JSON 数组)。⚠️ 扁平 `MCP_NAME`/`MCP_COMMAND`/… 为**未接线残留键,配了不生效** |
| `CHANNEL_*` / `CHANNELS_JSON` | 见模板 | 渠道接入(`.env` 为兜底,面板 `channels.json` 优先) |
| `CHANNEL_EVENT_RETENTION` | `720h` | **v0.67 登记(尚未实现)**:`channel_events` 保留期 —— 比文档的 `168h` 长,因为事件表的核心用途是**事后定案**(一周太短);`≤0` = 关闭 GC |
| `CHANNEL_EVENT_GC_INTERVAL` | `1h` | **v0.67 登记(尚未实现)**:渠道事件 GC 扫描间隔(与 `DOCUMENT_GC_INTERVAL` 同范式) |
| `CHANNEL_EVENT_COUNTER_FLUSH` | `30s` | **v0.67 登记(尚未实现)**:计数内存聚合的 flush 间隔。计数是**尽力而为的近似值**(进程崩丢 ≤30s),查询面以 `approx:true` 如实标注(见 §2.4(a)) |
| `CHANNEL_EVENT_RETENTION` | `720h` | **v0.67 登记,已接线**(E4 `afe9afb`;`.env.example` 同步于 `f9c5b56`):`channel_events` 保留期 —— 比文档的 `168h` 长,因为事件表的核心用途是**事后定案**(一周太短);`≤0` = 关闭 GC |
| `CHANNEL_EVENT_GC_INTERVAL` | `1h` | **v0.67 登记,已接线**(E4 `afe9afb`):渠道事件 GC 扫描间隔(与 `DOCUMENT_GC_INTERVAL` 同范式) |
| `CHANNEL_EVENT_COUNTER_FLUSH` | `30s` | **v0.67 登记,已接线**(E4 `afe9afb`):计数内存聚合的 flush 间隔。计数是**尽力而为的近似值**(进程崩丢 ≤30s),查询面以 `approx:true` 如实标注(见 §2.4(a)) |
**与契约相关但「配了不生效」的键**:
@@ -816,17 +818,17 @@
### v0.67(上一版)
**三批已在设计文档定稿的新增面一次性登记**:语音消息(播放端点 + 消息 DTO `voice` + 音频副本口径)、非消息事件查询面、账号在线状态(依据 `design/24 §10.11`、`design/26 §4.5` / `§2.6`)。登记时三批**都还没有实现**;其中**账号在线状态已落地**(见下)。
**三批已在设计文档定稿的新增面一次性登记**:语音消息(播放端点 + 消息 DTO `voice` + 音频副本口径)、非消息事件查询面、账号在线状态(依据 `design/24 §10.11`、`design/26 §4.5` / `§2.6`)。登记时三批**都还没有实现**;其中**账号在线状态**(`52dac45`)与**非消息事件查询面**(本切片 E6)**已落地**(见下)。
> ⚠️ **本版是「契约先行」登记**:登记的三批里,**账号在线状态已落地**(本次,见 §2.4(b) / §7.2),**语音消息与非消息事件查询面尚未实现**;后两批的形状**取自设计文档而非代码核对**。客户端**先按形状对齐、不要按「已可用」编程**;实现落地后本版会补记 commit 并把 §7.2 相应行改为上线。
> ⚠️ **本版是「契约先行」登记**:登记的三批里,**账号在线状态**(见 §2.4(b))与**非消息事件查询面**(见 §2.4(a))**已落地**;**语音消息尚未实现**,那一批的形状**取自设计文档而非代码核对**。客户端**先按形状对齐、不要按「已可用」编程**;实现落地后本版会补记 commit 并把 §7.2 相应行改为上线。
- **纯增量**:
① 新端点 `GET /api/v1/media/{name}`(用户侧,**owner/admin 必须 Bearer**):`200` 字节流 / `206 Partial Content` / `304 Not Modified`;`Content-Type: audio/*`;`Content-Disposition: inline; filename="<name>"`;`nosniff`;`Accept-Ranges: bytes`;`Cache-Control: private, max-age=86400, immutable`;支持 `Range` 与 `HEAD`;`404 media_not_found`;`401`/`403` 由中间件给出。⚠️ **`416` 是标准库直写的非 JSON 响应**(见 §0.3、§4.1、§4.5)。
② 消息 DTO 增**可选** `voice` 对象(`omitempty`;**非语音消息键缺席**):`{source,status,name?,mime?,size?,durationMs?,url?}`;`source` ∈ `asr`/`platform`,`status` ∈ `transcribed`/`empty`/`failed`,`name`/`mime`/`size`/`url` **四者同生共死**(有副本时才在);`url` 是服务端相对路径 `/api/v1/media/<name>`。**🎤 判据 = `voice` 键存在**。一次覆盖全部读取面:`GET /sessions/{id}/messages`、`PATCH /messages/{id}` 响应、`GET /admin/channels[/{platform}]/messages`、`message_persisted` / `channel_persisted` / `message_edited`、握手 `history` 回放(§3.2、§3.4.4、§4.4)。
③ 新错误码 **`media_not_found`(404)** → 加进 §4.1 的 404 行(与 `document_not_found` 并列;**刻意不复用**它,否则客户端分不清缺的是文档还是语音)。
④ 新错误形态 **`416`**(**非信封**,标准库直写)→ §0.3 / §4.1 / §4.5。
⑤ 新 admin 端点 **`GET /api/v1/admin/channels/events`**(非消息事件列表,倒序;`?platform=&tier=&category=&channelId=&since=&until=&limit=&offset=`;响应 `{data:{events:[…],total}}`;`since`/`until` 按 UTC ISO-8601 定宽字符串**字典序**比较、**跨度上限 90 天**;不含正文、不含媒体 URL、不含任何凭据 / `flag`)—— §2.4(a)。
⑥ 新 admin 端点 **`GET /api/v1/admin/channels/events/counters`**(逐类计数汇总 `{data:{counters:[{platform,accountId,tier,category,platformType,count,firstSeenAt,lastSeenAt}]}}`,并带 **`approx:true`** 如实标注「计数为聚合近似值」)—— §2.4(a);未接线存储 ⇒ **`500 channel_events_unavailable`**(新增错误码,**绝不回全 0 假汇总**)。
⑤ 新 admin 端点 **`GET /api/v1/admin/channels/events`**(非消息事件列表,倒序;`?platform=&tier=&category=&channelId=&since=&until=&limit=&offset=`;响应 `{data:{events:[…],total}}`;`since`/`until` 按 UTC ISO-8601 定宽字符串**字典序**比较、**跨度上限 90 天**;不含正文、不含媒体 URL、不含任何凭据 / `flag`)—— §2.4(a)。**已落地**(`owner|admin` 档;元素键名 camelCase,逐字见 §2.4(a);未给窗口 ⇒ 不注入窗口)。
⑥ 新 admin 端点 **`GET /api/v1/admin/channels/events/counters`**(逐类计数汇总 `{data:{approx:true,counters:[{platform,accountId,tier,category,platformType,count,firstSeenAt,lastSeenAt}]}}`)—— §2.4(a);未接线存储 ⇒ **`500 channel_events_unavailable`**(新增错误码,**绝不回全 0 假汇总**)。**已落地**(`approx` 与 `counters` 同级;`platform` 未知 ⇒ `400 bad_request`)。
⑦ `GET /api/v1/admin/channels` 的 `accounts[]` 增**可选** `accountOnline`(**账号在平台侧是否在线**,与 `connected`(适配器/WS 是否连着)**是两件事**;**键缺席 = 状态未知 ≠ 离线**)—— §2.4(b)。**已落地**(管理面按可选接口断言读 `AccountStatus()`;`known=false` 或适配器无该状态面 ⇒ 键缺席)。
⑧ 新 env 键 3 个:`CHANNEL_EVENT_RETENTION`(`720h`)/ `CHANNEL_EVENT_GC_INTERVAL`(`1h`)/ `CHANNEL_EVENT_COUNTER_FLUSH`(`30s`)—— 已在 §5 登记(本切片**不新增**语音相关 env 键;语音上限/保留期复用既有常量与 `DOCUMENT_*`)。
⑨ **WS 协议零变化**:`media` 是 REST 端点;无新帧、无新上行字段。
@@ -917,7 +919,7 @@
### 7.1 在飞切片
**v0.66 及更早登记的在飞项已全部冻结、客户端可直接依赖**(下表)。⚠️ **v0.67 新登记的三批中,账号在线状态(`accounts[].accountOnline`)已落地**(本次,见 §7.2);**语音消息 / 非消息事件查询面尚未实现**,**不在本表** —— 逐条见 §7.2。
**v0.66 及更早登记的在飞项已全部冻结、客户端可直接依赖**(下表)。⚠️ **v0.67 新登记的三批中,账号在线状态(`accounts[].accountOnline`)与非消息事件查询面都已落地**(见 §7.2);**语音消息尚未实现**,**不在本表** —— 逐条见 §7.2。
| 项 | 状态 | 冻结位置 |
|---|---|---|
@@ -935,7 +937,7 @@
| 项 | 现状 |
|---|---|
| **🔴 v0.67 登记的「语音消息面」**(`GET /api/v1/media/{name}` + 消息 DTO `voice` + 入站语音音频副本落盘) | **未实现**(**契约已先行登记**):端点 / 字段 / 副本口径见 §1.2 U19、§4.4、§4.5;权威设计 `design/24 §10`。⚠️ 同批的**行为变更**(QQ 语音 `content` 不再含 `[语音]`)与**口径变更**(`/admin/system/storage` 的 `documents` 段含语音副本)也**随该切片一起生效**,现在**尚未生效** |
| **🔴 v0.67 登记的「非消息事件查询面」**(`GET /admin/channels/events`、`GET /admin/channels/events/counters`) | **未实现**(**契约已先行登记**):形状与错误码见 §2.4(a);权威设计 `design/26 §4.5`。未接线存储 ⇒ `500 channel_events_unavailable`;⚠️ 两个端点的 **RBAC 档位设计未定**(见 §7.4-12) |
| **✅ v0.67 登记的「非消息事件查询面」**(`GET /admin/channels/events`、`GET /admin/channels/events/counters`) | **已实现**(v0.67 登记项本切片落地):形状与错误码见 §2.4(a)(已按代码核对:元素键名 camelCase、`approx` 与 `counters` 同级、`400 bad_request`、`owner\|admin` 档);权威设计 `design/26 §4.5`。未接线存储 ⇒ `500 channel_events_unavailable`。⚠️ 本行留在此表仅为与上一版对照,下版改文档时应移入 §7.1 在飞切片表并回填 §2.1/§2.2 的端点计数 |
| **✅ v0.67 登记的「账号在线状态」**(`GET /admin/channels` 的 `accounts[].accountOnline`) | **已实现**(v0.67 登记项本次落地;落地前基线 `f91a83f`,本切片 commit 由主会话补记):形状见 §2.4(b)。管理面按**可选接口断言**读 `AccountStatus()`(`plugins/channels/qq_obv11/probe.go`,`a79fe96`);适配器**没有**该状态面、或实现了但 `known=false` ⇒ **键缺席**(未知 ≠ 离线,绝不用 `false` 冒充)。当前只有 `qq-obv11` 会带该键。⚠️ 本行留在此表仅为与上一版对照,下版改文档时应移入 §7.1 在飞切片表 |
| **✅ P1-T:`directMsgPerMin` 的 gateway 写入路径**(`GET /admin/channels` 的 `accounts[].directMsgPerMin` + `PUT .../config` 的顶层 `directMsgPerMin`) | **已实现**(v0.69):缺口 = gateway 面没让该字段通过 ⇒ **面板保存一次渠道配置就把主人手配的「关闭流控(-1)」静默重置回 `0` = 保护性默认 6 条/分**。现 5 处透传(`AdminChannelAccount` DTO / `channelConfigRequest` / `config.ChannelAccount` 拷贝 / `adminChannelAccountOf` / 微信扫码重建账号)。形状见 §2.2 A5 与 §2.3 C28 注 |
| **`graph_update` 按路分桶** | **未实现**:`graph_update` 只带 `data.sessionId`(会话级),**无 `turnId`** |
@@ -970,10 +972,10 @@
6. **`account_not_registered`(409) 的适用面**:本文档只确证了 C19/C20(open/close)会带它;`POST /admin/channels/test`、`/{platform}/message/test` 等其它渠道端点是否也有等价的账号级错误码 —— **未逐一确证**。
7. **`turn_begin.data.leg` 在长会话中的取值范围**:代码里是会话内单调递增序号(非「1/2 路」固定枚举),但本文档**未确证**客户端应如何展示 >2 的取值 —— 建议只当诊断信息用。
8. **`GET /admin/channels` 的 `accounts[]` 除 `connected` 外的字段集**:本文档未逐字段展开(该结构在 v0.66 只是**新增**一个可选键;v0.67 另登记了一个可选 `accountOnline`,见 §2.4(b))。
9. **(v0.67 登记)`GET /admin/channels/events` 的 `events[]` 元素 JSON 键名**:设计只给到 `channel_events` 的**落库列**(snake_case 列名)与 `{events,total}` 外框,**未逐字给出**元素的 JSON 键名(camelCase 还是 snake_case 未定)⇒ 客户端**现在不要硬编码**;`counters[]` 的元素名设计已逐字给出且为 camelCase(大概率同风格,但**未确证**)。
10. **(v0.67 登记)`events.total` 的语义**:是**筛选条件下的总条数**(不受 `limit`/`offset` 影响)还是**本次返回条数** —— 设计未说明。
11. **(v0.67 登记)渠道事件查询的参数默认值与错误码名**:`limit`/`offset` 的默认值、以及 `platform` 未知 / 跨度超 90 天时的**具体 `400` `code` 名**(设计只写「→ `400`」)—— 均未给出。
12. **(v0.67 登记)两个渠道事件端点的 RBAC 档位**:设计原文「owner/admin;`viewer` 只读亦可,按既有 RBAC 惯例」**自相含混**(按既有分档,渠道消息日志面 B2/B3 是 owner/admin 档)⇒ 落地前**不要**假设 viewer 能读。
9. **(v0.67 登记 · 已落地确证)`GET /admin/channels/events` 的 `events[]` 元素 JSON 键名**:**已定为 camelCase**,逐字与 `internal/services/gateway/admin_channel_events.go` 的 `channelEventDTO` 一致:`id` / `platform` / `accountId` / `tier` / `category` / `platformType` / `channelId` / `channelType` / `groupId` / `actorId` / `actorRole` / `targetId` / `selfRelated` / `messageId` / `summary` / `createdAt` / `updatedAt`(时间为 **UTC RFC3339**;`counters[]` 同为 camelCase,风格一致)。客户端**可以**硬编码这些键名了。
10. **(v0.67 登记 · 已落地确证)`events.total` 的语义**:是**筛选条件下的总条数**(**不受** `limit`/`offset` 影响;与 `events` 同源条件),因此 `total > events.length` 是正常的分页状态。
11. **(v0.67 登记 · 已落地确证)渠道事件查询的参数默认值与错误码名**:`limit` 未给 / `≤0` ⇒ 存储层缺省 **`100`**;超硬上限 ⇒ **钳到 `500`**(不报错);`offset` 负值 ⇒ `0`;**非法整数 ⇒ `400 bad_request`**。`platform` 未知 / `tier` 非 `t1|t2|t3` / `since`-`until` 跨度超 90 天 / 时间点非法 ⇒ 一律 **`400` + `code:"bad_request"`**(未新造专用错误码)。`category` **不做白名单**(未知类别 = 精确匹配 ⇒ 空结果)。`approx` 的**嵌套层级已定为 `data.approx`**(与 `data.counters` 同级)。
12. **(v0.67 登记 · 已落地确证)两个渠道事件端点的 RBAC 档位**:**`owner|admin`**(`writeAuth`),与渠道消息日志面(B2/B3)同档;**viewer 为 `403`**。依据:事件行带群 id / 成员 id / 关联消息 id,属私有内容面,不是 viewer 的监控投影。
13. **(v0.67 登记 · 已确证)`accounts[].accountOnline` 的字段名与是否有 `known` 同级键**:字段名 `accountOnline` 为本契约所拟(设计只规定「独立状态面 / `AccountStatus()` / `known` 三态」,未给管理面字段名),**实现沿用了它、未改名**;**不发** `known` 同级键 —— 「未知」由**键缺席**承载。哪些平台会带该键**已确证**:只有适配器实现了 `AccountStatus()` 的平台(当前仅 `qq-obv11`)。
14. **(v0.67 登记)音频各容器在各客户端的可播放性**:`amr` / `flac` / `opus` 在部分浏览器内核上播不了(design/24 §10.6 明确「未逐一实测」)⇒ 客户端**以实测为准**,播不了就照实提示。
15. **(v0.67 登记)`GET /admin/system/storage` 的 `documents` 段是否拆出语音副本**:设计**本期不拆**(不新增 `kind` 列 / 不按 ext 聚合)⇒ 该段**只增口径、不变形状**,客户端**不要**指望能分开统计。
+5
View File
@@ -89,6 +89,11 @@ func (a *app) wireFrontend() error {
// 连接池,自己不开池、不读环境变量。库未接时为 nil(端点回 500
// usage_unavailable,不回假汇总)。
Usage: model.NewUsageReader(a.db),
// EventStore 是渠道事件留存的只读查询出口(design/26 §4.5;切片 E6):
// 复用组合根已按 CHANNEL_EVENT_* 建好的那份 store。未接线时为**真 nil**
// (`channelEventStore()` 的取舍),端点据此回 500 channel_events_unavailable,
// 绝不回空 200 假装「没有事件」。
EventStore: a.channelEventStore(),
})
if err != nil {
return fmt.Errorf("gateway: %w", err)
+2
View File
@@ -14,6 +14,7 @@ import { SessionsModule } from "./modules/Sessions";
import { MemoriesModule } from "./modules/Memories";
import { PluginsModule } from "./modules/Plugins";
import { ChannelsModule } from "./modules/Channels";
import { ChannelEventsModule } from "./modules/ChannelEvents";
import { IoTModule } from "./modules/IoT";
import { RagModule } from "./modules/Rag";
import { LlmModule } from "./modules/Llm";
@@ -34,6 +35,7 @@ function renderSection(section: AdminSectionId): React.ReactNode {
case "memories": return <MemoriesModule />;
case "plugins": return <PluginsModule />;
case "channels": return <ChannelsModule />;
case "channel-events": return <ChannelEventsModule />;
case "iot": return <IoTModule />;
case "rag": return <RagModule />;
case "llm": return <LlmModule />;
@@ -0,0 +1,172 @@
// 「渠道事件」页的**真实渲染**测试(design/26 §4.5 的面板段;切片 E6)。
//
// 为什么需要它:本页要回答的是那次无法定案的事故(「上游推过没有?」),因此它的
// 三个态都有产品后果 ——
// ① 有数据:计数与事件行要能一眼读出来(含「计数为近似值」这句真话);
// ② 空:**微信渠道**的空是平台事实,必须换一句说明,而不是「暂无数据」了事;
// ③ 错误:读不到就显示错误,**绝不显示 0**(画成 0 就是「界面与事实无关」,
// 正是本项目面板明令禁止的那一类)。
// 文案对不对只有画出来才算数,因此这里渲染真组件。
//
// 跑法(与 channels/accountRow.render.test.ts 同):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/ChannelEvents.render.test.ts \
// --bundle --platform=node --format=cjs \
// --outfile=/tmp/cyrene-channel-events.render.test.cjs \
// && node /tmp/cyrene-channel-events.render.test.cjs
//
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import type { AdminChannelEvent, AdminChannelEventCounter } from "../../lib/types";
import {
ChannelEventCountersBody,
ChannelEventListBody,
NO_EVENT_PLATFORM_HINT,
} from "./channelEventViews";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function has(haystack: string, needle: string): boolean {
return haystack.includes(needle);
}
const EVENTS: AdminChannelEvent[] = [
{
id: 12,
platform: "qq-obv11",
accountId: "acct-1",
tier: "t2",
category: "group_increase",
platformType: "group_increase",
channelId: "1070898847",
channelType: "group",
groupId: "1070898847",
actorId: "10086",
actorRole: "member",
targetId: "",
selfRelated: false,
messageId: "",
summary: "群成员加入",
createdAt: "2026-10-05T03:04:05.000Z",
updatedAt: "2026-10-05T03:04:05.000Z",
},
{
id: 11,
platform: "qq-obv11",
accountId: "acct-1",
tier: "t3",
category: "poke",
platformType: "notify.poke",
channelId: "1070898847",
channelType: "group",
groupId: "1070898847",
actorId: "10086",
actorRole: "member",
targetId: "9616b27a",
selfRelated: true,
messageId: "",
summary: "有人戳了你一下",
createdAt: "2026-10-05T03:03:05.000Z",
updatedAt: "2026-10-05T03:03:05.000Z",
},
];
const COUNTERS: AdminChannelEventCounter[] = [
{ platform: "qq-obv11", accountId: "acct-1", tier: "t1", category: "heartbeat", platformType: "heartbeat", count: 1440, firstSeenAt: "2026-10-01T00:00:00.000Z", lastSeenAt: "2026-10-05T03:04:00.000Z" },
{ platform: "qq-obv11", accountId: "acct-1", tier: "t1", category: "lifecycle", platformType: "lifecycle", count: 2, firstSeenAt: "2026-10-01T00:00:00.000Z", lastSeenAt: "2026-10-05T02:00:00.000Z" },
{ platform: "qq-obv11", accountId: "acct-1", tier: "t2", category: "group_increase", platformType: "group_increase", count: 3, firstSeenAt: "2026-10-02T00:00:00.000Z", lastSeenAt: "2026-10-05T03:04:00.000Z" },
{ platform: "qq-obv11", accountId: "acct-1", tier: "t1", category: "unknown", platformType: "notify.mystery", count: 7, firstSeenAt: "2026-10-02T00:00:00.000Z", lastSeenAt: "2026-10-05T03:04:00.000Z" },
];
function countersHtml(props: Partial<Parameters<typeof ChannelEventCountersBody>[0]>): string {
return renderToStaticMarkup(
createElement(ChannelEventCountersBody, {
counters: null,
approx: false,
loading: false,
error: null,
platform: "",
...props,
})
);
}
function listHtml(props: Partial<Parameters<typeof ChannelEventListBody>[0]>): string {
return renderToStaticMarkup(
createElement(ChannelEventListBody, {
events: null,
total: 0,
loading: false,
error: null,
platform: "",
...props,
})
);
}
// ── ① 有数据:四个计数口径 + 合计,事件行的档位 / 摘要 / 当事人 / 时间 ────────────
const dataCounters = countersHtml({ counters: COUNTERS, approx: true });
ok(has(dataCounters, "qq-obv11"), "计数卡必须点名平台");
ok(has(dataCounters, "心跳"), "计数卡必须有「心跳」口径(design/26 §4.5)");
ok(has(dataCounters, "连接"), "计数卡必须有「连接」口径");
ok(has(dataCounters, "请求"), "计数卡必须有「请求」口径");
ok(has(dataCounters, "未知"), "计数卡必须有「未知」口径");
ok(has(dataCounters, "1440"), "心跳计数要如实显示");
ok(has(dataCounters, "合计 1452"), "合计 = 该平台全部落盘计数之和(1440+2+3+7)");
ok(has(dataCounters, "计数为近似值"), "approx=true 时必须如实标注近似语义");
ok(has(dataCounters, "已落盘"), "必须说清读的是已落盘计数表");
const dataList = listHtml({ events: EVENTS, total: 2 });
ok(has(dataList, "群成员加入"), "事件行必须显示脱敏摘要");
ok(has(dataList, "有人戳了你一下"), "第二行摘要也要渲染");
ok(has(dataList, "留存+上行"), "t2 档要有中文档位标签");
ok(has(dataList, "涉及她自己"), "selfRelated 必须在行上标出来");
ok(has(dataList, "10086 → 9616b27a"), "T3 要显示「谁对谁」");
ok(has(dataList, "1070898847"), "群 id 要显示(排障按群定位)");
ok(has(dataList, "共 "), "要给 total 与「本页 N 条」");
ok(has(dataList, "2"), "total 数值要渲染");
// 时间按本地时区渲染,只断言它出现在行上(不带时区假设)。
ok(/10-05 0[0-9]:0[0-9]:0[0-9]|10-0[4-6] /.test(dataList) || has(dataList, "2026"), "事件行必须显示发生时刻");
// ── ② 空态:普通平台 vs 微信渠道(后者是平台事实,必须换文案说明)──────────────
const emptyList = listHtml({ events: [], total: 0 });
ok(has(emptyList, "没有事件落痕"), "空列表要有明确空态");
ok(has(emptyList, "只进计数"), "空态要解释 T1 只进计数(否则会被误读成「没采到」)");
ok(!has(emptyList, "共 "), "空态不该渲染「共 N 条」这类数字行");
const emptyCounters = countersHtml({ counters: [], approx: true });
ok(has(emptyCounters, "暂无计数"), "空计数要有明确空态");
ok(!has(emptyCounters, "合计 0"), "空计数绝不渲染「合计 0」——0 会被读成「上游一帧都没推」");
const weixinCounters = countersHtml({ counters: [], approx: true, platform: "weixin-ilink" });
const weixinList = listHtml({ events: [], total: 0, platform: "weixin-ilink" });
ok(has(weixinCounters, "不提供非消息事件"), "微信渠道的空必须说明「该渠道不提供非消息事件」(§3.4/§8.5)");
ok(has(weixinList, "不提供非消息事件"), "微信列表空态同样要说明");
ok(has(weixinList, NO_EVENT_PLATFORM_HINT), "微信空态用的是同一条平台事实文案");
// ── ③ 错误态:显示错误,绝不显示 0 ───────────────────────────────────────────
const errCounters = countersHtml({ counters: null, error: "读取渠道事件计数失败", approx: true });
ok(has(errCounters, "计数读取失败"), "计数失败必须显示错误");
ok(has(errCounters, "读取渠道事件计数失败"), "要带上后端的错误信息");
ok(!has(errCounters, "合计"), "失败时绝不渲染任何计数(包括 0)");
ok(!has(errCounters, "心跳"), "失败时绝不渲染计数口径");
const errList = listHtml({ events: null, total: 0, error: "读取渠道事件列表失败" });
ok(has(errList, "事件列表读取失败"), "列表失败必须显示错误");
ok(!has(errList, "共 "), "失败时绝不渲染「共 0 条」");
ok(!has(errList, "没有事件落痕"), "失败不得退化成空态(空 ≠ 读不到)");
// 有旧数据时的失败:保留旧行,但必须挂一条醒目的错误说明。
const staleList = listHtml({ events: EVENTS, total: 2, error: "读取渠道事件列表失败" });
ok(has(staleList, "刷新失败"), "带旧数据的失败要显式说明这是上次的结果");
ok(has(staleList, "群成员加入"), "带旧数据的失败仍要保留上一次的结果(不闪空)");
console.log(`channel events panel render checks passed: ${checks}`);
@@ -0,0 +1,222 @@
import { useMemo, useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminChannelEventCounters, adminChannelEvents } from "../../lib/api";
import { useAutoReload } from "../hooks";
import { Notice, PageHeader } from "../components";
import { Button, Surface } from "../../components/primitives";
import { ChannelEventCountersBody, ChannelEventListBody, categoryLabel } from "./channelEventViews";
/**
* 「渠道事件」页(design/26 §4.5 的面板段;切片 E6)。
*
* 它回答的是那次**无法定案**的事故(design/26 §0.2):「上游到底推过没有?是不是
* 到了适配器却被过滤掉了?」—— E1–E4 把每一帧的脱敏结构痕迹落进了事件表与计数表,
* 本页把那份证据画出来。展示层在 `channelEventViews.tsx`(三态可被渲染测试直接钉住)。
*
* 本文件只负责:过滤条 + 取数 + 组装。三条纪律见展示层与下方说明块。
*/
/** 平台白名单:与后端 `parseChannelEventPlatform` 逐字一致(写错就是 400)。 */
export const CHANNEL_EVENT_PLATFORMS: ReadonlyArray<string> = [
"qq-obv11",
"kook",
"telegram",
"weixin-ilink",
"webhook",
];
/** 一页条数。上限 500 由后端钳制,这里取缺省 100 免得一屏塞爆。 */
const PAGE_SIZE = 100;
/** 静默刷新间隔(列表与计数一起)。 */
const RELOAD_MS = 30_000;
/** 毫秒 / 小时 —— 时间窗换算的唯一出处(不散落字面量)。 */
const MS_PER_HOUR = 3600_000;
/** 时间窗档位(都远在 90 天上限内;面板**显式**传窗口,不指望后端给缺省)。 */
export const WINDOWS = [
{ value: "1d", label: "近 1 天", hours: 24 },
{ value: "7d", label: "近 7 天", hours: 168 },
{ value: "30d", label: "近 30 天", hours: 720 },
{ value: "90d", label: "近 90 天", hours: 2160 },
] as const;
type WindowKey = (typeof WINDOWS)[number]["value"];
/** 时间窗起点(UTC RFC3339)。每次拉取时现算,因此「近 7 天」始终跟着现在走。 */
export function windowSince(key: WindowKey): string {
const meta = WINDOWS.find((w) => w.value === key) ?? WINDOWS[1];
return new Date(Date.now() - meta.hours * MS_PER_HOUR).toISOString();
}
const inputCls =
"h-9 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 text-sm text-[var(--mu-text-hi)] outline-none focus:ring-1 focus:ring-[var(--mu-accent)]";
/** 过滤器条:平台 / 档位 / 类别 / 时间窗 / 会话-群 id。 */
function FilterBar(props: {
platform: string;
tier: string;
category: string;
channelId: string;
windowKey: WindowKey;
categories: readonly string[];
onPlatform: (v: string) => void;
onTier: (v: string) => void;
onCategory: (v: string) => void;
onChannelId: (v: string) => void;
onWindow: (v: WindowKey) => void;
onReload: () => void;
busy: boolean;
}): React.ReactNode {
return (
<div className="flex flex-wrap items-center gap-2">
<select
value={props.platform}
onChange={(e) => props.onPlatform(e.target.value)}
className={inputCls}
title="平台(后端白名单;未知平台会 400,绝不静默放宽成全部)"
>
<option value="">全部平台</option>
{CHANNEL_EVENT_PLATFORMS.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
<select value={props.tier} onChange={(e) => props.onTier(e.target.value)} className={inputCls} title="留存档">
<option value="">全部档位</option>
<option value="t1">t1 · 遥测</option>
<option value="t2">t2 · 留存+上行</option>
<option value="t3">t3 · 交互</option>
</select>
<select
value={props.category}
onChange={(e) => props.onCategory(e.target.value)}
className={inputCls}
title="归一化类别(选项来自已落盘计数,因此永远与数据同步)"
>
<option value="">全部类别</option>
{props.categories.map((c) => (
<option key={c} value={c}>
{categoryLabel(c)} · {c}
</option>
))}
</select>
<select
value={props.windowKey}
onChange={(e) => props.onWindow(e.target.value as WindowKey)}
className={inputCls}
title="时间窗(面板显式传 since,后端不注入缺省窗口;上限 90 天)"
>
{WINDOWS.map((w) => (
<option key={w.value} value={w.value}>
{w.label}
</option>
))}
</select>
<input
value={props.channelId}
onChange={(e) => props.onChannelId(e.target.value)}
placeholder="会话 / 群 id"
className={`${inputCls} w-40`}
/>
<Button variant="soft" size="sm" disabled={props.busy} onClick={props.onReload}>
刷新
</Button>
</div>
);
}
export function ChannelEventsModule(): React.ReactNode {
const [platform, setPlatform] = useState("");
const [tier, setTier] = useState("");
const [category, setCategory] = useState("");
const [channelId, setChannelId] = useState("");
const [windowKey, setWindowKey] = useState<WindowKey>("7d");
const list = useAsyncData(
() =>
adminChannelEvents({
platform,
tier,
category,
channelId,
since: windowSince(windowKey),
limit: PAGE_SIZE,
}),
[platform, tier, category, channelId, windowKey]
);
const counters = useAsyncData(() => adminChannelEventCounters(platform), [platform]);
useAutoReload(list.reload, RELOAD_MS);
useAutoReload(counters.reload, RELOAD_MS);
// 类别选项**来自已落盘的计数**:数据里有什么就能筛什么,不另维护一份会漂移的清单。
// 当前选中的类别即便不在选项里也照样显示(否则会出现「选了却看不见」的空白下拉)。
const categories = useMemo(() => {
const seen = new Set<string>();
for (const c of counters.data?.counters ?? []) if (c.category) seen.add(c.category);
if (category) seen.add(category);
return Array.from(seen).sort();
}, [counters.data, category]);
const reloadAll = (): void => {
list.reload();
counters.reload();
};
return (
<div className="space-y-5">
<PageHeader
title="渠道事件"
subtitle="GET /api/v1/admin/channels/events · 非消息事件留存(design/26 §4.5):到达适配器的每一帧都留痕 —— 推过没有、推了什么,这里查得到"
actions={
<Button variant="soft" disabled={list.loading} onClick={reloadAll}>
刷新
</Button>
}
/>
<Surface className="space-y-3">
<FilterBar
platform={platform}
tier={tier}
category={category}
channelId={channelId}
windowKey={windowKey}
categories={categories}
onPlatform={setPlatform}
onTier={setTier}
onCategory={setCategory}
onChannelId={setChannelId}
onWindow={setWindowKey}
onReload={reloadAll}
busy={list.loading}
/>
<ChannelEventCountersBody
counters={counters.data?.counters ?? null}
approx={counters.data?.approx === true}
loading={counters.loading}
error={counters.error}
platform={platform}
/>
</Surface>
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">事件列表</h3>
<ChannelEventListBody
events={list.data?.events ?? null}
total={list.data?.total ?? 0}
loading={list.loading}
error={list.error}
platform={platform}
/>
</Surface>
<Notice>
只读查询:事件行只有结构化事实(平台 / 类型 / 当事人 id / 固定摘要),
没有正文、没有媒体 URL、没有凭据与 OneBot <span className="font-mono">flag</span>——
那些东西根本没进事件表(design/26 §4.2 红线)。T1 遥测(心跳等)只进计数、
不逐条落行;未接线或读库失败时两个端点诚实回错,本页照实显示,绝不画成 0。
</Notice>
</div>
);
}
@@ -0,0 +1,291 @@
import { EmptyState, Surface, Tag, type TagTone } from "../../components/primitives";
import { Icon } from "../../components/Icon";
import type { AdminChannelEvent, AdminChannelEventCounter } from "../../lib/types";
import { Notice } from "../components";
/**
* 「渠道事件」页的**展示层**(design/26 §4.5 的面板段;切片 E6)。
*
* 与页壳(`ChannelEvents.tsx`,负责取数与过滤)分开,是因为这些组件是**纯展示**:
* 三态完全由 props 决定,因此渲染测试可以逐态直接钉住文案 —— 本页最要紧的三条
* 面板纪律(失败显示错误不显示 0 / 计数如实标近似 / 微信渠道的空是平台事实)都在
* 这里,它们必须能被断言,而不是只能靠肉眼。
*/
/** 微信 iLink 渠道的平台事实:它没有非消息事件信封(design/26 §3.4)。
* 空结果对它**不是故障**,因此换一句说明,而不是「暂无数据」了事。 */
export const WEIXIN_PLATFORM = "weixin-ilink";
/** 「该渠道不提供非消息事件」的说明文案(§3.4 / §8.5 的诚实标注)。 */
export const NO_EVENT_PLATFORM_HINT =
"微信 iLink 渠道不提供非消息事件(没有 notice / request / meta_event 信封)——这里的空是平台事实,不是漏采或故障。";
/** 档位标签(design/26 §2.1 的直觉话术)。未知档位照实显示原值。 */
const TIER_META: Record<string, { label: string; tone: TagTone }> = {
t1: { label: "遥测留存", tone: "neutral" },
t2: { label: "留存+上行", tone: "accent" },
t3: { label: "交互", tone: "ok" },
};
/** 归一化类别 → 中文(只覆盖已登记的类别;未登记的原样显示,绝不丢行)。 */
const CATEGORY_LABELS: Record<string, string> = {
heartbeat: "心跳",
lifecycle: "生命周期",
link: "链路",
message_sent: "自我回声",
join: "成员加入",
friend_add: "加为好友",
leave: "成员离开",
recall: "消息撤回",
admin: "管理员变更",
ban: "禁言",
ban_all: "全员禁言",
upload: "文件上传",
essence: "精华消息",
card: "名片变更",
offline_file: "离线文件",
client_status: "他人在线",
honor: "群荣誉",
title: "头衔",
poke: "戳一戳",
lucky_king: "运气王",
friend_request: "加好友请求",
group_request: "加群请求",
reaction: "表情回应",
button: "按钮点击",
item: "道具",
vote: "投票",
unknown: "未知类型",
decode_error: "解析失败",
};
/** 顶部计数卡的四个口径(design/26 §4.5 第 478 行逐字:心跳 / 连接 / 请求 / 未知)。 */
const COUNTER_GROUPS: ReadonlyArray<{
key: string;
label: string;
categories: readonly string[];
tone: TagTone;
hint: string;
}> = [
{ key: "heartbeat", label: "心跳", categories: ["heartbeat"], tone: "neutral", hint: "上游 meta_event 心跳(对端配了推送才有)" },
{ key: "link", label: "连接", categories: ["link", "lifecycle"], tone: "ok", hint: "连接 / 生命周期(链路当时活没活)" },
{ key: "request", label: "请求", categories: ["friend_request", "group_request"], tone: "warn", hint: "加好友 / 加群请求(默认不自动同意)" },
{ key: "unknown", label: "未知", categories: ["unknown", "decode_error"], tone: "err", hint: "类型未枚举 / 载荷解析失败——「看不懂」也要留痕" },
];
function pad2(n: number): string {
return String(n).padStart(2, "0");
}
/** 本地 `MM-DD HH:mm:ss`;事件是排障证据,秒要留着。 */
export function formatEventTime(iso: string): string {
const ms = Date.parse(iso);
if (!Number.isFinite(ms)) return iso || "—";
const d = new Date(ms);
return `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
}
/** 类别标签:未登记的类别原样显示(绝不因为不认识就吞掉一行)。 */
export function categoryLabel(category: string): string {
return CATEGORY_LABELS[category] ?? category ?? "";
}
/** 按平台聚合计数:四个口径 + 合计。未登记的类别只进「合计」,绝不并进某一档。 */
export interface CounterGroupRow {
platform: string;
total: number;
groups: { key: string; count: number }[];
}
function groupCounters(counters: readonly AdminChannelEventCounter[]): CounterGroupRow[] {
const byPlatform = new Map<string, CounterGroupRow>();
for (const c of counters) {
let row = byPlatform.get(c.platform);
if (!row) {
row = {
platform: c.platform,
total: 0,
groups: COUNTER_GROUPS.map((g) => ({ key: g.key, count: 0 })),
};
byPlatform.set(c.platform, row);
}
row.total += c.count;
const index = COUNTER_GROUPS.findIndex((g) => g.categories.includes(c.category));
if (index >= 0) row.groups[index].count += c.count;
}
return Array.from(byPlatform.values()).sort((a, b) => (a.platform < b.platform ? -1 : 1));
}
export interface ChannelEventCountersBodyProps {
counters: readonly AdminChannelEventCounter[] | null;
approx: boolean;
loading: boolean;
error: string | null;
platform: string;
}
/** 顶部计数卡(纯展示组件:三态由 props 决定,便于渲染测试直接钉住文案)。 */
export function ChannelEventCountersBody({
counters,
approx,
loading,
error,
platform,
}: ChannelEventCountersBodyProps): React.ReactNode {
// 失败就显示失败:绝不把读不到的计数画成 0(那正是「界面与事实无关」)。
if (error) return <Notice tone="err">计数读取失败:{error}</Notice>;
if (loading && !counters) {
return <div className="px-4 py-6 text-center text-xs text-[var(--mu-text-lo)]">加载中…</div>;
}
const rows = groupCounters(counters ?? []);
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--mu-text-lo)]">
<Tag
tone={approx ? "warn" : "neutral"}
title="计数在内存聚合、每 30s 落盘一次(CHANNEL_EVENT_COUNTER_FLUSH),读到的表不含待落盘部分 ⇒ 只少不多"
>
{approx ? "计数为近似值(下界)" : "计数未标注近似语义"}
</Tag>
<span>读的是已落盘计数表;「上游到底推没推」看这里 —— 与事件行的「推过什么」互为补充。</span>
</div>
{rows.length === 0 ? (
<EmptyState
icon={<Icon name="storage" size={28} />}
title="暂无计数"
hint={
platform === WEIXIN_PLATFORM
? NO_EVENT_PLATFORM_HINT
: "该平台在这段时间内没有落盘计数。若入站一直在跑,先确认留存面是否已接线(未接线时计数只在内存里)。"
}
/>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{rows.map((row) => (
<Surface key={row.platform} gap="sm" className="space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="truncate font-mono text-sm">{row.platform}</span>
<Tag tone="accent">合计 {row.total}</Tag>
</div>
<div className="grid grid-cols-2 gap-2">
{row.groups.map((g, i) => (
<div key={g.key} className="rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-2.5 py-2">
<div className="flex items-center justify-between gap-1">
<span className="text-xs text-[var(--mu-text-lo)]" title={COUNTER_GROUPS[i]?.hint}>
{COUNTER_GROUPS[i]?.label ?? g.key}
</span>
<Tag tone={COUNTER_GROUPS[i]?.tone ?? "neutral"}>{g.count}</Tag>
</div>
</div>
))}
</div>
</Surface>
))}
</div>
)}
</div>
);
}
export interface ChannelEventListBodyProps {
events: readonly AdminChannelEvent[] | null;
total: number;
loading: boolean;
error: string | null;
platform: string;
}
/** 事件列表(倒序)。同样三态分明:失败显示错误,空态带平台说明。 */
export function ChannelEventListBody({
events,
total,
loading,
error,
platform,
}: ChannelEventListBodyProps): React.ReactNode {
if (error && !events) return <Notice tone="err">事件列表读取失败:{error}</Notice>;
if (loading && !events) {
return <div className="px-4 py-10 text-center text-xs text-[var(--mu-text-lo)]">加载中…</div>;
}
const rows = events ?? [];
if (rows.length === 0) {
return (
<EmptyState
icon={<Icon name="storage" size={32} />}
title="没有事件落痕"
hint={
platform === WEIXIN_PLATFORM
? NO_EVENT_PLATFORM_HINT
: "该筛选下没有 T2/T3/未知/链路事件。T1 遥测(心跳等他人在线状态)只进计数、不逐条落行 —— 去上面的计数卡看。"
}
/>
);
}
return (
<div className="space-y-3">
{error ? <Notice tone="err">列表刷新失败,下面显示的是上次成功的结果:{error}</Notice> : null}
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--mu-text-lo)]">
<span>
共 <span className="font-mono text-[var(--mu-text-hi)]">{total}</span> 条(本页 {rows.length} 条)
</span>
<span>· 倒序 · 逐条留痕,可查不可改</span>
</div>
<div className="space-y-2">
{rows.map((ev) => (
<ChannelEventRow key={`${ev.platform}-${ev.id}`} event={ev} />
))}
</div>
</div>
);
}
function tierMeta(tier: string): { label: string; tone: TagTone } {
return TIER_META[tier] ?? { label: tier || "未标档", tone: "neutral" };
}
function ChannelEventRow({ event }: { event: AdminChannelEvent }): React.ReactNode {
const tier = tierMeta(event.tier);
const who = event.targetId ? `${event.actorId || "?"} → ${event.targetId}` : event.actorId || "—";
return (
<div className="flex flex-col gap-1.5 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2.5">
<div className="flex flex-wrap items-center gap-2">
<Tag tone={tier.tone} title={`留存档 ${event.tier}`}>
{tier.label}
</Tag>
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{event.summary || categoryLabel(event.category) || "(无摘要)"}
</span>
{event.selfRelated ? <Tag tone="warn">涉及她自己</Tag> : null}
<span className="shrink-0 font-mono text-xs text-[var(--mu-text-lo)]">{formatEventTime(event.createdAt)}</span>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-[var(--mu-text-lo)]">
<span className="font-mono">
{event.platform}
{event.accountId ? `:${event.accountId}` : ""}
</span>
<span title="归一化类别 / 平台原生类型码(排障用)">
<span className="font-mono">{categoryLabel(event.category)}</span>
<span className="font-mono"> ({event.platformType || "—"})</span>
</span>
<span>
行为者 <span className="font-mono">{who}</span> · <span className="font-mono">{event.actorRole || "unknown"}</span>
</span>
{event.groupId ? (
<span>
群 <span className="font-mono">{event.groupId}</span>
</span>
) : null}
{event.channelId && event.channelId !== event.groupId ? (
<span>
会话 <span className="font-mono">{event.channelId}</span>
</span>
) : null}
{event.messageId ? (
<span>
关联消息 <span className="font-mono">{event.messageId}</span>
</span>
) : null}
</div>
</div>
);
}
+2
View File
@@ -18,6 +18,7 @@ export type AdminSectionId =
| "memories"
| "plugins"
| "channels"
| "channel-events"
| "iot"
| "rag"
| "llm"
@@ -53,6 +54,7 @@ const VALID_SECTIONS: ReadonlySet<string> = new Set([
"memories",
"plugins",
"channels",
"channel-events",
"iot",
"rag",
"llm",
+1
View File
@@ -21,6 +21,7 @@ export const ADMIN_SECTIONS: ReadonlyArray<AdminSectionMeta> = [
{ id: "rag", label: "知识库 RAG", icon: "docs", group: "内容" },
{ id: "plugins", label: "插件与能力", icon: "plugin", group: "能力与连接" },
{ id: "channels", label: "渠道管理", icon: "channel", group: "能力与连接" },
{ id: "channel-events", label: "渠道事件", icon: "storage", group: "能力与连接" },
{ id: "iot", label: "IoT 设备", icon: "device", group: "能力与连接" },
{ id: "llm", label: "LLM 模型", icon: "robot", group: "能力与连接" },
{ id: "automations", label: "自动化与提醒", icon: "clock", group: "能力与连接" },
+3
View File
@@ -111,6 +111,9 @@ export {
} from "./api/adminChannels";
export type { ChannelActionResult, ChannelConfigSaveResult } from "./api/adminChannels";
export { adminChannelEventCounters, adminChannelEvents } from "./api/adminChannelEvents";
export type { ChannelEventQuery } from "./api/adminChannelEvents";
export {
adminBackthink,
adminConfigFacts,
@@ -0,0 +1,45 @@
import type { AdminChannelEventCounters, AdminChannelEventList } from "../types";
import { request } from "./core";
/** `GET /api/v1/admin/channels/events` 的查询条件(全部可选;空串 = 不过滤)。
*
* 与后端 `channelEventsListFilter` 逐项对应:后端**不注入**缺省时间窗
* (没给 `since`/`until` 就是不加时间条件),因此这里也绝不替调用方补一个 ——
* 面板自己显式传它想看的窗口,界面上才不会出现「我以为是全部,其实是最近一天」。 */
export interface ChannelEventQuery {
platform?: string;
tier?: string;
category?: string;
channelId?: string;
/** RFC3339(`toISOString()`);跨度上限 90 天,超了后端回 400。 */
since?: string;
until?: string;
limit?: number;
offset?: number;
}
/** 渠道事件列表(倒序)。只读;不含正文 / URL / 凭据。 */
export function adminChannelEvents(query: ChannelEventQuery = {}): Promise<AdminChannelEventList> {
const params = new URLSearchParams();
const add = (key: string, value: string | undefined): void => {
const trimmed = value?.trim();
if (trimmed) params.set(key, trimmed);
};
add("platform", query.platform);
add("tier", query.tier);
add("category", query.category);
add("channelId", query.channelId);
add("since", query.since);
add("until", query.until);
if (query.limit && query.limit > 0) params.set("limit", String(query.limit));
if (query.offset && query.offset > 0) params.set("offset", String(query.offset));
const qs = params.toString();
return request<AdminChannelEventList>(`/admin/channels/events${qs ? `?${qs}` : ""}`);
}
/** 逐类计数汇总(近似值,响应带 `approx`)。`platform` 空 = 全部平台。 */
export function adminChannelEventCounters(platform?: string): Promise<AdminChannelEventCounters> {
const trimmed = platform?.trim();
const qs = trimmed ? `?platform=${encodeURIComponent(trimmed)}` : "";
return request<AdminChannelEventCounters>(`/admin/channels/events/counters${qs}`);
}
+1
View File
@@ -13,3 +13,4 @@ export * from "./types/adminWrite";
export * from "./types/kook";
export * from "./types/notification";
export * from "./types/configFacts";
export * from "./types/channelEvents";
@@ -0,0 +1,66 @@
// 管理面「渠道事件」查询的线上类型(design/26 §4.5;切片 E6,契约 v0.67 登记)。
// 唯一真源:internal/services/gateway/admin_channel_events.go。
//
// 事件行里**没有**平台原文、媒体 URL、凭据或 OneBot `flag`(design/26 §4.2 是红线,
// 事件表本身就没有这些列),因此这里也刻意不存在承载它们的字段:面板查不出、
// 也画不出那些东西 —— 这不是「没显示」,是「根本取不到」。
/** 一条 `channel_events` 行(T2/T3/`unknown`/`link` 逐条留存)。 */
export interface AdminChannelEvent {
id: number;
/** 平台(`qq-obv11` / `kook` / `telegram` / `weixin-ilink` / `webhook`)。 */
platform: string;
/** 多账号 id;空 = 单账号 / 未知。 */
accountId: string;
/** 留存档:`t1`/`t2`/`t3`。刻意不收窄成联合类型:将来新增档位时旧前端照旧渲染
* (绝不因为「我不认识这一档」而丢掉一行)。 */
tier: string;
/** 归一化类别(`join`/`recall`/`poke`/`heartbeat`/`unknown`…)。 */
category: string;
/** 平台原生类型码(`notice_type`/`extra.type`/Update 键名),排障用。 */
platformType: string;
channelId: string;
channelType: string;
groupId: string;
/** 行为者**结构化 id**(不是昵称)。 */
actorId: string;
/** `owner`/`member`/`system`/`unknown`(结构化解析所得)。 */
actorRole: string;
/** 被作用者 id(戳一戳目标 / 被禁言者…);无则空串。 */
targetId: string;
/** 是否命中「她自己」(design/26 §2.2 的升档依据)。 */
selfRelated: boolean;
/** 关联消息 id(撤回 / 回应 / 按钮 / 置顶)。 */
messageId: string;
/** 脱敏结构级摘要(固定文案,不含平台原文)。 */
summary: string;
/** UTC RFC3339。 */
createdAt: string;
updatedAt: string;
}
/** `GET /admin/channels/events` 的 data 本体。`events` 恒为数组(无数据时 `[]`)。 */
export interface AdminChannelEventList {
events: AdminChannelEvent[];
total: number;
}
/** 计数表的一行:某个平台某类事件的累计帧数。 */
export interface AdminChannelEventCounter {
platform: string;
accountId: string;
tier: string;
category: string;
platformType: string;
count: number;
firstSeenAt: string;
lastSeenAt: string;
}
/** `GET /admin/channels/events/counters` 的 data 本体。
* `approx` = 计数是**近似值**(内存聚合 + 每 30s 落盘,读到的表不含待落盘部分,
* 因此只少不多);面板必须如实标注,绝不画成精确账。 */
export interface AdminChannelEventCounters {
approx: boolean;
counters: AdminChannelEventCounter[];
}
@@ -0,0 +1,369 @@
package gateway
// 渠道事件**只读查询面**(design/26 §4.5 / §6.5;切片 E6)。
//
// 它回答的正是 design/26 §0.2 那次无法定案的事故:「上游到底推过没有?是不是
// 到了适配器却被我们过滤掉了?」—— E1/E2/E3+E4 把每一帧的脱敏结构痕迹落进了
// `channel_events`(T2/T3/unknown/link 逐条)与 `channel_event_counters`(所有帧
// 的计数),本文件把那份证据变成**查得到**的内容,供管理面板「渠道事件」页使用。
//
// 四条口径(每一条都有测试钉住,绝不靠注释自觉):
//
// 1. **只读**:本文件只有 List / Count / Counters 三个查询调用,没有写入路径,
// 也不碰任何凭据 —— 事件行里本就没有正文 / URL / `flag`(§4.2)。
// 2. **不静默裁剪窗口**:`since`/`until` 跨度超过 90 天回 400 并点名上限
// (与 `adminLlmUsage` 同口径);**没给窗口就不注入窗口** —— 替客户端造一个
// 下界,等于把「我没查那么久」伪装成「就那么少」。
// 3. **计数是近似值**:`Counters()` 读的是**已落盘**的计数表,不含内存里待 flush
// 的那部分(§4.3),因此响应恒带 `approx:true`,绝不谎称精确账。
// 4. **平台差异如实**:`weixin-ilink` 平台事实上没有非消息事件(§3.4),对它查询
// 就是空 —— 绝不拿别的渠道的事件顶替、绝不伪造(§8.5)。
//
// 未接线(组合根没注入留存面)时两个端点诚实作答 **500 `channel_events_unavailable`**,
// 绝不回一份全 0 的假汇总(与 `usage_unavailable` / `storage_unavailable` 同一口径)。
import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/events"
)
// channelEventsLogSource 是本文件全部日志的 source 标签(AGENTS §10:禁无来源裸日志)。
const channelEventsLogSource = "gateway/admin_channel_events"
// channelEventsMaxWindow 是一次查询允许的最大时间窗(90 天)。它**刻意**与
// `model.UsageMaxWindow` 取同一个值:design/26 §4.5 明写「跨度上限 90 天」且
// 「与 `llm/usage` 同口径」,两处各对着同一条设计口径写死,谁也别想只改一处。
const channelEventsMaxWindow = 90 * 24 * time.Hour
// channelEventDTO 是一条 `channel_events` 行的线上投影(键名与同族的
// `counters[]` 一致,取 camelCase)。
//
// 列与 `events.Event` **一一对应且不多不少**:它没有、也不会有承载平台原文、
// 媒体 URL、凭据或 OneBot `flag` 的字段(§4.2 红线)。`summary` 是适配器生成的
// 固定文案(如「群成员加入」),**已净化**,查不出原始文本。
type channelEventDTO struct {
ID int64 `json:"id"`
Platform string `json:"platform"`
AccountID string `json:"accountId"`
Tier string `json:"tier"`
Category string `json:"category"`
PlatformType string `json:"platformType"`
ChannelID string `json:"channelId"`
ChannelType string `json:"channelType"`
GroupID string `json:"groupId"`
ActorID string `json:"actorId"`
ActorRole string `json:"actorRole"`
TargetID string `json:"targetId"`
SelfRelated bool `json:"selfRelated"`
MessageID string `json:"messageId"`
Summary string `json:"summary"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// channelEventListResponse 是列表端点的 data 本体(§4.5:`{events, total}`)。
// `events` 恒为非 nil 切片(无数据时是 `[]` 而非 `null`,客户端不必判空)。
type channelEventListResponse struct {
Events []channelEventDTO `json:"events"`
Total int64 `json:"total"`
}
// channelEventCounterDTO 是计数表的一行(§4.5 逐字给出字段名)。
type channelEventCounterDTO struct {
Platform string `json:"platform"`
AccountID string `json:"accountId"`
Tier string `json:"tier"`
Category string `json:"category"`
PlatformType string `json:"platformType"`
Count int64 `json:"count"`
FirstSeenAt string `json:"firstSeenAt"`
LastSeenAt string `json:"lastSeenAt"`
}
// channelEventCountersResponse 是计数端点的 data 本体。
//
// `approx` 不是装饰:计数在内存里聚合、每 30s 落一次盘(§4.3),面板读到的表
// **不含**待 flush 的部分,因此它只少不多 —— 是个下界,不是精确账。这个字段就是
// 把这句话传到线上,免得面板把近似值画成事实。
type channelEventCountersResponse struct {
Approx bool `json:"approx"`
Counters []channelEventCounterDTO `json:"counters"`
}
// adminChannelEventsList 处理 GET /api/v1/admin/channels/events(列表,倒序)。
// RBAC 沿用既有管理读中间件(与同族的 `/admin/channels/messages` 同门)。
func (s *Server) adminChannelEventsList(w http.ResponseWriter, r *http.Request) {
if s.channelEvents == nil {
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_events_unavailable", "渠道事件留存未接线"))
return
}
filter, err := channelEventsListFilter(r)
if err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "%s", err.Error()))
return
}
rows, err := s.channelEvents.List(r.Context(), filter)
if err != nil {
// 原始错误(可能带 SQL/路径)只进日志;响应只给稳定 code + 中文说明。
logger.WithSource(s.log, channelEventsLogSource).Error("list channel events", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_events_unavailable", "读取渠道事件列表失败"))
return
}
total, err := s.channelEvents.Count(r.Context(), filter)
if err != nil {
logger.WithSource(s.log, channelEventsLogSource).Error("count channel events", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_events_unavailable", "统计渠道事件条数失败"))
return
}
writeJSON(w, http.StatusOK, channelEventsListResponseOf(rows, total))
}
// adminChannelEventsCounters 处理 GET /api/v1/admin/channels/events/counters
// (逐类计数汇总,近似值)。`platform` 空 = 全部平台。
func (s *Server) adminChannelEventsCounters(w http.ResponseWriter, r *http.Request) {
if s.channelEvents == nil {
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_events_unavailable", "渠道事件留存未接线"))
return
}
platform, err := parseChannelEventPlatform(r.URL.Query().Get("platform"))
if err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "%s", err.Error()))
return
}
counters, err := s.channelEvents.Counters(r.Context(), platform)
if err != nil {
logger.WithSource(s.log, channelEventsLogSource).Error("read channel event counters", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_events_unavailable", "读取渠道事件计数失败"))
return
}
writeJSON(w, http.StatusOK, channelEventCountersResponseOf(counters))
}
// channelEventsListFilter 解析并**校验**列表端点的查询参数。校验顺序是「形状先于
// 语义」:一个拼错的时间点 / 平台名直接 400,绝不拿零值条件去查库。
func channelEventsListFilter(r *http.Request) (events.Filter, error) {
params := r.URL.Query()
platform, err := parseChannelEventPlatform(params.Get("platform"))
if err != nil {
return events.Filter{}, err
}
tier, err := parseChannelEventTier(params.Get("tier"))
if err != nil {
return events.Filter{}, err
}
since, until, err := parseChannelEventWindow(params.Get("since"), params.Get("until"))
if err != nil {
return events.Filter{}, err
}
limit, err := parseChannelEventCount("limit", params.Get("limit"))
if err != nil {
return events.Filter{}, err
}
offset, err := parseChannelEventCount("offset", params.Get("offset"))
if err != nil {
return events.Filter{}, err
}
return events.Filter{
Platform: platform,
Tier: tier,
Category: channelEventCategory(params.Get("category")),
ChannelID: strings.TrimSpace(params.Get("channelId")),
Since: since,
Until: until,
Limit: clampChannelEventLimit(limit),
Offset: offset,
}, nil
}
// channelEventPlatforms 返回**已知平台**(design/26 §4.1 的 platform 列取值,
// 与 `internal/runtime/events` 的平台常量逐字一致)。它按调用返回而不是包级变量:
// AGENTS §10 禁包级状态,且白名单只有这一处定义,不存在两份漂移。
//
// 已知集合**不含** `weixin-ilink` 以外的微信别名 —— 平台名就是适配器写进
// `Platform` 字段的那个字符串(`plugins/channels/*` 的 platformName)。
func channelEventPlatforms() []string {
return []string{
events.PlatformQQ,
events.PlatformKook,
events.PlatformTelegram,
events.PlatformWeixinILink,
events.PlatformWebhook,
}
}
// parseChannelEventPlatform 校验 `platform` 过滤参数:空串 = 不过滤;不在白名单里
// ⇒ 报错(§4.5:`platform` 未知 → 400)。
//
// 绝不把未知平台当成「不过滤」静默放行 —— 那会让一次拼错的筛选返回**全部**渠道的
// 事件,比返回空更误导(这正是「不静默」要防的那一类)。
func parseChannelEventPlatform(raw string) (string, error) {
platform := strings.TrimSpace(raw)
if platform == "" {
return "", nil
}
for _, known := range channelEventPlatforms() {
if platform == known {
return platform, nil
}
}
return "", fmt.Errorf("platform 未知:%q(取值 %s)", raw, strings.Join(channelEventPlatforms(), "/"))
}
// parseChannelEventTier 校验 `tier` 过滤参数。档位只有三个取值(§2.1,迁移 0021 的
// 列取值即它们),非法值直接 400:一个写错的档位若被当成「不过滤」,面板会显示
// 全部档位却让人以为只看了一档。
func parseChannelEventTier(raw string) (events.Tier, error) {
switch tier := events.Tier(strings.TrimSpace(raw)); tier {
case "":
return "", nil
case events.TierT1, events.TierT2, events.TierT3:
return tier, nil
default:
return "", fmt.Errorf("tier 非法:%q(取值 t1/t2/t3)", raw)
}
}
// channelEventCategory 归一 `category` 过滤参数(精确匹配,空 = 不过滤)。
//
// 它**刻意不做白名单校验**:类别轴的权威在适配器层(`adapter.NoticeCategory*`),
// 会随新平台持续增加(KOOK / Telegram 的 `reaction`/`button`/`item`/`vote` 等),
// 在网关侧钉一份封闭集合只会让将来新登记的类别被 400 挡住。未知类别是一次
// **精确匹配**(返回空),不会把筛选放宽成「全部」—— 与未知平台的处理相反,理由
// 也正在这里:返回空是诚实的,返回全部才不是。
func channelEventCategory(raw string) events.Category {
return events.Category(strings.TrimSpace(raw))
}
// parseChannelEventCount 解析一个非负整数参数(limit / offset)。空串 = 未给(0);
// 非法整数报错;负值归一成 0(= 缺省语义),绝不把一个负页大小悄悄变成「全表」。
func parseChannelEventCount(name, raw string) (int, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return 0, nil
}
n, err := strconv.Atoi(trimmed)
if err != nil {
return 0, fmt.Errorf("%s 必须是整数:%q", name, raw)
}
if n < 0 {
return 0, nil
}
return n, nil
}
// clampChannelEventLimit 把页大小钳到上限(§4.5 的分页钳制)。超上限即钳住而不是
// 报错 —— 上限由 `events.MaxListLimit` 单点给出,这里不复写一份数字;0(未给 /
// 负值)保持 0,由存储层用 `events.DefaultListLimit` 补缺省。
func clampChannelEventLimit(n int) int {
if n > events.MaxListLimit {
return events.MaxListLimit
}
return n
}
// parseChannelEventWindow 解析 `since`/`until` 成一个闭区间,并守住 90 天上限。
//
// 归一规则(三条,都是「不静默裁剪」的直接推论):
//
// - **两个都没给**:不注入任何时间条件(客户端要的就是不加时间过滤);
// - **只给 since**:上界按「现在」算来判断跨度(`since` 早于 90 天前 ⇒ 400),
// 但**不**把「现在」写进 until —— 判跨度与改条件是两回事;
// - **只给 until**:只给上界,不替客户端造下界。造了就是把「我没指定下界」
// 偷偷换成「我只看最近 N 天」。
//
// 时间点接受 RFC3339 与 Unix 毫秒两种形态(复用同包的 `parseInstantParam`,与
// `llm/usage` 同一口径);落进 Filter 前是 `time.Time`,由存储层格式化成
// UTC 毫秒 + 字面量 `Z` 的定宽字符串,从而吃得上 `idx_channel_events_created_at`。
func parseChannelEventWindow(sinceRaw, untilRaw string) (time.Time, time.Time, error) {
since, err := parseInstantParam(sinceRaw)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("since 非法:%w", err)
}
until, err := parseInstantParam(untilRaw)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("until 非法:%w", err)
}
switch {
case since.IsZero() && until.IsZero():
return time.Time{}, time.Time{}, nil
case since.IsZero():
return time.Time{}, until, nil
case until.IsZero():
if span := time.Since(since); span > channelEventsMaxWindow {
return time.Time{}, time.Time{}, fmt.Errorf("时间窗超过上限 %s", channelEventsMaxWindow)
}
return since, time.Time{}, nil
}
if !since.Before(until) {
return time.Time{}, time.Time{}, fmt.Errorf("since 必须早于 until")
}
if until.Sub(since) > channelEventsMaxWindow {
return time.Time{}, time.Time{}, fmt.Errorf("时间窗超过上限 %s", channelEventsMaxWindow)
}
return since, until, nil
}
// channelEventsListResponseOf 把存储层结果翻成线上结构。时间一律以 **UTC RFC3339**
// 回显(与 `llm/usage` 同口径)。
func channelEventsListResponseOf(rows []events.Event, total int64) channelEventListResponse {
out := channelEventListResponse{
Events: make([]channelEventDTO, 0, len(rows)),
Total: total,
}
for _, ev := range rows {
out.Events = append(out.Events, channelEventDTOOf(ev))
}
return out
}
// channelEventDTOOf 是单行投影(一处换算,避免 List 与将来可能的详情端点各写一遍)。
func channelEventDTOOf(ev events.Event) channelEventDTO {
return channelEventDTO{
ID: ev.ID,
Platform: ev.Platform,
AccountID: ev.AccountID,
Tier: string(ev.Tier),
Category: string(ev.Category),
PlatformType: ev.PlatformType,
ChannelID: ev.ChannelID,
ChannelType: ev.ChannelType,
GroupID: ev.GroupID,
ActorID: ev.ActorID,
ActorRole: ev.ActorRole,
TargetID: ev.TargetID,
SelfRelated: ev.SelfRelated,
MessageID: ev.MessageID,
Summary: ev.Summary,
CreatedAt: ev.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: ev.UpdatedAt.UTC().Format(time.RFC3339),
}
}
// channelEventCountersResponseOf 把计数表结果翻成线上结构。`approx` 恒为 true:
// 计数面的近似语义来自数据源本身(内存聚合 + 周期 flush),不是某次查询的偶然。
func channelEventCountersResponseOf(counters []events.Counter) channelEventCountersResponse {
out := channelEventCountersResponse{
Approx: true,
Counters: make([]channelEventCounterDTO, 0, len(counters)),
}
for _, c := range counters {
out.Counters = append(out.Counters, channelEventCounterDTO{
Platform: c.Platform,
AccountID: c.AccountID,
Tier: string(c.Tier),
Category: string(c.Category),
PlatformType: c.PlatformType,
Count: c.Count,
FirstSeenAt: c.FirstSeenAt.UTC().Format(time.RFC3339),
LastSeenAt: c.LastSeenAt.UTC().Format(time.RFC3339),
})
}
return out
}
@@ -0,0 +1,177 @@
package gateway
// 本文件是渠道事件查询面的**跨层**闭环(design/26 §4.5;切片 E6):用真的写入
// 路径(`events.SQLStore.Record` + `Flush`)把事件落进真 SQLite(跑迁移 0021),
// 再经**真的** HTTP handler 读回来。它钉住三件只有端到端才成立的事:
//
// - 计数面读的是**已落盘**的计数表且恒带 `approx:true`(近似值语义);
// - T1 遥测(心跳)**只计数不落行**(这正是「近似值」的来源);
// - `weixin-ilink` 没有非消息事件时如实为空,**绝不借别的平台的事件顶替**(§3.4/§8.5)。
//
// 参数翻译与诚实边界在 `admin_channel_events_test.go`。
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/events"
)
type channelEventCounterRowDTO struct {
Platform string `json:"platform"`
AccountID string `json:"accountId"`
Tier string `json:"tier"`
Category string `json:"category"`
PlatformType string `json:"platformType"`
Count int64 `json:"count"`
FirstSeenAt string `json:"firstSeenAt"`
LastSeenAt string `json:"lastSeenAt"`
}
type channelEventCountersDTO struct {
Approx bool `json:"approx"`
Counters []channelEventCounterRowDTO `json:"counters"`
}
// decodeChannelEventCounters 解出计数端点的 data 本体。
func decodeChannelEventCounters(t *testing.T, body []byte) channelEventCountersDTO {
t.Helper()
var out channelEventCountersDTO
if err := json.Unmarshal(decodeBody(t, body)["data"], &out); err != nil {
t.Fatalf("decode counters data: %v (body=%s)", err, body)
}
return out
}
// channelEventTestStore 打开一个真实的 SQLite 文件库、跑迁移,并构造 E1 的
// 事件存储 —— 端点与写入面用的是**同一台** store(与生产同一条路)。
func channelEventTestStore(t *testing.T, dir string) (*sql.DB, *events.SQLStore) {
t.Helper()
ctx := context.Background()
conn, err := db.Open(ctx, filepath.Join(dir, "cyrene.db"), gwTestLogger())
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
if err := db.MigrateUp(ctx, conn, gwTestLogger()); err != nil {
t.Fatalf("migrate: %v", err)
}
store, err := events.NewStore(events.Config{DB: conn, Log: gwTestLogger()})
if err != nil {
t.Fatalf("new event store: %v", err)
}
return conn, store
}
// TestAdminChannelEventsCountersAreApproxAndOnlyWhatIsOnDisk 断言已落盘计数的
// 真值(心跳 2 帧 = count 2)与 T1「只计数不落行」,并顺带钉住微信平台的空。
func TestAdminChannelEventsCountersAreApproxAndOnlyWhatIsOnDisk(t *testing.T) {
t.Parallel()
ctx := context.Background()
_, store := channelEventTestStore(t, t.TempDir())
heartbeat := events.Event{
Platform: events.PlatformQQ, AccountID: "acct-1", Tier: events.TierT1,
Category: events.CategoryHeartbeat, PlatformType: "heartbeat",
}
for i := 0; i < 2; i++ {
if err := store.Record(ctx, heartbeat); err != nil {
t.Fatalf("record heartbeat: %v", err)
}
}
join := events.Event{
Platform: events.PlatformQQ, AccountID: "acct-1", Tier: events.TierT2,
Category: events.CategoryJoin, PlatformType: "group_increase",
ChannelID: "g1", ChannelType: events.ChannelGroup, ActorID: "10001", Summary: "群成员加入",
}
if err := store.Record(ctx, join); err != nil {
t.Fatalf("record join: %v", err)
}
if err := store.Flush(ctx); err != nil {
t.Fatalf("flush counters: %v", err)
}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{EventStore: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
status, body := getChannelEvents(t, ts.URL, "/api/v1/admin/channels/events/counters", "", tok)
if status != http.StatusOK {
t.Fatalf("counters status = %d, want 200 (body=%s)", status, body)
}
counters := decodeChannelEventCounters(t, body)
if !counters.Approx {
t.Error("counters response must carry approx:true(计数是内存聚合 + 周期 flush 的近似值)")
}
byType := make(map[string]int64, len(counters.Counters))
for _, c := range counters.Counters {
byType[c.Platform+"/"+c.PlatformType] = c.Count
}
if byType["qq-obv11/heartbeat"] != 2 {
t.Errorf("heartbeat count = %d, want 2 (%+v)", byType["qq-obv11/heartbeat"], counters.Counters)
}
if byType["qq-obv11/group_increase"] != 1 {
t.Errorf("group_increase count = %d, want 1 (%+v)", byType["qq-obv11/group_increase"], counters.Counters)
}
status, body = getChannelEvents(t, ts.URL, "/api/v1/admin/channels/events", "platform=qq-obv11", tok)
if status != http.StatusOK {
t.Fatalf("list status = %d, want 200 (body=%s)", status, body)
}
list := decodeChannelEvents(t, body)
if list.Total != 1 || len(list.Events) != 1 {
t.Fatalf("qq list = %+v, want exactly the T2 row (T1 心跳只计数不落行)", list)
}
if list.Events[0].PlatformType != "group_increase" {
t.Errorf("qq row = %+v, want the join event", list.Events[0])
}
// 微信渠道**没有**非消息事件(§3.4):如实为空,绝不借别的平台顶替。
status, body = getChannelEvents(t, ts.URL, "/api/v1/admin/channels/events",
"platform="+events.PlatformWeixinILink, tok)
if status != http.StatusOK {
t.Fatalf("weixin list status = %d, want 200 (body=%s)", status, body)
}
weixin := decodeChannelEvents(t, body)
if weixin.Total != 0 || len(weixin.Events) != 0 {
t.Errorf("weixin list = %+v, want empty(该渠道不提供非消息事件)", weixin)
}
if strings.Contains(string(body), events.PlatformQQ) {
t.Errorf("weixin response borrowed another platform's events: %s", body)
}
status, body = getChannelEvents(t, ts.URL, "/api/v1/admin/channels/events/counters",
"platform="+events.PlatformWeixinILink, tok)
if status != http.StatusOK {
t.Fatalf("weixin counters status = %d, want 200 (body=%s)", status, body)
}
weixinCounters := decodeChannelEventCounters(t, body)
if len(weixinCounters.Counters) != 0 {
t.Errorf("weixin counters = %+v, want empty", weixinCounters.Counters)
}
if !strings.Contains(string(body), `"counters":[]`) {
t.Errorf("empty counters must serialise as [] not null: %s", body)
}
}
// TestAdminChannelEventsEmptyListSerialisesAsArray 钉住线上的空形状:没有事件时
// `events` 是 `[]`(客户端不必在 null 与空数组之间分叉)。
func TestAdminChannelEventsEmptyListSerialisesAsArray(t *testing.T) {
t.Parallel()
stub := &channelEventStoreStub{}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{EventStore: stub})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
status, body := getChannelEvents(t, ts.URL, "/api/v1/admin/channels/events", "platform=kook", tok)
if status != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", status, body)
}
if !strings.Contains(string(body), `"events":[]`) {
t.Errorf("empty list must serialise as [] not null: %s", body)
}
}
@@ -0,0 +1,371 @@
package gateway
// 本文件钉住渠道事件只读查询面的**参数翻译与诚实边界**(design/26 §4.5;切片 E6,
// 契约 v0.67 登记的两个 admin 端点):
//
// - 过滤(平台/档位/类别/会话/时间窗)逐条落到存储层条件上;
// - **跨度 > 90 天 ⇒ 400**(不静默裁剪);没给时间窗就**不注入**时间窗;
// - 分页钳制(超上限钳到 MaxListLimit);
// - 未接线 / 读库失败 ⇒ **500 channel_events_unavailable**(绝不回全 0 假汇总);
// - 空响应是 `[]` 而不是 `null`。
//
// 真实存储的端到端行为(同一台 SQLStore、真 SQLite、真迁移)在
// `admin_channel_events_store_test.go`。
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/events"
)
// channelEventStoreStub 是 ChannelEventStore 的测试替身:记录**端点传下去的**
// 查询条件,回放预置结果。它让「端点怎么翻译查询参数」与「端点怎么翻线上形状」
// 可以分别断言。
type channelEventStoreStub struct {
rows []events.Event
counters []events.Counter
listErr error
countErr error
countersErr error
gotList []events.Filter
gotCount []events.Filter
gotPlatforms []string
}
func (s *channelEventStoreStub) List(_ context.Context, f events.Filter) ([]events.Event, error) {
s.gotList = append(s.gotList, f)
if s.listErr != nil {
return nil, s.listErr
}
return s.rows, nil
}
func (s *channelEventStoreStub) Count(_ context.Context, f events.Filter) (int64, error) {
s.gotCount = append(s.gotCount, f)
if s.countErr != nil {
return 0, s.countErr
}
return int64(len(s.rows)), nil
}
func (s *channelEventStoreStub) Counters(_ context.Context, platform string) ([]events.Counter, error) {
s.gotPlatforms = append(s.gotPlatforms, platform)
if s.countersErr != nil {
return nil, s.countersErr
}
return s.counters, nil
}
// 线上形状的本地复刻(键名与 admin_channel_events.go 逐字对齐;写错就解不出来)。
type channelEventRowDTO struct {
ID int64 `json:"id"`
Platform string `json:"platform"`
AccountID string `json:"accountId"`
Tier string `json:"tier"`
Category string `json:"category"`
PlatformType string `json:"platformType"`
ChannelID string `json:"channelId"`
ChannelType string `json:"channelType"`
GroupID string `json:"groupId"`
ActorID string `json:"actorId"`
ActorRole string `json:"actorRole"`
TargetID string `json:"targetId"`
SelfRelated bool `json:"selfRelated"`
MessageID string `json:"messageId"`
Summary string `json:"summary"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type channelEventListDTO struct {
Events []channelEventRowDTO `json:"events"`
Total int64 `json:"total"`
}
// getChannelEvents 以 owner token 请求一个渠道事件端点。
func getChannelEvents(t *testing.T, url, path, query, token string) (int, []byte) {
t.Helper()
if query != "" {
path += "?" + query
}
resp, body := doReq(t, http.MethodGet, url+path, nil, token)
return resp.StatusCode, body
}
// decodeChannelEvents 解出列表端点的 data 本体。
func decodeChannelEvents(t *testing.T, body []byte) channelEventListDTO {
t.Helper()
var out channelEventListDTO
if err := json.Unmarshal(decodeBody(t, body)["data"], &out); err != nil {
t.Fatalf("decode events data: %v (body=%s)", err, body)
}
return out
}
// channelEventErrorCode 取出错误信封里的 code(形状不对时 fatal)。
func channelEventErrorCode(t *testing.T, body []byte) string {
t.Helper()
wantErrorShape(t, body)
var eobj struct {
Code string `json:"code"`
}
if err := json.Unmarshal(decodeBody(t, body)["error"], &eobj); err != nil {
t.Fatalf("decode error envelope: %v (body=%s)", err, body)
}
return eobj.Code
}
// TestAdminChannelEventsListPassesEveryFilterDown 断言 8 个查询参数**逐条**落到
// 存储层条件上(少传一个就是静默放宽筛选),且回上来的行按线上形状投影。
func TestAdminChannelEventsListPassesEveryFilterDown(t *testing.T) {
t.Parallel()
created := time.Date(2026, 10, 5, 3, 4, 5, 600_000_000, time.UTC)
stub := &channelEventStoreStub{rows: []events.Event{{
ID: 7, Platform: events.PlatformQQ, AccountID: "acct-1", Tier: events.TierT2,
Category: events.CategoryJoin, PlatformType: "group_increase",
ChannelID: "g1", ChannelType: events.ChannelGroup, GroupID: "g1",
ActorID: "10001", ActorRole: events.RoleMember, TargetID: "0", SelfRelated: true,
MessageID: "m-9", Summary: "群成员加入", CreatedAt: created, UpdatedAt: created,
}}}
since := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
until := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{EventStore: stub})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
query := url.Values{
"platform": {events.PlatformQQ},
"tier": {string(events.TierT2)},
"category": {string(events.CategoryJoin)},
"channelId": {"g1"},
"since": {since.Format(time.RFC3339)},
"until": {until.Format(time.RFC3339)},
"limit": {"25"},
"offset": {"5"},
}
status, body := getChannelEvents(t, ts.URL, "/api/v1/admin/channels/events", query.Encode(), tok)
if status != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", status, body)
}
if len(stub.gotList) != 1 {
t.Fatalf("store.List calls = %d, want exactly 1", len(stub.gotList))
}
got := stub.gotList[0]
want := events.Filter{
Platform: events.PlatformQQ, Tier: events.TierT2, Category: events.CategoryJoin,
ChannelID: "g1", Since: since, Until: until, Limit: 25, Offset: 5,
}
if got != want {
t.Errorf("store filter = %+v, want %+v", got, want)
}
// Count 用的是**同一个**条件(否则 total 与 events 说的不是同一件事)。
if len(stub.gotCount) != 1 || stub.gotCount[0] != want {
t.Errorf("store count filters = %+v, want one %+v", stub.gotCount, want)
}
list := decodeChannelEvents(t, body)
if list.Total != 1 || len(list.Events) != 1 {
t.Fatalf("list = %+v, want one row and total=1", list)
}
row := list.Events[0]
if row.ID != 7 || row.Platform != events.PlatformQQ || row.AccountID != "acct-1" {
t.Errorf("row identity = %+v", row)
}
if row.Tier != string(events.TierT2) || row.Category != string(events.CategoryJoin) {
t.Errorf("row tier/category = %q/%q", row.Tier, row.Category)
}
if row.ChannelType != events.ChannelGroup || row.GroupID != "g1" || row.ActorID != "10001" {
t.Errorf("row channel/actor = %+v", row)
}
if !row.SelfRelated || row.MessageID != "m-9" || row.Summary != "群成员加入" {
t.Errorf("row self/message/summary = %+v", row)
}
if row.CreatedAt != created.Format(time.RFC3339) || row.UpdatedAt != created.Format(time.RFC3339) {
t.Errorf("row times = %q/%q, want UTC RFC3339 %q", row.CreatedAt, row.UpdatedAt, created.Format(time.RFC3339))
}
}
// TestAdminChannelEventsListRejectsWindowOver90Days 钉住 §4.5 的「跨度上限 90 天,
// 超 → 400」:**绝不静默裁剪**成 90 天(那会让客户端把「我没查那么久」误读成
// 「就那么少」)。只给 since 时同样按「现在」算跨度。
func TestAdminChannelEventsListRejectsWindowOver90Days(t *testing.T) {
t.Parallel()
stub := &channelEventStoreStub{}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{EventStore: stub})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
now := time.Now().UTC()
old := now.Add(-91 * 24 * time.Hour)
for _, tc := range []struct {
name string
query url.Values
}{
{"both bounds", url.Values{"since": {old.Format(time.RFC3339)}, "until": {now.Format(time.RFC3339)}}},
{"since only", url.Values{"since": {old.Format(time.RFC3339)}}},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
status, body := getChannelEvents(t, ts.URL, "/api/v1/admin/channels/events", tc.query.Encode(), tok)
if status != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", status, body)
}
if code := channelEventErrorCode(t, body); code != "bad_request" {
t.Errorf("error code = %q, want bad_request", code)
}
if !strings.Contains(string(body), "90d") && !strings.Contains(string(body), "2160h") {
t.Errorf("400 message must name the cap: %s", body)
}
// 被拒的请求**不得**摸到存储层(否则「静默裁剪」会以另一种形式回来)。
if len(stub.gotList) != 0 {
t.Errorf("store was queried %d time(s) for a rejected window", len(stub.gotList))
}
})
}
}
// TestAdminChannelEventsListDoesNotInventAWindow 断言「没给窗口就不注入窗口」:
// 端点的缺省不是「近 24h」,而是「不加时间条件」—— 替客户端造下界就是把
// 「我没指定」偷偷换成「我只看了最近一段」。
func TestAdminChannelEventsListDoesNotInventAWindow(t *testing.T) {
t.Parallel()
stub := &channelEventStoreStub{}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{EventStore: stub})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
status, body := getChannelEvents(t, ts.URL, "/api/v1/admin/channels/events", "", tok)
if status != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", status, body)
}
if len(stub.gotList) != 1 {
t.Fatalf("store.List calls = %d, want 1", len(stub.gotList))
}
if f := stub.gotList[0]; !f.Since.IsZero() || !f.Until.IsZero() {
t.Errorf("filter window = %v..%v, want both zero (no invented window)", f.Since, f.Until)
}
}
// TestAdminChannelEventsListClampsPagination 钉住分页钳制:超上限**钳到**
// events.MaxListLimit(500),未给/负值保持「缺省」,非法整数 400(形状先于语义)。
func TestAdminChannelEventsListClampsPagination(t *testing.T) {
t.Parallel()
stub := &channelEventStoreStub{}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{EventStore: stub})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
for _, tc := range []struct {
query string
wantLimit int
wantOff int
}{
{"limit=100000", events.MaxListLimit, 0},
{"limit=0", 0, 0},
{"limit=-3&offset=-9", 0, 0},
{"limit=17&offset=3", 17, 3},
} {
tc := tc
t.Run(tc.query, func(t *testing.T) {
before := len(stub.gotList)
status, body := getChannelEvents(t, ts.URL, "/api/v1/admin/channels/events", tc.query, tok)
if status != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", status, body)
}
if len(stub.gotList) != before+1 {
t.Fatalf("store.List calls = %d, want %d", len(stub.gotList), before+1)
}
f := stub.gotList[len(stub.gotList)-1]
if f.Limit != tc.wantLimit || f.Offset != tc.wantOff {
t.Errorf("filter limit/offset = %d/%d, want %d/%d", f.Limit, f.Offset, tc.wantLimit, tc.wantOff)
}
})
}
// 非法整数是**形状错误**,不是「悄悄用缺省」。
status, body := getChannelEvents(t, ts.URL, "/api/v1/admin/channels/events", "limit=abc", tok)
if status != http.StatusBadRequest {
t.Fatalf("limit=abc: status = %d, want 400 (body=%s)", status, body)
}
}
// TestAdminChannelEventsListRejectsUnknownPlatformAndTier 钉住 §4.5 的
// 「`platform` 未知 → 400」以及档位白名单:未知平台**绝不**被当成「不过滤」
// 静默放行(那会让一次拼错的筛选返回全部渠道的事件)。类别则相反 —— 类别轴的
// 权威在适配器层且会持续增加,因此未知类别是一次**精确匹配**(返回空),不是 400。
func TestAdminChannelEventsListRejectsUnknownPlatformAndTier(t *testing.T) {
t.Parallel()
stub := &channelEventStoreStub{}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{EventStore: stub})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
for _, query := range []string{"platform=tiktok", "tier=t9"} {
status, body := getChannelEvents(t, ts.URL, "/api/v1/admin/channels/events", query, tok)
if status != http.StatusBadRequest {
t.Fatalf("%s: status = %d, want 400 (body=%s)", query, status, body)
}
}
if len(stub.gotList) != 0 {
t.Errorf("store was queried for rejected parameters: %+v", stub.gotList)
}
status, body := getChannelEvents(t, ts.URL, "/api/v1/admin/channels/events", "category=not_a_category", tok)
if status != http.StatusOK {
t.Fatalf("unknown category: status = %d, want 200 exact-match (body=%s)", status, body)
}
if got := stub.gotList[len(stub.gotList)-1].Category; got != "not_a_category" {
t.Errorf("category filter = %q, want the exact value passed through", got)
}
}
// TestAdminChannelEventsUnwiredReturns500 钉住 §6.5 的诚实边界:留存面未接线时
// 两个端点都以 **500 channel_events_unavailable** 作答,绝不回一份全 0 / 空的
// 假汇总(面板据此显示错误,而不是一个看着正常的 0)。
func TestAdminChannelEventsUnwiredReturns500(t *testing.T) {
t.Parallel()
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
for _, path := range []string{
"/api/v1/admin/channels/events",
"/api/v1/admin/channels/events/counters",
} {
status, body := getChannelEvents(t, ts.URL, path, "", tok)
if status != http.StatusInternalServerError {
t.Fatalf("%s: status = %d, want 500 (body=%s)", path, status, body)
}
if code := channelEventErrorCode(t, body); code != "channel_events_unavailable" {
t.Errorf("%s: error code = %q, want channel_events_unavailable", path, code)
}
if strings.Contains(string(body), `"data"`) {
t.Errorf("%s: unwired response must not carry a data envelope: %s", path, body)
}
}
}
// TestAdminChannelEventsReadFailureIsUnavailable 断言读库失败与「未接线」同一种
// 诚实:500 + 稳定 code,绝不降级成「200 + 空列表」(那正是把故障画成事实)。
func TestAdminChannelEventsReadFailureIsUnavailable(t *testing.T) {
t.Parallel()
stub := &channelEventStoreStub{listErr: fmt.Errorf("disk on fire")}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{EventStore: stub})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
status, body := getChannelEvents(t, ts.URL, "/api/v1/admin/channels/events", "", tok)
if status != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500 (body=%s)", status, body)
}
if code := channelEventErrorCode(t, body); code != "channel_events_unavailable" {
t.Errorf("error code = %q, want channel_events_unavailable", code)
}
// 原始错误(可能带 SQL / 路径)只进日志,绝不出现在响应里。
if strings.Contains(string(body), "disk on fire") {
t.Errorf("internal error leaked into the response: %s", body)
}
}
+9 -5
View File
@@ -90,17 +90,17 @@ func (s *Server) adminLlmUsage(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, llmUsageResponseOf(summary))
}
// llmUsageQuery 解析并**校验**查询参数:`since`/`until` 见 parseLlmUsageInstant,
// llmUsageQuery 解析并**校验**查询参数:`since`/`until` 见 parseInstantParam,
// 缺省窗口为近 24h,跨度上限见 model.UsageMaxWindow。
//
// 校验顺序是「形状先于语义」:时间点解析不了就直接 400,不会拿一个零值窗口去查库。
func llmUsageQuery(r *http.Request) (model.UsageQuery, error) {
params := r.URL.Query()
since, err := parseLlmUsageInstant(params.Get("since"))
since, err := parseInstantParam(params.Get("since"))
if err != nil {
return model.UsageQuery{}, fmt.Errorf("since 非法:%w", err)
}
until, err := parseLlmUsageInstant(params.Get("until"))
until, err := parseInstantParam(params.Get("until"))
if err != nil {
return model.UsageQuery{}, fmt.Errorf("until 非法:%w", err)
}
@@ -123,14 +123,18 @@ func llmUsageQuery(r *http.Request) (model.UsageQuery, error) {
}, nil
}
// parseLlmUsageInstant 解析一个时间点参数。接受两种形态:
// parseInstantParam 解析一个时间点参数。接受两种形态:
//
// - **RFC3339**(如 `2026-10-05T00:00:00Z`,JS 的 `toISOString()` 就是这个);
// - **Unix 毫秒**(纯数字,如 `1759622400000`)—— 与 `/sessions/{id}/messages`
// 的 `before`/`after` 游标同一种口径,便于客户端复用游标。
//
// 空串 = 未给(返回零值)。其它形态报错(绝不静默当成「现在」)。
func parseLlmUsageInstant(raw string) (time.Time, error) {
//
// 它是**跨端点共用**的解析器:`/admin/llm/usage` 与 `/admin/channels/events`
// (切片 E6)的 `since`/`until` 口径相同(同一形态 ⇒ 同一解析 ⇒ 同一比较语义),
// 因此只此一份,不各写一遍。
func parseInstantParam(raw string) (time.Time, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}, nil
+23
View File
@@ -13,6 +13,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion"
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/events"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
@@ -261,3 +262,25 @@ type LlmUsageStore interface {
// handler 校验并补齐(缺省近 24h、上限 90 天)。
Summary(ctx context.Context, q model.UsageQuery) (model.UsageSummary, error)
}
// ChannelEventStore 是渠道事件留存的**只读查询出口**(design/26 §4.5 / §6.5;
// 切片 E6)。生产实现是 `internal/runtime/events` 的 `*SQLStore`,组合根把
// 同一个句柄既交给桥接层(写入面,E3)也交给网关(本查询面)。
//
// 它刻意只声明三个**读**方法:写入、计数落盘与保留期清扫是桥接层与组合根的
// 职责,管理查询面拿不到、也不该拿到它们(最小权限)。
//
// 查询结果里**没有任何平台原文 / 媒体 URL / 凭据 / OneBot `flag`** —— 事件表
// 本身就没有这些列(§4.2 红线),因此这里不存在「回显敏感字段」的代码路径。
//
// 为 nil 时两个端点诚实作答 500 `channel_events_unavailable`,绝不回一份全 0
// 的假汇总(与 `usage_unavailable` / `storage_unavailable` 同一口径)。
type ChannelEventStore interface {
// List 按时间倒序返回符合条件的事件行(Limit/Offset 见 events.Filter)。
List(ctx context.Context, f events.Filter) ([]events.Event, error)
// Count 统计符合条件的事件行数(List 的分页配套)。
Count(ctx context.Context, f events.Filter) (int64, error)
// Counters 返回逐类计数汇总;platform 为空表示全部平台。它读的是**已落盘**
// 的表,不含内存里待 flush 的部分(即 §4.3 的「近似值」)。
Counters(ctx context.Context, platform string) ([]events.Counter, error)
}
+6
View File
@@ -142,6 +142,12 @@ type Deps struct {
// GET /api/v1/admin/llm/usage 诚实作答 500 usage_unavailable,
// 绝不回一份全 0 的假汇总。
Usage LlmUsageStore
// EventStore 是渠道事件留存的只读查询出口(design/26 §4.5;切片 E6)。
// 组合根注入的正是交给桥接层写入的**同一个** `*events.SQLStore` 句柄
// (同一个库、同一份口径)。可选;为 nil 时
// GET /api/v1/admin/channels/events 与 `.../counters` 诚实作答
// 500 channel_events_unavailable,绝不回一份全 0 的假汇总。
EventStore ChannelEventStore
// MaxParallelTurns 是单会话在途并行路数的上限(design/15 §2.5.7)。
// 零值/负数 ⇒ 编排器用默认 2;1 ⇒ 关闭真并行(恒 1 路,完全回落到
// queue + 回合内 steer 的现状);超过硬上限 3 ⇒ 钳到 3。
@@ -44,6 +44,8 @@ var ownerAdminReadRoutes = []rbacRoute{
{"memories", http.MethodGet, "/api/v1/admin/memories"},
{"channels/messages", http.MethodGet, "/api/v1/admin/channels/messages"},
{"channels/{platform}/messages", http.MethodGet, "/api/v1/admin/channels/kook/messages"},
{"channels/events", http.MethodGet, "/api/v1/admin/channels/events"},
{"channels/events/counters", http.MethodGet, "/api/v1/admin/channels/events/counters"},
{"llm", http.MethodGet, "/api/v1/admin/llm"},
{"users", http.MethodGet, "/api/v1/admin/users"},
{"backthink", http.MethodGet, "/api/v1/admin/backthink"},
+6
View File
@@ -167,6 +167,12 @@ func (s *Server) registerAdminRoutes(
mux.Handle("GET /api/v1/admin/memories", writeAuth(http.HandlerFunc(s.adminListMemories)))
mux.Handle("GET /api/v1/admin/channels/messages", writeAuth(http.HandlerFunc(s.handleChannelMessages)))
mux.Handle("GET /api/v1/admin/channels/{platform}/messages", writeAuth(http.HandlerFunc(s.handleChannelPlatformLog)))
// 渠道事件查询面(design/26 §4.5;切片 E6,契约 **v0.67 已登记**):非消息事件的
// 只读投影 + 逐类计数(近似值)。与同族的 `/admin/channels/messages` 同门
// (owner|admin):事件行带群 id / 成员 id / 关联消息 id,属私有内容面,
// 不是给 viewer 的监控投影(契约 §7.4-12 登记「落地前不要假设 viewer 能读」)。
mux.Handle("GET /api/v1/admin/channels/events", writeAuth(http.HandlerFunc(s.adminChannelEventsList)))
mux.Handle("GET /api/v1/admin/channels/events/counters", writeAuth(http.HandlerFunc(s.adminChannelEventsCounters)))
mux.Handle("GET /api/v1/admin/llm", writeAuth(http.HandlerFunc(s.adminGetLlm)))
// LLM 用量汇总(切片 U1,**v0.64**):只读、不审计,与同页的 /admin/llm
// 同门(owner|admin)—— 它含成本与模型/用途分解,不是给 viewer 的监控投影。
+5
View File
@@ -86,6 +86,10 @@ type Server struct {
// usage 是 LLM 用量汇总的只读出口(切片 U1,见 Deps.Usage)。为 nil 时
// GET /api/v1/admin/llm/usage 回 500 usage_unavailable。
usage LlmUsageStore
// channelEvents 是渠道事件留存的只读查询出口(切片 E6,见 Deps.EventStore)。
// 为 nil 时 GET /api/v1/admin/channels/events(及其 /counters)回
// 500 channel_events_unavailable —— 未接线就说未接线,不回全 0 假汇总。
channelEvents ChannelEventStore
// db 是组合根注入的活 SQLite 连接池(见 Deps.Database)。管理侧
// 「存储状态」只读端点用它读页级统计与 documents 表;为 nil 时该
// 端点回 500 storage_unavailable(不另开池、不假装有数据)。
@@ -141,6 +145,7 @@ func New(cfg *config.Config, deps Deps) (*Server, error) {
ownerRegistrar: deps.OwnerRegistrar,
webhooks: deps.Webhooks,
usage: deps.Usage,
channelEvents: deps.EventStore,
dbPath: deps.DatabasePath,
dataDir: deps.DataDir,
db: deps.Database,