diff --git a/Docs/api-client.md b/Docs/api-client.md index 05809aa..8b2d538 100644 --- a/Docs/api-client.md +++ b/Docs/api-client.md @@ -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 */`),**不套** `{"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=""`;`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/`。**🎤 判据 = `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 聚合)⇒ 该段**只增口径、不变形状**,客户端**不要**指望能分开统计。 diff --git a/cmd/cyrene/wire_frontend.go b/cmd/cyrene/wire_frontend.go index 78436d6..edb046d 100644 --- a/cmd/cyrene/wire_frontend.go +++ b/cmd/cyrene/wire_frontend.go @@ -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) diff --git a/frontend/web/src/admin/AdminApp.tsx b/frontend/web/src/admin/AdminApp.tsx index 514183e..00a3b96 100644 --- a/frontend/web/src/admin/AdminApp.tsx +++ b/frontend/web/src/admin/AdminApp.tsx @@ -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 ; case "plugins": return ; case "channels": return ; + case "channel-events": return ; case "iot": return ; case "rag": return ; case "llm": return ; diff --git a/frontend/web/src/admin/modules/ChannelEvents.render.test.ts b/frontend/web/src/admin/modules/ChannelEvents.render.test.ts new file mode 100644 index 0000000..8679456 --- /dev/null +++ b/frontend/web/src/admin/modules/ChannelEvents.render.test.ts @@ -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[0]>): string { + return renderToStaticMarkup( + createElement(ChannelEventCountersBody, { + counters: null, + approx: false, + loading: false, + error: null, + platform: "", + ...props, + }) + ); +} + +function listHtml(props: Partial[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}`); diff --git a/frontend/web/src/admin/modules/ChannelEvents.tsx b/frontend/web/src/admin/modules/ChannelEvents.tsx new file mode 100644 index 0000000..90d3bfd --- /dev/null +++ b/frontend/web/src/admin/modules/ChannelEvents.tsx @@ -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 = [ + "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 ( +
+ + + + + props.onChannelId(e.target.value)} + placeholder="会话 / 群 id" + className={`${inputCls} w-40`} + /> + +
+ ); +} + +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("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(); + 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 ( +
+ + 刷新 + + } + /> + + + + + + + +

事件列表

