diff --git a/plugins/channels/weixin_ilink/inbound_debug.go b/plugins/channels/weixin_ilink/inbound_debug.go new file mode 100644 index 0000000..cffffd7 --- /dev/null +++ b/plugins/channels/weixin_ilink/inbound_debug.go @@ -0,0 +1,291 @@ +package weixin_ilink + +// 本文件承载**入站媒体取证日志**(DEBUG),用来回答一个此前无从取证的问题: +// 「模型手里那个媒体地址,是平台给的,还是它自己编的?」 +// +// 事故背景(2026-10-05 真机):主人用微信发了一张图,回复内容准确(链路本身是通的), +// 但同一回合里模型额外调了一次 `vision`,传的地址是 `wechat-image.ilink.example/...` +// —— 一个解析不了的域名,调用以「解析主机失败」告终。全仓检索证明这个字面量**不在** +// 我们仓库里(只在 `data/cyrene.db-wal` 的那条错误里出现过),因此它只可能来自两处: +// 平台载荷,或模型自己编的。而当时**没有任何一条日志**能把这两者区分开。 +// +// 本文件补上这条取证,两个观测点: +// +// 1. **入站媒体形状**(`logInboundMediaShape`,从 convertMessage 调用):getupdates +// 投递的每一条消息留一行 —— 媒体**项数**、每项的 `type`、引用**来自哪个字段** +// (`media.full_url` / `media.encrypt_query_param` / 平铺猜测键)、引用所指的 +// **host**,以及载荷里**声明**的 mime/size。 +// 2. **取回结局**(`logInboundMediaFetch`,从 ilinkFetcher.Fetch 调用):成功记 +// host/类型/字节数(图片另记嗅探所得 mime);失败只记 host/类型/**错误类别**。 +// +// 有了这两行,同一次故障当场可分:模型编的地址在本文件里**一条形状行都不会有** +// (我们压根没收到过带那个 host 的媒体项);平台给的地址则带出 +// `host=<平台 CDN 主机>` 与 `ref=<平台字段名>` 的原话。 +// +// 脱敏口径(红线,与 `plugins/channels/qq_obv11/inbound_debug.go` 同一口径、 +// **不跨包引用**):只记结构,不记内容 —— 绝不记完整 URL、绝不记 query 里的 +// `encrypted_query_param` / `aes_key` / `bot_token` 的值、绝不记正文、绝不记响应体与 +// 错误原文。级别固定 DEBUG(默认 INFO 下行为与改造前完全一致,需要取证时把日志级别 +// 调到 debug),并经 `a.log` 打出(`source=plugins/channels/weixin_ilink`)。 +// +// 「非消息事件」取证:**该渠道不提供**。iLink 的 getupdates 只回一个消息流 `msgs` +// (外加 `get_updates_buf` 与 `longpolling_timeout_ms`),没有 OneBot 那样的 +// `post_type=meta_event` 心跳/生命周期流(见 DevDocs/reference/weixin_ClawBot_Bridge/ +// 对照清单.md 的协议要点:「传输是纯 HTTP/JSON + 长轮询」)。因此这里**不伪造**心跳行; +// 能做、也已经做的是:**msgs 里的每一条都留一行**(含 `message_type != 1` 的非用户 +// 消息 —— 此前会被 convertMessage 静默丢弃),于是「平台压根没推」与「推了但不是 +// 主人消息」这两件事仍然区分得开。 + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "net" + "net/url" + "strconv" + "strings" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" + "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/mediafetch" +) + +const ( + // inboundDebugMaxItems 是取证行里逐项展开的 item 上限。item_list 由对端构造, + // 不设上限时一条消息就能把日志撑成几万项 —— 超出部分只在 `items` 计数与 + // `truncated` 标记里体现。 + inboundDebugMaxItems = 16 + + // refNone 表示该媒体 item 没有任何可用的 CDN 引用。 + refNone = "none" + + // unknownRefHost 是引用解析不出主机名时的占位值(绝不退回记原串)。 + unknownRefHost = "未知来源" +) + +// 取回失败的**类别**标签。只记类别,不记错误原文:错误原文可能带上完整 URL +// (`*url.Error` 会把地址连同查询串拼进去),而响应体更是绝不能进日志。 +const ( + errCategoryDNS = "dns" + errCategoryHTTPStatus = "http_status" + errCategoryOriginDenied = "origin_not_allowed" + errCategoryTooLarge = "too_large" + errCategoryRedirect = "too_many_redirects" + errCategoryTimeout = "timeout" + errCategoryBadRef = "bad_reference" + errCategoryNoKey = "no_key" + errCategoryOther = "other" +) + +// logInboundMediaShape 打出一条入站消息的**媒体结构**(DEBUG)。它对每条成功解码的 +// getupdates 消息都打一行 —— 包括 `message_type != 1` 的非用户消息,否则「平台没推」 +// 与「推了但不是主人消息」又会变回一片空白。 +// +// 投影前先问一句 Enabled:DEBUG 关着时(生产默认)这条路径**一点解析工作都不做**, +// 「默认级别下行为与改造前完全一致」因此不只是「不输出」,而是「不算」。 +func (a *Adapter) logInboundMediaShape(rm *rawMsg) { + if rm == nil || !a.log.Enabled(context.Background(), slog.LevelDebug) { + return + } + types, refs := inboundMediaShape(a, rm.ItemList) + a.log.Debug("inbound media shape", + "message_type", rm.MessageType, + "items", len(rm.ItemList), + "item_types", types, + "media", refs, + "truncated", len(rm.ItemList) > inboundDebugMaxItems, + ) +} + +// inboundMediaShape 把 item_list **投影**成两份只含结构的切片:每项的类型标签,以及 +// 每个媒体项的「类型 + 引用字段 + 引用 host + 声明的 mime/size」摘要行。正文、文件名、 +// URL 原串与一切密钥都不取(解析出的 map 用完即弃)。 +func inboundMediaShape(a *Adapter, items []json.RawMessage) (types, refs []string) { + limit := len(items) + if limit > inboundDebugMaxItems { + limit = inboundDebugMaxItems + } + types = make([]string, 0, limit) + for i := 0; i < limit; i++ { + var item map[string]any + if err := json.Unmarshal(items[i], &item); err != nil { + types = append(types, "undecodable") + continue + } + types = append(types, itemKindLabel(intOf(item["type"]))) + if line := mediaShapeLine(a, item); line != "" { + refs = append(refs, line) + } + } + return types, refs +} + +// itemKindLabel 把平台 item 类型码翻成稳定短标签;未知码保留原值,便于遇到新类型时 +// 一眼看出平台到底标了什么。 +func itemKindLabel(kind int) string { + switch kind { + case itemText: + return "text" + case itemImage: + return "image" + case itemVoice: + return "voice" + case itemFile: + return "file" + case itemVideo: + return "video" + default: + return "other:" + strconv.Itoa(kind) + } +} + +// mediaShapeLine 渲染一个媒体 item 的结构摘要,例如 +// `image ref=media.encrypt_query_param host=novac2c.cdn.weixin.qq.com`。 +// 它只在载荷**确有**媒体引用时返回非空(纯文本 item 不产生行)。 +// +// host 取的是引用经 `cdnURL` 归一成绝对地址后的**主机名** —— 与取回路径同一条归一, +// 因此相对引用显示的是我们自己的 CDN 根主机,而绝对 `full_url` 显示的是平台/对端 +// 声称的那台主机。这两者的差别正是取证要看的。 +func mediaShapeLine(a *Adapter, item map[string]any) string { + ref := mediaRefOf(item) + if ref == "" { + return "" + } + // 声明的元数据回用 mediaOf 的同一解析口径(平铺 + 嵌套子对象扫描),不另写一份。 + meta := mediaOf(item, adapter.MediaKind("")) + line := itemKindLabel(intOf(item["type"])) + + " ref=" + mediaRefOrigin(item) + + " host=" + a.refHost(ref) + if meta.MIMEType != "" { + line += " declared_mime=" + meta.MIMEType + } + if meta.Size != 0 { + line += " declared_size=" + strconv.FormatInt(meta.Size, 10) + } + return line +} + +// mediaRefOrigin 报告引用的**来源字段名**,供取证分辨「平台给了完整地址」与「平台只 +// 给了加密查询参数、地址由我们拼」。分支顺序与 mediaRefOf 逐条对应,两者对同一 item +// 不可能给出矛盾答案(`TestMediaRefOriginMatchesMediaRefOf` 交叉验证这一点)。 +func mediaRefOrigin(item map[string]any) string { + if media := cdnMediaOf(item); media != nil { + if firstString(media, "full_url") != "" { + return "media.full_url" + } + if firstString(media, "encrypt_query_param") != "" { + return "media.encrypt_query_param" + } + } + if k := firstRefGuessKey(item); k != "" { + return "item." + k + } + for _, sub := range mediaSubObjects { + inner, ok := item[sub].(map[string]any) + if !ok { + continue + } + if k := firstRefGuessKey(inner); k != "" { + return sub + "." + k + } + } + return refNone +} + +// firstRefGuessKey 返回平铺/嵌套猜测键里第一个非空串的**键名**(键列表与 mediaRefOf +// 同源)。它只回键名,绝不回值 —— 值可能是带签名凭据的地址。 +func firstRefGuessKey(m map[string]any) string { + return firstStringKey(m, "url", "cdn_url", "download_url", "file_url", "cdn_ref") +} + +// firstStringKey 返回 keys 中第一个非空字符串所在的键名,全空时返回 ""。 +func firstStringKey(m map[string]any, keys ...string) string { + for _, k := range keys { + if s, ok := m[k].(string); ok && s != "" { + return k + } + } + return "" +} + +// refHost 返回一个媒体引用归一成绝对地址后的**主机名**(小写,不含端口)。它只回 +// 主机名,绝不回路径与查询串(`encrypt_query_param` 就是签名凭据)。解析不出来时给 +// 占位值。 +func (a *Adapter) refHost(ref string) string { + u, err := url.Parse(strings.TrimSpace(a.cdnURL(ref))) + if err != nil || u.Hostname() == "" { + return unknownRefHost + } + return strings.ToLower(u.Hostname()) +} + +// logInboundMediaFetch 打出一次入站媒体取回的**结局**(DEBUG)。成功记 host/类型/字节 +// 数与(图片才有的)嗅探 mime;失败只记 host/类型/**错误类别**。两类都不记 URL、查询串、 +// 响应体与错误原文。 +func (a *Adapter) logInboundMediaFetch(m adapter.Media, data []byte, err error) { + kind := mediaKindLabel(m.Kind) + host := a.refHost(m.URL) + if err != nil { + a.log.Debug("inbound media fetch failed", + "kind", kind, "host", host, "err_kind", fetchErrCategory(err)) + return + } + a.log.Debug("inbound media fetch ok", + "kind", kind, "host", host, "bytes", len(data), "mime", sniffedImageMIME(m, data)) +} + +// mediaKindLabel 给出媒体的类型标签(空 Kind 是「只给了消息级内容类型」的形状, +// 这里如实标 unknown,绝不猜)。 +func mediaKindLabel(kind adapter.MediaKind) string { + if kind == "" { + return "unknown" + } + return string(kind) +} + +// sniffedImageMIME 对图片类媒体返回字节嗅探所得的 MIME(非白名单光栅图为空串); +// 非图片类一律返回空串 —— 这个字段回答的是「取回来的到底是不是一张图」,不回答别的。 +func sniffedImageMIME(m adapter.Media, data []byte) string { + if m.Kind != adapter.MediaImage { + return "" + } + // 复用渠道侧取回路径的同一份裁决入口(mediafetch),不另写一份嗅探。 + if mime, ok := mediafetch.ImageMIMEOf(data); ok { + return mime + } + return "" +} + +// fetchErrCategory 把一次取回失败归类成一个**稳定标签**。用 errors.As/Is 走错误链, +// 绝不按错误文本匹配。分类必须排在别的哨兵之前的是 DNS:一次解析失败的内层错误是 +// `*net.DNSError`,它正是本次事故的那一类。 +func fetchErrCategory(err error) string { + if err == nil { + return "" + } + var dnsErr *net.DNSError + switch { + case errors.As(err, &dnsErr): + return errCategoryDNS + case errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled): + return errCategoryTimeout + case errors.Is(err, mediafetch.ErrOriginNotAllowed): + return errCategoryOriginDenied + case errors.Is(err, mediafetch.ErrTooManyRedirects): + return errCategoryRedirect + case errors.Is(err, mediafetch.ErrTooLarge): + return errCategoryTooLarge + case errors.Is(err, mediafetch.ErrBadStatus): + return errCategoryHTTPStatus + case errors.Is(err, mediafetch.ErrEmptyURL), + errors.Is(err, mediafetch.ErrNoHost), + errors.Is(err, mediafetch.ErrBadScheme): + return errCategoryBadRef + case errors.Is(err, ErrNoMediaKey), + errors.Is(err, ErrKeyNotAES128), + errors.Is(err, ErrKeyTooShort): + return errCategoryNoKey + default: + return errCategoryOther + } +} diff --git a/plugins/channels/weixin_ilink/inbound_debug_test.go b/plugins/channels/weixin_ilink/inbound_debug_test.go new file mode 100644 index 0000000..44475bb --- /dev/null +++ b/plugins/channels/weixin_ilink/inbound_debug_test.go @@ -0,0 +1,318 @@ +package weixin_ilink + +// 入站媒体取证日志(inbound_debug.go)的自证测试。断言分三类: +// - **记全了结构**:取证要看的那几个字段一个不少(否则日志等于白留); +// - **一个凭据/地址都没记**:查询串、`aes_key`、正文、完整 URL 都不得出现; +// - **级别留在 DEBUG**:默认 INFO 下不得出现。 +// +// 断言的落点是**渲染后的日志串**(TextHandler 真输出),不是属性表:「没往某个属性里 +// 写」离「日志里没有」还差一步 —— 凭据只要混进任何一个属性,只有看渲染结果才抓得到。 +// +// 末尾还有一条**交叉验证**:`mediaRefOrigin` 是 `mediaRefOf` 的取证投影,两者对同一 +// item 必须给出「有引用/没引用」一致的答案,否则日志会说谎。 + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "net" + "net/http" + "strconv" + "strings" + "testing" +) + +// debugCapture 返回渲染目标与一条把 DEBUG 也打出来的日志器(被测日志级别固定 DEBUG)。 +func debugCapture() (*bytes.Buffer, *slog.Logger) { + buf := &bytes.Buffer{} + return buf, slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})) +} + +// debugLines 摘出渲染后**消息等于 msg** 的日志行。 +func debugLines(buf *bytes.Buffer, msg string) []string { + var out []string + for _, line := range strings.Split(buf.String(), "\n") { + if strings.Contains(line, `msg="`+msg+`"`) { + out = append(out, line) + } + } + return out +} + +// newDebugAdapter 装配一个日志被捕获的适配器(client 可为 nil,本次只用转换/取回路径)。 +func newDebugAdapter(t *testing.T, cfg Config, log *slog.Logger, client *http.Client) *Adapter { + t.Helper() + a, err := New(cfg, client, log) + if err != nil { + t.Fatalf("New: %v", err) + } + return a +} + +// TestInboundMediaShapeLogIsStructuralOnly 用一条真机形状的入站消息(文本 + 图片 item, +// 引用在 `media.encrypt_query_param`)走转换路径:日志必须说清「几个 item、各是什么类型、 +// 引用来自哪个字段、host 是谁」,而查询串、`aes_key` 与正文一个都不许出现。 +func TestInboundMediaShapeLogIsStructuralOnly(t *testing.T) { + t.Parallel() + + const ( + body = "BODY-SENTINEL-9137" + query = "QUERY-SENTINEL-9137" + aesKey = "AESKEY-SENTINEL-9137" + cdnOrigin = "novac2c.cdn.weixin.qq.com" + ) + raw, err := json.Marshal(map[string]any{ + "client_id": "c-dbg", "from_user_id": "u-dbg", "message_type": msgTypeUser, + "item_list": []any{ + map[string]any{"type": itemText, "text_item": map[string]any{"text": body}}, + map[string]any{"type": itemImage, "image_item": map[string]any{ + "media": map[string]any{"encrypt_query_param": query, "aes_key": aesKey}, + }}, + }, + }) + if err != nil { + t.Fatalf("marshal fixture: %v", err) + } + + buf, log := debugCapture() + a := newDebugAdapter(t, Config{BaseURL: "https://ilink.invalid", CDNBaseURL: "https://" + cdnOrigin + "/c2c"}, log, nil) + if _, m := a.convertMessage(raw); m == nil { + t.Fatal("convertMessage 没有解析出消息") + } + + lines := debugLines(buf, "inbound media shape") + if len(lines) != 1 { + t.Fatalf("入站媒体形状日志行数 = %d, want 1\n全量日志:\n%s", len(lines), buf.String()) + } + line := lines[0] + t.Logf("渲染后的形状行: %s", line) + + for _, want := range []string{ + "level=DEBUG", + "source=plugins/channels/weixin_ilink", + "message_type=1", + "items=2", + `item_types="[text image]"`, + "ref=media.encrypt_query_param", + "host=" + cdnOrigin, + "truncated=false", + } { + if !strings.Contains(line, want) { + t.Errorf("入站媒体形状日志缺少结构字段 %q\n实际: %s", want, line) + } + } + + for _, forbidden := range []string{body, query, aesKey, "https://", "encrypted_query_param", "download?"} { + if strings.Contains(line, forbidden) { + t.Errorf("入站媒体形状日志泄露了 %q\n实际: %s", forbidden, line) + } + } + if all := buf.String(); strings.Contains(all, query) || strings.Contains(all, aesKey) || strings.Contains(all, body) { + t.Errorf("正文或凭据出现在日志里:\n%s", all) + } +} + +// TestInboundMediaFetchLogsOutcomeWithoutURL 走完整的取回路径(假 CDN 命中一次):成功行 +// 只记类型/host/字节数/嗅探 mime,绝不记查询串与完整地址。 +func TestInboundMediaFetchLogsOutcomeWithoutURL(t *testing.T) { + t.Parallel() + + key := keyOf(0x61) + s := newIlinkAssetServer(t, encrypted(t, key, pngBytes)) + buf, log := debugCapture() + a := newDebugAdapter(t, + Config{BaseURL: s.srv.URL, CDNBaseURL: s.srv.URL, BotToken: encodeToken(key)}, log, s.srv.Client()) + + _, m := a.convertMessage(imageItemRaw(t, map[string]any{ + "media": map[string]any{"encrypt_query_param": "QUERY-SENTINEL-9137"}, + })) + a.deliver(context.Background(), *m, nil) + if s.hits.Load() != 1 { + t.Fatalf("CDN 命中 = %d, want 1", s.hits.Load()) + } + + lines := debugLines(buf, "inbound media fetch ok") + if len(lines) != 1 { + t.Fatalf("取回结局日志行数 = %d, want 1\n全量日志:\n%s", len(lines), buf.String()) + } + line := lines[0] + for _, want := range []string{"level=DEBUG", "kind=image", "host=127.0.0.1", "bytes=" + strconv.Itoa(len(pngBytes)), "mime=image/png"} { + if !strings.Contains(line, want) { + t.Errorf("取回结局日志缺少字段 %q\n实际: %s", want, line) + } + } + for _, forbidden := range []string{"QUERY-SENTINEL-9137", "encrypted_query_param", s.srv.URL, "http://"} { + if strings.Contains(line, forbidden) { + t.Errorf("取回结局日志泄露了 %q\n实际: %s", forbidden, line) + } + } + + // 形状行同样只记 host,不记查询串。 + shapes := debugLines(buf, "inbound media shape") + if len(shapes) != 1 || !strings.Contains(shapes[0], "ref=media.encrypt_query_param host=127.0.0.1") { + t.Errorf("形状行未记下平台字段与 host:\n%s", strings.Join(shapes, "\n")) + } +} + +// TestInboundMediaFetchFailureLogsHostAndCategory 复现事故形状:取回的主机解析不了 +// (DNS)。日志必须给出**试过哪台主机**与**是哪一类失败**,且不带完整地址 ——「模型编的 +// 地址」正是靠「这个 host 在我们自己的形状行里出现过没有」来排除的。 +// +// 夹具刻意用事故里那个字面量主机;这里它由**我们自己配置的 CDN 根**给出,从而走完 +// 「形状行 + 取回失败行」两行日志,正是取证要看到的样子。 +func TestInboundMediaFetchFailureLogsHostAndCategory(t *testing.T) { + t.Parallel() + + const accidentHost = "wechat-image.ilink.example" + key := keyOf(0x62) + buf, log := debugCapture() + a := newDebugAdapter(t, Config{ + BaseURL: "https://ilink.invalid", + CDNBaseURL: "https://" + accidentHost + "/c2c", + BotToken: encodeToken(key), + }, log, dnsFailClient(accidentHost)) + + _, m := a.convertMessage(imageItemRaw(t, map[string]any{ + "media": map[string]any{"encrypt_query_param": "QUERY-SENTINEL-9137"}, + })) + a.deliver(context.Background(), *m, nil) + + lines := debugLines(buf, "inbound media fetch failed") + if len(lines) != 1 { + t.Fatalf("取回失败日志行数 = %d, want 1\n全量日志:\n%s", len(lines), buf.String()) + } + line := lines[0] + for _, want := range []string{"level=DEBUG", "kind=image", "host=" + accidentHost, "err_kind=dns"} { + if !strings.Contains(line, want) { + t.Errorf("取回失败日志缺少字段 %q\n实际: %s", want, line) + } + } + for _, forbidden := range []string{"QUERY-SENTINEL-9137", "encrypted_query_param", "https://", "download?"} { + if strings.Contains(line, forbidden) { + t.Errorf("取回失败日志泄露了 %q\n实际: %s", forbidden, line) + } + } +} + +// TestInboundMediaLogsStayAtDebugLevel 这两条日志必须留在 DEBUG:默认 INFO 下不得出现, +// 免得给正常运行加上每条消息一行的噪声(要取证时把日志级别调到 debug)。 +func TestInboundMediaLogsStayAtDebugLevel(t *testing.T) { + t.Parallel() + + buf := &bytes.Buffer{} + a, err := New(Config{BaseURL: "https://ilink.invalid"}, nil, slog.New(slog.NewTextHandler(buf, nil))) + if err != nil { + t.Fatalf("New: %v", err) + } + a.convertMessage(imageItemRaw(t, map[string]any{"media": map[string]any{"encrypt_query_param": "ENC"}})) + + if got := buf.String(); strings.Contains(got, "inbound media shape") { + t.Errorf("默认级别下不应出现入站媒体取证日志:\n%s", got) + } +} + +// TestInboundShapeLogCoversNonUserMessages 覆盖「非消息事件」的替代口径:该渠道没有 +// OneBot 那样的 heartbeat/meta_event 流,但 `message_type != 1` 的非用户消息此前会被 +// 静默丢弃 —— 取证行因此排在类型判定**之前**,把「推了但不是主人消息」也留痕。 +func TestInboundShapeLogCoversNonUserMessages(t *testing.T) { + t.Parallel() + + raw := jsonBody(map[string]any{ + "client_id": "c-bot", "from_user_id": "bot-1", "message_type": msgTypeUser + 1, + "item_list": []any{map[string]any{"type": itemText, "text_item": map[string]any{"text": "BODY-SENTINEL-9137"}}}, + }) + buf, log := debugCapture() + a := newDebugAdapter(t, Config{BaseURL: "https://ilink.invalid"}, log, nil) + + if _, m := a.convertMessage(raw); m != nil { + t.Fatal("非用户消息不该被转换成 UnifiedMessage") + } + lines := debugLines(buf, "inbound media shape") + if len(lines) != 1 || !strings.Contains(lines[0], "message_type=2") { + t.Fatalf("非用户消息没有留下取证行:\n%s", buf.String()) + } + if strings.Contains(lines[0], "BODY-SENTINEL-9137") { + t.Errorf("非用户消息的正文进了日志:\n%s", lines[0]) + } +} + +// TestInboundShapeLogCapsItemList 钉住逐项展开的上限:item_list 由对端构造,不能让它 +// 把一条日志撑成几万项;超出部分只体现在计数与 truncated 标记里。 +func TestInboundShapeLogCapsItemList(t *testing.T) { + t.Parallel() + + items := make([]any, 0, inboundDebugMaxItems+3) + for i := 0; i < inboundDebugMaxItems+3; i++ { + items = append(items, map[string]any{"type": itemText, "text_item": map[string]any{"text": "x"}}) + } + raw, err := json.Marshal(map[string]any{ + "client_id": "c-cap", "from_user_id": "u-cap", "message_type": msgTypeUser, "item_list": items, + }) + if err != nil { + t.Fatalf("marshal fixture: %v", err) + } + + buf, log := debugCapture() + a := newDebugAdapter(t, Config{BaseURL: "https://ilink.invalid"}, log, nil) + a.convertMessage(raw) + + lines := debugLines(buf, "inbound media shape") + if len(lines) != 1 { + t.Fatalf("入站媒体形状日志行数 = %d, want 1\n全量日志:\n%s", len(lines), buf.String()) + } + if !strings.Contains(lines[0], "items="+strconv.Itoa(inboundDebugMaxItems+3)) { + t.Errorf("计数应为真实项数\n实际: %s", lines[0]) + } + if !strings.Contains(lines[0], "truncated=true") { + t.Errorf("超限时应标 truncated=true\n实际: %s", lines[0]) + } + if got := strings.Count(lines[0], "text"); got != inboundDebugMaxItems { + t.Errorf("逐项展开的条数 = %d, want %d\n实际: %s", got, inboundDebugMaxItems, lines[0]) + } +} + +// TestMediaRefOriginMatchesMediaRefOf 交叉验证「取证投影」与「取回用的真身」:两者对同一 +// item 必须对「有没有引用」给出一致的答案。少了这条,mediaRefOrigin 一旦与 mediaRefOf +// 漂移,日志就会开始说谎,而故障现场恰恰只有它可看。 +func TestMediaRefOriginMatchesMediaRefOf(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + item map[string]any + }{ + {"无引用", map[string]any{"type": itemText}}, + {"media.full_url", map[string]any{"type": itemImage, + "image_item": map[string]any{"media": map[string]any{"full_url": "https://cdn.invalid/x.png"}}}}, + {"media.encrypt_query_param", map[string]any{"type": itemImage, + "image_item": map[string]any{"media": map[string]any{"encrypt_query_param": "E"}}}}, + {"平铺 url", map[string]any{"type": itemImage, "url": "https://cdn.invalid/y.png"}}, + {"嵌套 cdn_ref", map[string]any{"type": itemFile, "file_item": map[string]any{"cdn_ref": "r"}}}, + {"平铺 download_url", map[string]any{"type": itemVideo, "download_url": "d"}}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ref := mediaRefOf(tc.item) + origin := mediaRefOrigin(tc.item) + if (ref != "") != (origin != refNone) { + t.Fatalf("mediaRefOf = %q 但 mediaRefOrigin = %q(两者已经漂移)", ref, origin) + } + }) + } +} + +// roundTripFunc 让测试用一个函数冒充 RoundTripper(不触网)。 +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +// dnsFailClient 返回一个把每次请求都判成 DNS 解析失败的客户端:解析器给出的失败在 +// net/http 里正是 `*net.DNSError`,与真机上「解析主机失败」走出同一条错误链。 +func dnsFailClient(host string) *http.Client { + return &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, &net.DNSError{Err: "no such host", Name: host, IsNotFound: true} + })} +} diff --git a/plugins/channels/weixin_ilink/media.go b/plugins/channels/weixin_ilink/media.go index 4520233..ecdf880 100644 --- a/plugins/channels/weixin_ilink/media.go +++ b/plugins/channels/weixin_ilink/media.go @@ -138,6 +138,16 @@ type ilinkFetcher struct { // `mediafetch.DefaultTimeout`(**不是** `MaxMediaSize`:那是媒体通道自己的历史上限, // 与本切片的内联上限刻意解耦)。 func (f ilinkFetcher) Fetch(ctx context.Context, m adapter.Media) ([]byte, error) { + data, err := f.fetch(ctx, m) + // 结局取证(DEBUG,见 inbound_debug.go):取道一个出口,成功与每一种失败(含密钥 + // 缺失)都不可能漏记;它只记类型/host/字节数/错误类别,绝不记 URL 与错误原文。 + f.a.logInboundMediaFetch(m, data, err) + return data, err +} + +// fetch 是 Fetch 的实现体:按条密钥 → 白名单取回 → 剥 AES-ECB 层。它从 Fetch 拆出, +// 只为让上面那条结局取证恰好打一次。 +func (f ilinkFetcher) fetch(ctx context.Context, m adapter.Media) ([]byte, error) { key, err := f.inboundKey(m) if err != nil { return nil, err diff --git a/plugins/channels/weixin_ilink/poll.go b/plugins/channels/weixin_ilink/poll.go index ba32071..cf81188 100644 --- a/plugins/channels/weixin_ilink/poll.go +++ b/plugins/channels/weixin_ilink/poll.go @@ -193,6 +193,10 @@ func (a *Adapter) convertMessage(raw json.RawMessage) (string, *adapter.UnifiedM a.log.Warn("decode inbound message", "err", err) return "", nil } + // 结构取证(DEBUG,见 inbound_debug.go):它刻意排在 message_type 判定**之前**, + // 于是 `message_type != 1` 的非用户消息也留得下一行,「平台没推」与「推了但不是 + // 主人消息」才区分得开。 + a.logInboundMediaShape(&rm) if rm.MessageType != msgTypeUser { return "", nil }