Skip to content

refactor(messaging/feishu): streaming SRP, SDK logger DRY, content pipeline duplication #509

@hrygo

Description

@hrygo

Background

internal/messaging/feishu/ 是飞书 WebSocket 适配器 + STT,包含 ~10 个 .go 源文件(~3000 行)。模块负责飞书事件分发、消息格式化、流式卡片更新、交互(权限/Q&A)、以及 SDK 日志桥接。Related: issue 500(messaging 父包 SOLID/DRY)。

Scope: solid, dry, coupling — cycle 149 (模块分析通过 1)
Key files: streaming.go, conn.go, sdk_logger.go, handler.go


Finding Summary

Category Critical High Medium Low
SOLID/SRP 0 0 1 0
DRY 0 0 2 1
Coupling 0 0 1 0
合计 0 0 4 1

Findings

SOLID/SRP

streaming-card-controller-srp

Severity: Medium | Confidence: High | ROI: Medium
Location: streaming.go:76-130, streaming.go:603-711

Problem: StreamingCardController(1091 行)管理两个独立关注点:(1) 内容累积 + flush 调度(Write/Flush/flushLoop),和 (2) 卡片生命周期状态机(phase transitions, EnsureCard, enableStreaming, Close)。Close 方法单独 108 行跨越 final flush、integrity check、streaming disable、header update。20+ 字段混合生命周期状态(phase, cardID, streamingActive)与内容状态(buf, lastFlushed, bufRunes)与交付配置(client, limiter, chatType)。

Current Pattern:

type StreamingCardController struct {
    phase     atomic.Int32    // lifecycle state
    cardID    string          // lifecycle state
    buf         strings.Builder  // content buffer
    lastFlushed string           // content buffer
    bufRunes    int              // content buffer
    client       *lark.Client    // delivery config
    limiter      *FeishuRateLimiter // delivery config
    // ... 12 more fields mixing concerns
}

Proposed Fix (低投入选项 — 拆分 Close):

func (c *StreamingCardController) Close(ctx context.Context) error {
    c.stopFlushLoop()
    c.MarkAllToolsDone()
    content := c.finalContent()
    c.finalFlush(ctx, content)
    c.disableStreamingAndFinalize(ctx, content)
    return nil
}

Acceptance Criteria:

  • Close 拆分为 3-4 个聚焦子方法(stopFlushLoop, finalFlush, disableStreamingAndFinalize)
  • 字段按关注点分组(lifecycle / content / delivery)添加注释分隔
  • make test 零回归

DRY

sdk-logger-boilerplate-dry

Severity: Medium | Confidence: High | ROI: Medium
Location: sdk_logger.go:75-102

Problem: SlogLogger 的 Debug/Info/Warn/Error 4 个方法体完全相同:sdkLogFilter(redactURL(fmt.Sprint(args...))) → 空 check → log。唯一差异是 slog level 常量。如果 filter 或 redaction 逻辑变更,4 处必须同步修改。

Current Pattern:

func (s SlogLogger) Debug(_ context.Context, args ...any) {
    msg := sdkLogFilter(redactURL(fmt.Sprint(args...)))
    if msg == "" { return }
    s.Logger.Log(context.Background(), slog.LevelDebug, msg)
}
func (s SlogLogger) Info(_ context.Context, args ...any) {
    msg := sdkLogFilter(redactURL(fmt.Sprint(args...)))
    if msg == "" { return }
    s.Logger.Log(context.Background(), slog.LevelInfo, msg)
}

Proposed Fix:

func (s SlogLogger) logf(level slog.Level, args ...any) {
    msg := sdkLogFilter(redactURL(fmt.Sprint(args...)))
    if msg == "" { return }
    s.Logger.Log(context.Background(), level, msg)
}

func (s SlogLogger) Debug(_ context.Context, args ...any) { s.logf(slog.LevelDebug, args...) }
func (s SlogLogger) Info(_ context.Context, args ...any)  { s.logf(slog.LevelInfo, args...) }
func (s SlogLogger) Warn(_ context.Context, args ...any)  { s.logf(slog.LevelWarn, args...) }
func (s SlogLogger) Error(_ context.Context, args ...any) { s.logf(slog.LevelError, args...) }

Acceptance Criteria:

  • 提取 logf 私有方法统一 4 个日志级别
  • Debug/Info/Warn/Error 各变为 1 行委托调用
  • SDK 日志输出行为不变

content-pipeline-dry

Severity: Medium | Confidence: High | ROI: Medium
Location: conn.go:349, conn.go:542-544, streaming.go:628

Problem: 3 步内容管道 OptimizeMarkdownStyle(SanitizeForCard(SanitizeText(content))) 在 3+ 调用点重复。正确的顺序(sanitize → optimize)是隐含契约,每个调用者必须记住。如果管道增加步骤或顺序变更,所有调用点必须同步更新,遗漏可能导致未净化内容发送到飞书卡片。