+ +
+ + + 只读查询:事件行只有结构化事实(平台 / 类型 / 当事人 id / 固定摘要), + 没有正文、没有媒体 URL、没有凭据与 OneBot flag—— + 那些东西根本没进事件表(design/26 §4.2 红线)。T1 遥测(心跳等)只进计数、 + 不逐条落行;未接线或读库失败时两个端点诚实回错,本页照实显示,绝不画成 0。 + +
+ ); +} diff --git a/frontend/web/src/admin/modules/channelEventViews.tsx b/frontend/web/src/admin/modules/channelEventViews.tsx new file mode 100644 index 0000000..cd313b6 --- /dev/null +++ b/frontend/web/src/admin/modules/channelEventViews.tsx @@ -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 = { + t1: { label: "遥测留存", tone: "neutral" }, + t2: { label: "留存+上行", tone: "accent" }, + t3: { label: "交互", tone: "ok" }, +}; + +/** 归一化类别 → 中文(只覆盖已登记的类别;未登记的原样显示,绝不丢行)。 */ +const CATEGORY_LABELS: Record = { + 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(); + 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 计数读取失败:{error}; + if (loading && !counters) { + return
加载中…
; + } + const rows = groupCounters(counters ?? []); + return ( +
+
+ + {approx ? "计数为近似值(下界)" : "计数未标注近似语义"} + + 读的是已落盘计数表;「上游到底推没推」看这里 —— 与事件行的「推过什么」互为补充。 +
+ {rows.length === 0 ? ( + } + title="暂无计数" + hint={ + platform === WEIXIN_PLATFORM + ? NO_EVENT_PLATFORM_HINT + : "该平台在这段时间内没有落盘计数。若入站一直在跑,先确认留存面是否已接线(未接线时计数只在内存里)。" + } + /> + ) : ( +
+ {rows.map((row) => ( + +
+ {row.platform} + 合计 {row.total} +
+
+ {row.groups.map((g, i) => ( +
+
+ + {COUNTER_GROUPS[i]?.label ?? g.key} + + {g.count} +
+
+ ))} +
+
+ ))} +
+ )} +
+ ); +} + +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 事件列表读取失败:{error}; + if (loading && !events) { + return
加载中…
; + } + const rows = events ?? []; + if (rows.length === 0) { + return ( + } + title="没有事件落痕" + hint={ + platform === WEIXIN_PLATFORM + ? NO_EVENT_PLATFORM_HINT + : "该筛选下没有 T2/T3/未知/链路事件。T1 遥测(心跳等他人在线状态)只进计数、不逐条落行 —— 去上面的计数卡看。" + } + /> + ); + } + return ( +
+ {error ? 列表刷新失败,下面显示的是上次成功的结果:{error} : null} +
+ + 共 {total} 条(本页 {rows.length} 条) + + · 倒序 · 逐条留痕,可查不可改 +
+
+ {rows.map((ev) => ( + + ))} +
+
+ ); +} + +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 ( +
+
+ + {tier.label} + + + {event.summary || categoryLabel(event.category) || "(无摘要)"} + + {event.selfRelated ? 涉及她自己 : null} + {formatEventTime(event.createdAt)} +
+
+ + {event.platform} + {event.accountId ? `:${event.accountId}` : ""} + + + {categoryLabel(event.category)} + ({event.platformType || "—"}) + + + 行为者 {who} · {event.actorRole || "unknown"} + + {event.groupId ? ( + + 群 {event.groupId} + + ) : null} + {event.channelId && event.channelId !== event.groupId ? ( + + 会话 {event.channelId} + + ) : null} + {event.messageId ? ( + + 关联消息 {event.messageId} + + ) : null} +
+
+ ); +} diff --git a/frontend/web/src/admin/router.ts b/frontend/web/src/admin/router.ts index 36c684c..36dc6c0 100644 --- a/frontend/web/src/admin/router.ts +++ b/frontend/web/src/admin/router.ts @@ -18,6 +18,7 @@ export type AdminSectionId = | "memories" | "plugins" | "channels" + | "channel-events" | "iot" | "rag" | "llm" @@ -53,6 +54,7 @@ const VALID_SECTIONS: ReadonlySet = new Set([ "memories", "plugins", "channels", + "channel-events", "iot", "rag", "llm", diff --git a/frontend/web/src/admin/sections.tsx b/frontend/web/src/admin/sections.tsx index f195200..fa47615 100644 --- a/frontend/web/src/admin/sections.tsx +++ b/frontend/web/src/admin/sections.tsx @@ -21,6 +21,7 @@ export const ADMIN_SECTIONS: ReadonlyArray = [ { 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: "能力与连接" }, diff --git a/frontend/web/src/lib/api.ts b/frontend/web/src/lib/api.ts index dba2c72..c3eb569 100644 --- a/frontend/web/src/lib/api.ts +++ b/frontend/web/src/lib/api.ts @@ -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, diff --git a/frontend/web/src/lib/api/adminChannelEvents.ts b/frontend/web/src/lib/api/adminChannelEvents.ts new file mode 100644 index 0000000..499f86f --- /dev/null +++ b/frontend/web/src/lib/api/adminChannelEvents.ts @@ -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 { + 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(`/admin/channels/events${qs ? `?${qs}` : ""}`); +} + +/** 逐类计数汇总(近似值,响应带 `approx`)。`platform` 空 = 全部平台。 */ +export function adminChannelEventCounters(platform?: string): Promise { + const trimmed = platform?.trim(); + const qs = trimmed ? `?platform=${encodeURIComponent(trimmed)}` : ""; + return request(`/admin/channels/events/counters${qs}`); +} diff --git a/frontend/web/src/lib/types.ts b/frontend/web/src/lib/types.ts index 1132e75..6e62f6f 100644 --- a/frontend/web/src/lib/types.ts +++ b/frontend/web/src/lib/types.ts @@ -13,3 +13,4 @@ export * from "./types/adminWrite"; export * from "./types/kook"; export * from "./types/notification"; export * from "./types/configFacts"; +export * from "./types/channelEvents"; diff --git a/frontend/web/src/lib/types/channelEvents.ts b/frontend/web/src/lib/types/channelEvents.ts new file mode 100644 index 0000000..1f71d86 --- /dev/null +++ b/frontend/web/src/lib/types/channelEvents.ts @@ -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[]; +} diff --git a/internal/services/gateway/admin_channel_events.go b/internal/services/gateway/admin_channel_events.go new file mode 100644 index 0000000..ff3dc37 --- /dev/null +++ b/internal/services/gateway/admin_channel_events.go @@ -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 +} diff --git a/internal/services/gateway/admin_channel_events_store_test.go b/internal/services/gateway/admin_channel_events_store_test.go new file mode 100644 index 0000000..0414801 --- /dev/null +++ b/internal/services/gateway/admin_channel_events_store_test.go @@ -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) + } +} diff --git a/internal/services/gateway/admin_channel_events_test.go b/internal/services/gateway/admin_channel_events_test.go new file mode 100644 index 0000000..f811faf --- /dev/null +++ b/internal/services/gateway/admin_channel_events_test.go @@ -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) + } +} diff --git a/internal/services/gateway/admin_llm_usage.go b/internal/services/gateway/admin_llm_usage.go index 93d78e6..c185bbd 100644 --- a/internal/services/gateway/admin_llm_usage.go +++ b/internal/services/gateway/admin_llm_usage.go @@ -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 diff --git a/internal/services/gateway/contracts.go b/internal/services/gateway/contracts.go index 7cfbcb8..a719f5b 100644 --- a/internal/services/gateway/contracts.go +++ b/internal/services/gateway/contracts.go @@ -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) +} diff --git a/internal/services/gateway/deps.go b/internal/services/gateway/deps.go index 0c52f1e..1f9b4b3 100644 --- a/internal/services/gateway/deps.go +++ b/internal/services/gateway/deps.go @@ -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。 diff --git a/internal/services/gateway/rbac_viewer_test.go b/internal/services/gateway/rbac_viewer_test.go index 4fe8882..e557284 100644 --- a/internal/services/gateway/rbac_viewer_test.go +++ b/internal/services/gateway/rbac_viewer_test.go @@ -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"}, diff --git a/internal/services/gateway/routes.go b/internal/services/gateway/routes.go index 8c4d5c7..42d87d4 100644 --- a/internal/services/gateway/routes.go +++ b/internal/services/gateway/routes.go @@ -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 的监控投影。 diff --git a/internal/services/gateway/server.go b/internal/services/gateway/server.go index 1733ca3..c2c2a19 100644 --- a/internal/services/gateway/server.go +++ b/internal/services/gateway/server.go @@ -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,