Skip to content

Commit 5a3f3fc

Browse files
committed
✨ feat(music-list): 实现音乐列表分页与流式展示功能
- 新增音乐列表分页协议,支持专辑/歌单/搜索结果翻页 - 重构音乐列表卡片流式展示逻辑,改为分批加载 - 将音乐列表卡片变量迁移到类型化模板变量 - 新增分页回调处理器,支持通过卡片按钮翻页 - 优化音乐列表卡片渲染性能,支持并发加载 📝 docs(architecture): 添加音乐列表分页架构设计文档 - 详细记录音乐列表分页、流式展示和类型化迁移的设计方案 - 包含模板变量协议、回调协议和测试策略说明 ♻️ refactor(card): 重构卡片消息发送逻辑 - 将卡片消息发送逻辑重构为类型化模板变量 - 新增支持获取消息ID的发送方法 - 优化卡片消息发送的性能和可维护性 ✅ test(music-list): 添加音乐列表分页测试 - 新增音乐列表分页功能单元测试 - 验证分页按钮状态和翻页逻辑 - 测试流式展示分批加载功能
1 parent 2883c87 commit 5a3f3fc

File tree

17 files changed

+1468
-220
lines changed

17 files changed

+1468
-220
lines changed
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
# Plan: Music List Pagination, Batch Reveal, and Typed Template Migration
2+
3+
**Generated**: 2026-03-12
4+
**Estimated Complexity**: Medium
5+
6+
## Overview
7+
完成音乐列表卡片的三项收口工作:
8+
1. 把当前“全量 loading 再逐项 patch”的流式体验改成“按批次出现”。
9+
2.`AlbumListTemplate` 增加分页协议,支持专辑/歌单/搜索结果翻页。
10+
3. 继续沿音乐域推进 typed template vars,避免继续用 `AddVariable(map[string]any)` 拼装模板变量。
11+
12+
本次范围只覆盖音乐列表相关卡片与其回调链路,不扩展到 schedule/config/permission 等无关模块。
13+
14+
## Assumptions
15+
- 用户会自行修改飞书 template,并接受新增分页按钮与页码展示区。
16+
- 列表卡使用同一个 template:`AlbumListTemplate`
17+
- 翻页行为使用卡片回调触发后端重新 patch 当前卡片,不新发消息。
18+
- 专辑/歌单/搜索结果的单页大小统一管理,默认值由后端控制。
19+
20+
## Prerequisites
21+
- 已有的音乐搜索与卡片回调链路可复用。
22+
- 已落地的 typed builder:`TemplateVersionV2[T]` / `NewCardContentWithData`
23+
- 已落地的兼容发送接口:`sendCompatibleCardWithMessageID(...)`
24+
25+
## Sprint 1: Batch Reveal Streaming
26+
**Goal**: 音乐列表不再先出现整页 loading,而是首批 ready 后发送,后续批次逐步追加。
27+
**Demo/Validation**:
28+
- 搜索歌曲/专辑/歌单时,卡片先出现第 1 批条目。
29+
- 后续条目按批次 append,而不是整页先显示“加载中”。
30+
- 当前卡片 message_id 不变,只做 patch。
31+
32+
### Task 1.1: 重写音乐列表流式策略
33+
- **Location**: `internal/infrastructure/neteaseapi/lark_card.go`
34+
- **Description**: 将 `StreamMusicListCard(...)` 从“先发全量骨架卡”改成“每批 resolve 完后再展示该批”。
35+
- **Dependencies**: 无
36+
- **Acceptance Criteria**:
37+
- 首次发送只包含首批已完成条目。
38+
- 后续每批完成后 patch 当前卡片,列表长度递增。
39+
- 不再向最终用户展示 `loading...` 占位行。
40+
- **Validation**:
41+
- `go test ./internal/infrastructure/neteaseapi ./internal/application/lark/handlers`
42+
- 手动验证 5 条搜索结果时,看到 `2 -> 4 -> 5` 或类似逐批增长。
43+
44+
### Task 1.2: 调整列表 renderer 的可见性状态
45+
- **Location**: `internal/infrastructure/neteaseapi/lark_card.go`
46+
- **Description**: 给内部行状态增加“是否可见”语义,只序列化已解锁批次的数据。
47+
- **Dependencies**: Task 1.1
48+
- **Acceptance Criteria**:
49+
- `Card(...)` 输出只包含当前可见条目。
50+
- 同步构卡路径 `BuildMusicListCard(...)` 仍输出全量内容。
51+
- **Validation**:
52+
- 新增/更新单测验证 visible lines 数量随批次增长。
53+
54+
## Sprint 2: Pagination Contract for AlbumListTemplate
55+
**Goal**: 为超长专辑/歌单结果提供翻页能力,并明确模板所需变量与 action payload 协议。
56+
**Demo/Validation**:
57+
- 卡片底部出现上一页/下一页按钮与页码文案。
58+
- 点击分页后 patch 当前卡片,内容切到目标页。
59+
- 歌曲播放按钮与分页按钮互不干扰。
60+
61+
### Task 2.1: 定义模板变量协议
62+
- **Location**: `internal/infrastructure/lark_dal/larkmsg/larktpl/template_card.go`
63+
- **Description**: 扩展音乐列表 typed vars,新增分页相关字段。
64+
- **Dependencies**: 无
65+
- **Acceptance Criteria**:
66+
- 新增以下变量名,供 template 使用:
67+
- `page_info_text`: 当前页文案,例如 `第 2 / 7 页`
68+
- `has_prev`: 是否可翻上一页
69+
- `has_next`: 是否可翻下一页
70+
- `prev_page_val`: 上一页按钮 payload
71+
- `next_page_val`: 下一页按钮 payload
72+
- 保留现有:`object_list_1``query`
73+
- **Validation**:
74+
- 新增单测校验 typed vars 能正常序列化。
75+
76+
### Task 2.2: 定义分页回调 payload 协议
77+
- **Location**: `pkg/cardaction/action.go`
78+
- **Description**: 增加音乐列表翻页 action 常量与字段约定。
79+
- **Dependencies**: Task 2.1
80+
- **Acceptance Criteria**:
81+
- 新增 action:`music.list_page`
82+
- payload 字段约定如下:
83+
- `action`: `music.list_page`
84+
- `type`: 搜索类型,值域 `song | album | playlist`
85+
- `keywords`: 搜索关键词;playlist 场景下仍传 playlistID
86+
- `page`: 目标页,从 `1` 开始
87+
- `page_size`: 当前页大小
88+
- `title`: 可选;playlist/album 用于卡片 query 展示
89+
- 统一使用 `cardaction.New(...).WithValue(...).Payload()` 构造
90+
- **Validation**:
91+
- 新增 action parser / payload builder 测试。
92+
93+
### Task 2.3: 输出给模板的推荐按钮绑定方式
94+
- **Location**: `docs/architecture/music-list-pagination-streaming-typed-plan.md` 与最终答复
95+
- **Description**: 给用户模板侧一份明确映射:
96+
- 上一页按钮绑定 `prev_page_val`
97+
- 下一页按钮绑定 `next_page_val`
98+
- 页码文本绑定 `page_info_text`
99+
- 按钮禁用态由 `has_prev` / `has_next` 控制
100+
- **Dependencies**: Task 2.2
101+
- **Acceptance Criteria**:
102+
- 模板改造不需要猜字段名。
103+
- 后端字段与模板字段一一对应。
104+
- **Validation**:
105+
- 由用户对照模板编辑器检查绑定项。
106+
107+
## Sprint 3: Pagination Handler and Data Slicing
108+
**Goal**: 点击分页后能基于上下文重新取数、切页并 patch 当前卡片。
109+
**Demo/Validation**:
110+
- 专辑/歌单的长列表可以翻页。
111+
- 页码切换不丢失 query/title。
112+
- 翻页只更新当前卡片,不新发消息。
113+
114+
### Task 3.1: 为音乐搜索增加分页取数入口
115+
- **Location**: `internal/application/lark/handlers/music_handler.go`, `internal/infrastructure/neteaseapi/lark_card.go`
116+
- **Description**: 抽出“列表结果 -> 指定页 card”的构造函数,支持 song/album/playlist 三类分页。
117+
- **Dependencies**: Sprint 2
118+
- **Acceptance Criteria**:
119+
- 统一入口输入:搜索类型、关键词、页码、页大小。
120+
- 输出:单页列表卡 typed vars。
121+
- query/title 展示与当前页匹配。
122+
- **Validation**:
123+
- 针对 song/album/playlist 新增分页单测。
124+
125+
### Task 3.2: 注册 `music.list_page` 回调
126+
- **Location**: `internal/application/lark/cardaction/builtin.go`
127+
- **Description**: 解析分页 payload,根据 `type/keywords/page/page_size/title` 重建目标页卡片并 patch 当前消息。
128+
- **Dependencies**: Task 3.1
129+
- **Acceptance Criteria**:
130+
- 回调后 patch `actionCtx.MessageID()`
131+
- 非法页码自动 clamp 到合法范围。
132+
- 错误时返回 toast,不 panic。
133+
- **Validation**:
134+
- 新增 cardaction 层测试。
135+
136+
### Task 3.3: 专辑/歌单列表分页切片
137+
- **Location**: `internal/infrastructure/neteaseapi/lark_card.go`, `internal/infrastructure/neteaseapi/netease.go`
138+
- **Description**: 对超长列表先拿全量结构化结果,再在 card 层做分页切片;避免模板层一次渲染过长列表。
139+
- **Dependencies**: Task 3.1
140+
- **Acceptance Criteria**:
141+
- 页面展示只渲染单页数据。
142+
- 页内条目仍复用现有歌曲播放/专辑查看按钮。
143+
- **Validation**:
144+
- 手动验证超长专辑/playlist 能翻页。
145+
146+
## Sprint 4: Continue Typed Migration in Music Scope
147+
**Goal**: 把音乐域剩余仍使用 `AddVariable(...)` 的 template 卡收口到 typed vars。
148+
**Demo/Validation**:
149+
- 音乐域 template 卡不再直接依赖裸 `map[string]any` 组装变量。
150+
151+
### Task 4.1: 完成音乐列表分页变量的 typed 化
152+
- **Location**: `internal/infrastructure/lark_dal/larkmsg/larktpl/template_card.go`
153+
- **Description**: 将分页字段放入 `MusicListCardVars`,不在调用方散落 `AddVariable(...)`
154+
- **Dependencies**: Sprint 2
155+
- **Acceptance Criteria**:
156+
- 音乐列表卡全部由 typed vars 驱动。
157+
- **Validation**:
158+
- 单测覆盖序列化结果。
159+
160+
### Task 4.2: 收口音乐域公共回复模板
161+
- **Location**: `internal/infrastructure/lark_dal/larkmsg/card.go`, `internal/infrastructure/lark_dal/larkmsg/larktpl/template_card.go`
162+
- **Description**: 把 `NormalCardReplyTemplate` 也纳入 typed vars 路径。
163+
- **Dependencies**: 无
164+
- **Acceptance Criteria**:
165+
- 不再用 `AddVariable("content", ...)`
166+
- **Validation**:
167+
- `go test ./internal/infrastructure/lark_dal/larkmsg`
168+
169+
## Testing Strategy
170+
- 单元测试:
171+
- typed vars 序列化
172+
- 分页 payload 构造
173+
- 列表 renderer 的 visible batch 递增
174+
- 页码 clamp / 边界页
175+
- 集成验证:
176+
- `/music 稻香`
177+
- `/music --type=album 范特西`
178+
- `/music --type=playlist 3778678`
179+
- 人工验证重点:
180+
- 首屏是否按批次出现
181+
- 翻页是否 patch 当前卡
182+
- 播放/查看专辑按钮是否仍可用
183+
184+
## Template Variable Contract
185+
建议你在 `AlbumListTemplate` 里新增以下绑定:
186+
- `object_list_1`: 当前页条目数组
187+
- `query`: 顶部查询文案
188+
- `page_info_text`: 页码展示,例如 `第 1 / 5 页`
189+
- `has_prev`: 上一页是否可点
190+
- `has_next`: 下一页是否可点
191+
- `prev_page_val`: 上一页按钮的 `value`
192+
- `next_page_val`: 下一页按钮的 `value`
193+
194+
建议两个分页按钮的 payload 形状完全一致,只是 `page` 不同:
195+
```json
196+
{
197+
"action": "music.list_page",
198+
"type": "album",
199+
"keywords": "范特西",
200+
"page": "2",
201+
"page_size": "10",
202+
"title": "[专辑] 范特西"
203+
}
204+
```
205+
206+
playlist 场景示例:
207+
```json
208+
{
209+
"action": "music.list_page",
210+
"type": "playlist",
211+
"keywords": "3778678",
212+
"page": "3",
213+
"page_size": "10",
214+
"title": "我喜欢的音乐"
215+
}
216+
```
217+
218+
## Potential Risks & Gotchas
219+
- `keywords` 对 playlist 场景其实是 playlistID,这个字段名语义偏弱,但它最兼容现有 handler。
220+
- 如果 template 侧按钮不支持直接 disabled,需要额外准备 `prev_button_type` / `next_button_type` 或隐藏逻辑。
221+
- 如果 album/playlist 数据量很大,全量拉取后再切页会增加一次后端内存占用,但当前规模通常可接受。
222+
- 如果后续要支持“上一页保留已加载缓存”,需要再加 message-level cache;本次先不做。
223+
- 当前全局 staticcheck 还有历史债,本次只要求新增/修改文件保持干净。
224+
225+
## Rollback Plan
226+
- 如分页 template 未及时改完,可先仅保留 batch reveal,不输出分页按钮变量。
227+
- 如分页回调不稳定,可暂时只开放第一页,保留现有播放/查看专辑行为。
228+
- typed vars 如发现模板字段名不一致,可局部回退到旧 `AddVariable(...)` 路径,不影响其他音乐卡。