Current Pattern:

// conn.go:349
return c.sendOrReply(ctx, OptimizeMarkdownStyle(SanitizeForCard(messaging.SanitizeText(content))))
// conn.go:542
return c.adapter.replyMessage(ctx, replyToMsgID, OptimizeMarkdownStyle(SanitizeForCard(text)), false)
// conn.go:544
return c.adapter.sendTextMessage(ctx, chatID, OptimizeMarkdownStyle(SanitizeForCard(text)))

Proposed Fix:

func prepareCardContent(text string) string {
    return OptimizeMarkdownStyle(SanitizeForCard(messaging.SanitizeText(text)))
}

// Callers:
return c.sendOrReply(ctx, prepareCardContent(content))
return c.adapter.replyMessage(ctx, replyToMsgID, prepareCardContent(text), false)

Acceptance Criteria:

  • prepareCardContent 函数封装完整 3 步管道
  • 所有 OptimizeMarkdownStyle(SanitizeForCard(...)) 调用点替换
  • 添加测试:TestPrepareCardContent 验证管道顺序
  • make test 零回归

duplicate-stream-ctrl-accessor

Severity: Low | Confidence: High | ROI: High
Location: conn.go:99-108

Problem: FeishuConn 同时有 GetStreamCtrl()(exported)和 getStreamCtrl()(unexported)实现完全相同。handler.go 在同一包内调用 exported 版本,完全可以用 unexported 版本。

Current Pattern:

func (c *FeishuConn) GetStreamCtrl() *StreamingCardController {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return c.streamCtrl
}
func (c *FeishuConn) getStreamCtrl() *StreamingCardController {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return c.streamCtrl
}

Proposed Fix: 移除 GetStreamCtrl(),将 handler.go 调用改为 getStreamCtrl()

Acceptance Criteria:

  • 移除 GetStreamCtrl() 方法
  • handler.go 使用 getStreamCtrl() 替代
  • 无外部包引用 GetStreamCtrl()(grep 确认)

Coupling

feishu-conn-coupling-to-adapter

Severity: Medium | Confidence: Medium | ROI: Medium
Location: conn.go:17-37, conn.go:139-143, conn.go:502-510

Problem: FeishuConn 持有 adapter *Adapter 直接指针,深度访问 6+ 字段(larkClient, rateLimiter, Log, resolveBotName(), phrases)。创建双向依赖:Adapter 创建 FeishuConn,FeishuConn 反向访问 Adapter 内部。尤其体现在 resetStreamCtrl() 和 writeContent() 中构造新 StreamingCardController 时直接读取 adapter 字段。

Current Pattern:

// conn.go:502-510
newCtrl := NewStreamingCardController(
    c.adapter.larkClient, c.adapter.rateLimiter, c.adapter.Log,
    c.adapter.resolveBotName(), c.turnCount, c.lastBranch, c.workDir,
    c.adapter.phrases,
)

Proposed Fix: 定义 connDeps 结构体或接口,Adapter 在构造时注入依赖而非暴露自身。

Acceptance Criteria:

  • FeishuConn 依赖通过 connDeps struct 注入,不直接持有 *Adapter
  • conn.go 不再直接访问 c.adapter.XXX 字段
  • make test 零回归

Implementation Priority

Finding Priority Effort Risk Impact
duplicate-stream-ctrl-accessor P0 Small Low 2 行减少,消除混淆
sdk-logger-boilerplate-dry P1 Small Low ~15 行减少,统一日志路径
content-pipeline-dry P1 Small Low 消除隐含顺序契约,防止遗漏
streaming-card-controller-srp P2 Medium Medium Close 可读性改善
feishu-conn-coupling-to-adapter P3 Medium Medium 解耦 Conn↔Adapter

Recommended starting point: duplicate-stream-ctrl-accessor — 最小改动,消除明显的重复访问器


Out of Scope

  • StreamingCardController flushLoop time.After 泄漏 — 实际使用 time.NewTicker,无泄漏
  • permission keyword matching 提取到共享包 — Slack 使用 button-based,无跨适配器重复
  • OptimizeMarkdownStyle regex pipeline 简化 — 6-pass pipeline 有清晰注释,各有用途
  • sendPermissionRequest/sendQuestionRequest 卡片构建模式 — 各交互类型卡片结构差异大

Verification

  • make test 通过,无回归
  • go test -race ./internal/messaging/feishu/... 通过
  • 飞书消息收发和流式卡片更新行为不变

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3Medium: tech debt, refactoring, improvementsarchitectureDomain: design patterns, coupling, separation of concernsarea/messagingScope: Slack/Feishu adapters, platform adapterrefactorRefactor: DRY, SOLID, code quality improvements

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions