Files
AskaEth 4c9cabce1b fix(channelbridge): 窗口准入落到车道头 + lane.go 按职责拆分(M23 复审 r1 P2)
P2-1(行为缺陷):窗口准入此前只在 prepareInbound 里算 `solo`,而 prepare 发生在
**窗口等待之后** —— 平台帧(action / 系统通知 / 我们自己的 message_sent 回执)照样
进窗、照样把滑动计时重置:一条回执就能把这一批的静默期一直续住(群友说了三句话,
她因为「撤回事件」迟迟不开口)。这与任务书「窗口只收 chat/image/voice/file」相反。

- `laneWindowAdmits(msg)` 成为**唯一准入判据**,只看结构化事实(ContentType +
  isSystemNoticeMessage,绝不解析正文);
- `splitWindowItems` 在窗口等待阶段把到达的条目分成「进窗口」与「不进窗口」两半;
- 非准入帧**不建窗口、不重置计时**(首条即到达即发车),窗口中途到达的只收进 solos,
  这一批发车后**原样回插队首**各自单跑(保序、不合并、不丢弃);
- prepareInbound 里的 `solo` 保留为第二道保险(适配器将来把平台事件标成 text 时,
  仍会在组批前被切出去)。

P2-2(硬约束):lane.go 682 行 → 按职责纯搬移成 5 个文件(最大 246 行):
lane.go(车道类型与注册表)/ lane_queue.go(入队与取队列)/
lane_worker.go(单消费者与回合槽)/ lane_drop.go(丢弃与停机诚实)/
lane_owner.go(主人私聊判据与车道键)。语义零改动:去注释后的声明集合
(34 个顶层声明,含函数体/字段/常量值)逐字节全等;既有用例一条未改。

顺带:把两处「IsOwner() && !IsGroupChat()」的注释改成代码真正用的判据
(ownerPrivateLane = !msg.IsGroup() && actor.IsOwner()),并记下这处 P3 分叉。

验证:全树 build/vet/test 全绿(73 包 ok);三包 -race 全绿;
新增 4 条准入用例在「准入失效」变异下三条必红、对照组保持绿。
2026-10-24 21:28:26 +08:00

109 lines
4.1 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 channelbridge
// 本文件承载车道的**单消费者**:一个会话一个 worker,串行跑完队列里的每一条。
//
// 「回合在跑」在结构上就等于「worker 正忙」—— M23 的回合中续跑因此不需要任何
// turn-in-flight 标志:回合结束回到循环头,takeAll 自然把这段期间攒下的消息整片取出。
//
// direct 车道(主人私聊)在这里按 `l.batcher == nil` 分派到 runDirectLaneItem:
// 到达即投递、一条一条跑,**没有**任何窗口等待。
import (
"context"
"time"
)
// laneWorker 是某会话车道的唯一执行者:串行跑完每一条,队列空了就把车道交还注册表
// (空闲车道不占内存,也不会把「这个人再说话」变慢)。
//
// **回合中续跑(⑤)就落在这里**:不存在任何 "turn in flight" 标志 —— 「回合在跑」
// 在结构上就等于「worker 正忙」,跑回合期间新消息落进同一个队列,回合结束回到循环头,
// takeAll 自然把这段期间攒下的消息整片取出 ⇒ **一轮续跑**(不是 N 轮,也不是 0 条)。
func (b *Bridge) laneWorker(ctx context.Context, l *lane) {
defer b.wg.Done()
for {
// 桥已停:队列里还没发车的条目**如实登记**(绝不静默消失),然后退休。
if ctx.Err() != nil {
b.abandonLaneAtStop(l)
b.retireLane(l)
return
}
if item, ok := b.nextLaneItem(l); ok {
b.dispatchLaneItem(ctx, l, item)
continue
}
// 队列空了才谈退休:摘表与入队用同一把锁,因此「摘下」与「刚有人塞进来」不会
// 交错成条目丢失(见 retireLaneIfIdle 的说明)。
if b.retireLaneIfIdle(l) {
return
}
select {
case <-ctx.Done():
// 桥停了:把车道摘掉,未处理的条目随进程取消一起作废(Stop 的语义)。
b.retireLane(l)
return
case item := <-l.ch:
b.dispatchLaneItem(ctx, l, item)
}
}
}
// dispatchLaneItem 按**车道类型**分派:direct(主人私聊)一条一条跑;batch 走窗口
// 引擎(见 coalesce_lane.go 的 runBatchLaneItem)。
//
// 分派依据是 `l.batcher == nil` —— 一个**结构**判据,不是配置开关:direct 车道的
// 类型里根本没有 batcher 字段值,窗口代码对它不可达。
func (b *Bridge) dispatchLaneItem(ctx context.Context, l *lane, item laneItem) {
if l.batcher == nil {
b.runDirectLaneItem(ctx, l, item)
return
}
b.runBatchLaneItem(ctx, l, item)
}
// runDirectLaneItem 跑 direct 车道(= 主人私聊)的一条:先取全局回合槽(**在线程池里
// 等,不在读循环里等**),再过陈旧度闸,然后走逐条闸门 + 单条回合。**没有任何等待窗口**。
func (b *Bridge) runDirectLaneItem(ctx context.Context, l *lane, item laneItem) {
if !b.acquireTurn(ctx) {
return
}
defer b.releaseTurn()
waited := b.now().Sub(item.arrivedAt)
if b.staleInbound(item, waited) {
return
}
b.logLaneStart(item, waited, 1)
p, ok := b.prepareInbound(ctx, item)
if !ok {
l.lastTurnEnd = b.now()
return
}
b.runBatch(ctx, []preparedInbound{p})
l.lastTurnEnd = b.now()
}
// acquireTurn 取一个全局回合槽(maxConcurrent 个)。ctx 取消时返回 false:桥停了
// 就不再开新回合(carried 条目由 Stop 的诚实登记负责,见 coalesce_lane.go)。
func (b *Bridge) acquireTurn(ctx context.Context) bool {
select {
case b.sem <- struct{}{}:
return true
case <-ctx.Done():
return false
}
}
// releaseTurn 归还一个全局回合槽。
func (b *Bridge) releaseTurn() { <-b.sem }
// logLaneStart 记「这一批等了多久才开始跑」—— 「她为什么回得晚」在生产日志里最直接
// 的那个答案。与它同时出现在日志里的 `msg="inbound frame"`(适配器的入站帧 DEBUG)
// 则说明读循环在等的同时一直在读:两行合起来就是「回合没有堵住心跳」的证据。
func (b *Bridge) logLaneStart(item laneItem, waited time.Duration, messages int) {
b.lanes.log.Debug("lane turn started",
"waited", waited.String(), "messages", messages,
"platform", item.msg.Platform, "channel", item.msg.ChannelID,
"sender", item.msg.SenderID, "group", item.msg.GroupID)
}