Files
AskaEth 99a644d545 feat(channel): 微信 iLink 账号状态三态(诚实口径)
state ∈ 有效 / 被拒(需重扫)/ 未知;🔴 -14 是**被拒不是过期**,且**未知原因绝不写成「过期」**
(面板文案与错误码都如实)。三态判定为纯函数 + 三态各有用例。

⚠️ 未做(如实登记):每账号逐条三态(accounts[] 数组上的三态)本片未做。
2026-10-06 09:03:53 +08:00

338 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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` 行的线上投影。它现在是
// `events.ChannelEventJSON` 的**类型别名**(切片 E7):同一条事件有两条线上出口 ——
// 这里的 REST 列表与实时 `channel_event` 帧的 `data.event`。两个出口共用一份结构,
// 是「不许两处各写一份形状」这条纪律的落地(否则迟早出现「帧里有这个键、列表里
// 没有」这类无从察觉的差异)。
//
// 列与 `events.Event` 一一对应且不多不少:它没有、也不会有承载平台原文、
// 媒体 URL、凭据或 OneBot `flag` 的字段(§4.2 红线)。`summary` 是适配器生成的
// 固定文案(如「群成员加入」)或本包的固定迁移文案,**已净化**,查不出原始文本。
// `id` 可缺席(实时帧里那行的 id 尚未回读),列表路径上它恒有值。
type channelEventDTO = events.ChannelEventJSON
// 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 ev.JSON()
}
// 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
}