feat(gateway): 渠道事件查询面 + 面板「渠道事件」页(design/26 E6)
- 两个只读端点: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:
1 parent
a0abf0199a
commit
c8053b1f36
21 files changed
+1805
-30
No files matched your search
+27
-25
@@ -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 聚合)⇒ 该段**只增口径、不变形状**,客户端**不要**指望能分开统计。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "能力与连接" },
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"},
|
||||
|
||||
@@ -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 的监控投影。
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user