diff --git a/plugins/channels/weixin_ilink/login_reuse_test.go b/plugins/channels/weixin_ilink/login_reuse_test.go index dd37c28..e43aecc 100644 --- a/plugins/channels/weixin_ilink/login_reuse_test.go +++ b/plugins/channels/weixin_ilink/login_reuse_test.go @@ -1,7 +1,6 @@ package weixin_ilink import ( - "bytes" "context" "fmt" "log/slog" @@ -102,7 +101,7 @@ func TestStartLogin_WaitingPastWindowReMints(t *testing.T) { case "/ilink/bot/get_bot_qrcode": mintCalls++ return handlerResult{body: jsonBody(map[string]any{ - "qrcode": fmt.Sprintf("qr-w%d", mintCalls), + "qrcode": fmt.Sprintf("qr-w%d", mintCalls), "qrcode_img_content": fmt.Sprintf("https://img/w%d", mintCalls), })} case "/ilink/bot/get_qrcode_status": @@ -148,19 +147,30 @@ func TestStartLogin_WaitingPastWindowReMints(t *testing.T) { // TestLogin_LogRedaction 验证扫码登录路径只记录已脱敏的标识:可渲染的二维码 URL 与 // bot id 会出现,但原始 qrcode 标识符、bot_token 以及主人的 user_id 绝不写入。 +// +// ⚠️ 日志缓冲必须是**并发安全**的 syncBuffer(M56 修掉既有 DATA RACE):写侧是 +// StartLogin 起的登录 goroutine(completeLogin 里的 `login confirmed`),读侧是测试 +// goroutine 的 buf.String(),两侧之间没有 happens-before 边 —— 裸 bytes.Buffer 在 +// -race 下必报。等待条件也必须等**那一行日志**落盘,而不只是 ad.IsConnected(): +// completeLogin 先在 a.mu 下置 connected、**之后**才打日志,只等 IsConnected 会 +// 偶发地读到还没有 `login confirmed` 的缓冲(同一处竞态的另一种表现:抖动)。 func TestLogin_LogRedaction(t *testing.T) { t.Parallel() - var buf bytes.Buffer - statusCalls := 0 - srv := newJSONServer(t, nil, func(req recordedReq) handlerResult { + var buf syncBuffer + rec := &reqRecorder{} + // 用 reqRecorder 数状态轮询次数(它自带互斥),而不是在处理器里 += 一个裸 int: + // 处理器跑在 httptest 的服务端 goroutine 上,任何无保护的共享计数器都是同一类 + // 竞态的下一个落点。 + srv := newJSONServer(t, rec, func(req recordedReq) handlerResult { switch req.path { case "/ilink/bot/get_bot_qrcode": return handlerResult{body: jsonBody(map[string]any{ "qrcode": "qr-redact", "qrcode_img_content": "https://img/redact", })} case "/ilink/bot/get_qrcode_status": - statusCalls++ - if statusCalls == 1 { + // rec.add 在处理器被调用之前完成,所以这里数到的已包含本次请求。 + calls := len(rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/get_qrcode_status" })) + if calls == 1 { return handlerResult{body: jsonBody(map[string]any{"status": "scaned"})} } return handlerResult{body: jsonBody(map[string]any{ @@ -180,7 +190,9 @@ func TestLogin_LogRedaction(t *testing.T) { if _, err := ad.StartLogin(context.Background()); err != nil { t.Fatalf("StartLogin: %v", err) } - waitFor(t, func() bool { return ad.IsConnected() }) + waitFor(t, func() bool { + return ad.IsConnected() && strings.Contains(buf.String(), "login confirmed") + }) logs := buf.String() for _, want := range []string{ diff --git a/plugins/channels/weixin_ilink/send.go b/plugins/channels/weixin_ilink/send.go index dd4971c..8c20321 100644 --- a/plugins/channels/weixin_ilink/send.go +++ b/plugins/channels/weixin_ilink/send.go @@ -93,8 +93,9 @@ func (a *Adapter) sendGuard() error { // // **单条媒体的失败绝不连累文本**:媒体只是附件,回复的主体是文字。真机实测 // (2026-10-04)就是「媒体项构建失败 ⇒ 整条回复被判失败 ⇒ 她的中文文本一起没了」。 -// 因此这里逐条构建,失败的那条跳过并留一条带来源的 Warn(kind/name/原因),文本项 -// 照常发出;全部媒体都失败也仍然发文本。失败必须可诊断,但绝不等于整条回复失败。 +// 因此这里逐条构建,失败的那条跳过并留一条带来源的 Warn(kind/size/序号/原因), +// 文本项照常发出;全部媒体都失败也仍然发文本。失败必须可诊断,但绝不等于整条回复 +// 失败。 func (a *Adapter) sendToOne(ctx context.Context, peer string, m adapter.UnifiedMessage) error { if peer == "" { return ErrNoContextToken @@ -109,14 +110,26 @@ func (a *Adapter) sendToOne(ctx context.Context, peer string, m adapter.UnifiedM items = append(items, nativeTextItem(m.Content)) } var mediaErr error - for _, md := range m.Media { + for i, md := range m.Media { item, err := a.buildMediaItem(ctx, peer, md) if err != nil { if mediaErr == nil { mediaErr = err } + // 🔴 `md.Name` **整字段不进日志**(M56)。它是**用户侧自由文本**:出站媒体名 + // 来自调用方给的 adapter.Media.Name,入站侧就是用户自己起的文件名,可以是 + // 「身份证-张三.jpg」这类内容 —— 与同一行的 peer/kind/size 不是一类东西。 + // + // 为什么删字段、而不是打码或截断:包内可复用的打码(trace.RedactTextLimited) + // 是**按形状**认凭据的,对「长得像人话的文件名」完全无效;截断则必然留下前 N + // 个字符,而中文文件名的头几个字往往就是全部有意义的部分 —— 两种修法都只是 + // 把泄露面缩小一点,并没有关上。而这个字段对排障**零贡献**:失败原因在 err + // 里,出问题的是哪一条由 kind + size + 下标(index)唯一确定。删字段因此是 + // 严格更强、又不损失诊断力的选择。包内已有同一口径的先例:入站取证行 + // (inbound_debug.go 的 inboundMediaShape)同样明确「正文、文件名、URL 原串 + // 与一切密钥都不取」—— 日志面从不承载用户侧的正文/文件名,两个方向一致。 a.log.Warn("outbound media item skipped", - "peer", peer, "kind", md.Kind, "name", md.Name, "size", md.Size, "err", err) + "peer", peer, "kind", md.Kind, "size", md.Size, "index", i, "err", err) continue } items = append(items, item) diff --git a/plugins/channels/weixin_ilink/send_media_failure_test.go b/plugins/channels/weixin_ilink/send_media_failure_test.go index 87582ca..3b7f0a0 100644 --- a/plugins/channels/weixin_ilink/send_media_failure_test.go +++ b/plugins/channels/weixin_ilink/send_media_failure_test.go @@ -6,6 +6,9 @@ package weixin_ilink // 真机现场是她的中文文本本身完好,却因为一个媒体项构建失败而整条没发出去; // B3 getuploadurl 的请求必须带上官方字段、响应必须认官方键名,且「键缺失」 // 与「会话被拒」必须分开报(后者已有 markRejected 路径)。 +// +// M56 又追加了一条**日志内容**边界:失败告警里绝不出现 md.Name(它是用户侧自由 +// 文本),可诊断性改由 kind/size/index/err 承担。 import ( "bytes" @@ -117,8 +120,51 @@ func TestSendMessage_MediaFailureStillSendsText(t *testing.T) { if !strings.Contains(logs, "source="+source) { t.Fatalf("告警没有标注来源:\n%s", logs) } - if !strings.Contains(logs, "kind=file") || !strings.Contains(logs, "name=1.txt") { - t.Fatalf("告警缺少 kind/name:\n%s", logs) + if !strings.Contains(logs, "kind=file") || !strings.Contains(logs, "index=0") { + t.Fatalf("告警缺少 kind/序号(媒体名的位置由它们承担):\n%s", logs) + } +} + +// TestSendMessage_MediaFailureLogOmitsUserFileName 钉住失败告警的**内容边界**(M56): +// md.Name 是用户侧自由文本(入站侧就是用户自己起的文件名),所以失败日志里一个字都 +// 不留 —— 连截断后的前缀也不行,断言因此拿「文件名的开头」当哨兵,把「截断到 N 个 +// 字符」这类半吊子修法一起打红。可诊断性由 kind/size/index/err 承担,这里也逐个断言: +// 否则「把整行日志删掉」同样能让泄露消失。 +func TestSendMessage_MediaFailureLogOmitsUserFileName(t *testing.T) { + t.Parallel() + rec := &reqRecorder{} + srv := mediaSendServer(t, rec, handlerResult{body: jsonBody(map[string]any{})}) + var buf bytes.Buffer + ad := newLoggingSendAdapter(t, srv, &buf) + ad.recordInbound("tok-1", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: ad.clock()}) + + // 用户侧自由文本的典型形态:中文姓名 + 证件号。两者都不该进日志。 + const userFileName = "身份证正面-张三-110101199003071234.jpg" + payload := []byte("user-bytes") + if err := ad.SendMessage(context.Background(), adapter.UnifiedResponse{ + Messages: []adapter.UnifiedMessage{{ + SenderID: "u-1", + Content: "文本照发", + Media: []adapter.Media{{ + Kind: adapter.MediaFile, Data: payload, Name: userFileName, Size: int64(len(payload)), + }}, + }}, + }); err != nil { + t.Fatalf("媒体失败连累了整条回复:%v", err) + } + + logs := buf.String() + // 先确认告警本身还在:否则「没泄露」只是因为什么都没记。 + for _, want := range []string{"outbound media item skipped", "kind=file", "size=10", "index=0", "err="} { + if !strings.Contains(logs, want) { + t.Fatalf("失败告警缺少 %q(诊断力不能被一起删掉):\n%s", want, logs) + } + } + // 泄露面:整名、名字开头、证件号 —— 任一段出现即红。 + for _, leak := range []string{userFileName, "身份证", "110101199003071234"} { + if strings.Contains(logs, leak) { + t.Fatalf("用户文件名泄露 %q:\n%s", leak, logs) + } } }