internal/application/lark/card_handlers/handler.go

Lines changed: 25 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -143,17 +143,19 @@ func GetCardMusicByPage(ctx context.Context, musicID, page int) *larktpl.Templat
143143

144144
lyrics = strings.Join(newList, "\n")
145145

146-
return larktpl.NewCardContent(
147-
ctx,
148-
larktpl.SingleSongDetailTemplate,
149-
).
150-
AddVariable("lyrics", lyrics).
151-
AddVariable("title", songDetail.Name).
152-
AddVariable("sub_title", songDetail.Ar[0].Name).
153-
AddVariable("imgkey", map[string]any{"img_key": imageKey}).
154-
AddVariable("player_url", playerURL).
155-
AddVariable("full_lyrics_button", cardaction.New(cardaction.ActionMusicLyrics).WithID(strconv.Itoa(musicID)).Payload()).
156-
AddVariable("refresh_id", cardaction.New(cardaction.ActionMusicRefresh).WithID(strconv.Itoa(musicID)).Payload())
146+
return larktpl.NewCardContentWithData(ctx, larktpl.SingleSongDetailTemplate, &larktpl.SingleSongDetailCardVars{
147+
Lyrics: lyrics,
148+
Title: songDetail.Name,
149+
SubTitle: songDetail.Ar[0].Name,
150+
ImgKey: larktpl.ImageKeyRef{ImgKey: imageKey},
151+
PlayerURL: playerURL,
152+
FullLyricsButton: cardaction.New(cardaction.ActionMusicLyrics).
153+
WithID(strconv.Itoa(musicID)).
154+
Payload(),
155+
RefreshID: cardaction.New(cardaction.ActionMusicRefresh).
156+
WithID(strconv.Itoa(musicID)).
157+
Payload(),
158+
})
157159
}
158160

159161
func SendMusicCard(ctx context.Context, metaData *xhandler.BaseMetaData, musicID int, msgID string, page int) {
@@ -174,23 +176,12 @@ func SendAlbumCard(ctx context.Context, metaData *xhandler.BaseMetaData, albumID
174176
span.SetAttributes(attribute.Key("albumID").String(albumID))
175177
defer span.End()
176178

177-
albumDetails, err := neteaseapi.NetEaseGCtx.GetAlbumDetail(ctx, albumID)
178-
if err != nil {
179-
logs.L().Ctx(ctx).Error("Failed to get album detail", zap.Error(err))
180-
return
181-
}
182-
searchRes := neteaseapi.SearchMusic{Result: *albumDetails}
183-
184-
result, err := neteaseapi.NetEaseGCtx.AsyncGetSearchRes(ctx, searchRes)
185-
if err != nil {
186-
return
187-
}
188-
cardContent, err := neteaseapi.BuildMusicListCard(ctx,
189-
result,
190-
neteaseapi.MusicItemNoTrans,
191-
neteaseapi.CommentTypeSong,
192-
)
179+
cardContent, err := neteaseapi.BuildMusicListCardForRequest(ctx, neteaseapi.MusicListRequest{
180+
Scene: neteaseapi.MusicListSceneAlbumDetail,
181+
Query: albumID,
182+
})
193183
if err != nil {
184+
logs.L().Ctx(ctx).Error("Failed to build album detail card", zap.Error(err))
194185
return
195186
}
196187
accessor := appconfig.NewAccessorFromMeta(ctx, metaData)
@@ -223,15 +214,13 @@ func HandleFullLyrics(ctx context.Context, metaData *xhandler.BaseMetaData, musi
223214
left := strings.Join(sp[:mid], "\n")
224215
right := strings.Join(sp[mid:], "\n")
225216

226-
cardContent := larktpl.NewCardContent(
227-
ctx,
228-
larktpl.FullLyricsTemplate,
229-
).
230-
AddVariable("left_lyrics", left).
231-
AddVariable("right_lyrics", right).
232-
AddVariable("title", songDetail.Name).
233-
AddVariable("sub_title", songDetail.Ar[0].Name).
234-
AddVariable("imgkey", map[string]any{"img_key": imgKey})
217+
cardContent := larktpl.NewCardContentWithData(ctx, larktpl.FullLyricsTemplate, &larktpl.FullLyricsCardVars{
218+
LeftLyrics: left,
219+
RightLyrics: right,
220+
Title: songDetail.Name,
221+
SubTitle: songDetail.Ar[0].Name,
222+
ImgKey: larktpl.ImageKeyRef{ImgKey: imgKey},
223+
})
235224
accessor := appconfig.NewAccessorFromMeta(ctx, metaData)
236225
err = larkmsg.ReplyCard(ctx, cardContent, msgID, "_music", utils.GetIfInthread(ctx, metaData, accessor.MusicCardInThread()))
237226
if err != nil {

internal/application/lark/cardaction/builtin.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import (
1414
scheduleapp "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/lark/schedule"
1515
apppermission "github.com/BetaGoRobot/BetaGo-Redefine/internal/application/permission"
1616
"github.com/BetaGoRobot/BetaGo-Redefine/internal/infrastructure/lark_dal/larkmsg"
17+
"github.com/BetaGoRobot/BetaGo-Redefine/internal/infrastructure/lark_dal/larkmsg/larktpl"
18+
"github.com/BetaGoRobot/BetaGo-Redefine/internal/infrastructure/neteaseapi"
1719
cardactionproto "github.com/BetaGoRobot/BetaGo-Redefine/pkg/cardaction"
1820
"github.com/BetaGoRobot/BetaGo-Redefine/pkg/logs"
1921
"github.com/larksuite/oapi-sdk-go/v3/event/dispatcher/callback"
@@ -28,6 +30,7 @@ func RegisterBuiltins() {
2830
RegisterAsync(cardactionproto.ActionMusicAlbum, handleMusicAlbum)
2931
RegisterAsync(cardactionproto.ActionMusicLyrics, handleMusicLyrics)
3032
RegisterAsync(cardactionproto.ActionMusicRefresh, handleMusicRefresh)
33+
RegisterAsync(cardactionproto.ActionMusicListPage, handleMusicListPage)
3134
RegisterAsync(cardactionproto.ActionCardWithdraw, handleCardWithdraw)
3235
RegisterSync(cardactionproto.ActionCommandOpenForm, handleCommandOpenForm)
3336
RegisterAsync(cardactionproto.ActionCommandRefresh, handleCommandRefresh)
@@ -109,6 +112,56 @@ func handleMusicRefresh(ctx context.Context, actionCtx *Context) (AsyncTask, err
109112
}, nil
110113
}
111114

115+
func handleMusicListPage(ctx context.Context, actionCtx *Context) (AsyncTask, error) {
116+
scene, err := actionCtx.Action.RequiredString(cardactionproto.SceneField)
117+
if err != nil {
118+
return nil, err
119+
}
120+
query, err := actionCtx.Action.RequiredString(cardactionproto.QueryField)
121+
if err != nil {
122+
return nil, err
123+
}
124+
pageRaw, err := actionCtx.Action.RequiredString(cardactionproto.PageField)
125+
if err != nil {
126+
return nil, err
127+
}
128+
pageSizeRaw, err := actionCtx.Action.RequiredString(cardactionproto.PageSizeField)
129+
if err != nil {
130+
return nil, err
131+
}
132+
page, err := strconv.Atoi(pageRaw)
133+
if err != nil {
134+
return nil, fmt.Errorf("invalid page")
135+
}
136+
pageSize, err := strconv.Atoi(pageSizeRaw)
137+
if err != nil {
138+
return nil, fmt.Errorf("invalid page_size")
139+
}
140+
msgID := strings.TrimSpace(actionCtx.MessageID())
141+
if msgID == "" {
142+
return nil, fmt.Errorf("message id is required")
143+
}
144+
return func(runCtx context.Context) {
145+
neteaseapi.CancelMusicListStream(runCtx, msgID)
146+
err := neteaseapi.StreamMusicListCardForRequest(runCtx, neteaseapi.MusicListRequest{
147+
Scene: neteaseapi.MusicListScene(scene),
148+
Query: query,
149+
Page: page,
150+
PageSize: pageSize,
151+
}, func(sendCtx context.Context, cardContent *larktpl.TemplateCardContent) (string, error) {
152+
if err := larkmsg.PatchCard(sendCtx, cardContent, msgID); err != nil {
153+
return "", err
154+
}
155+
return msgID, nil
156+
}, func(patchCtx context.Context, patchMsgID string, cardContent *larktpl.TemplateCardContent) error {
157+
return larkmsg.PatchCard(patchCtx, cardContent, patchMsgID)
158+
})
159+
if err != nil {
160+
logs.L().Ctx(runCtx).Warn("stream music list page card failed", zap.String("message_id", msgID), zap.Error(err))
161+
}
162+
}, nil
163+
}
164+
112165
func handleCardWithdraw(ctx context.Context, actionCtx *Context) (AsyncTask, error) {
113166
return func(runCtx context.Context) {
114167
cardhandlers.HandleWithDraw(runCtx, actionCtx.Event)

internal/application/lark/cardaction/registry.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ func Dispatch(ctx context.Context, event *callback.CardActionTriggerEvent, metaD
8383
return nil, err
8484
}
8585
if task != nil {
86-
go task(ctx)
86+
go task(context.WithoutCancel(ctx))
8787
}
8888
return nil, nil
8989
case ModeSync:

0 commit comments

Comments
 (0)