diff --git a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md index b4bc8df..cd05024 100644 --- a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md +++ b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md @@ -14,7 +14,7 @@ ## 当前事实 - Tsugu 源码目录:`src/qqbot/plugins/bangDream/tsugu` -- TS 文件:初始基线 92;当前 `tsugu` 源码 128 +- TS 文件:初始基线 92;当前 `tsugu` 源码 132 - 函数节点:481,其中稳定函数 410,匿名/内联回调 71 - 源码 JSDoc:稳定函数 410/410 已覆盖 - 变量声明:1896 @@ -298,6 +298,7 @@ export interface TsuguHook { - 已新增 `test/qqbot/plugins/bangDream/tsugu/data-provider.spec.ts`,覆盖 provider URL 解析、Bestdori/HHWX mock 数据源、retry/cache wrapper。 - 已新增 `cache-client-policy.spec.ts`,覆盖缓存客户端 retry/status/not-found 策略;`file-cache-client.spec.ts` 补 SVG 缺失资源 404 缓存,避免 SVG 素材缺失仍反复请求。 - 本地图片烟测已生成查歌 `136`、查活动 `50` 简易背景和真实活动背景图片,证明 provider/repository 第一段迁移后仍能输出非空图片。 +- 已新增 `models/song-resource-repository.ts`,把 `Song` 的歌曲详情、谱面、封面路径、封面完整 URL 和封面缺失时服务器回退下载收口到不依赖 `Song` 类的资源 repository,避免 `song.ts` 继续直接拼 Bestdori API/asset 路径;`song-resource-repository.spec.ts` 覆盖 detail/chart provider 调用、13/40 旧封面批次、273 强制 CN 和封面 fallback 行为,本地 `/查曲 136`、`/查谱面 136 expert` 图片 smoke 输出非空。 ### Phase 4:搜索 specification 和 matcher diff --git a/src/qqbot/plugins/bangDream/tsugu/models/song-resource-repository.ts b/src/qqbot/plugins/bangDream/tsugu/models/song-resource-repository.ts new file mode 100644 index 0000000..3ec6f47 --- /dev/null +++ b/src/qqbot/plugins/bangDream/tsugu/models/song-resource-repository.ts @@ -0,0 +1,192 @@ +import { assetErrorImageBuffer } from '@/qqbot/plugins/bangDream/tsugu/canvas/image-utils'; +import { bangDreamBestdoriProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/bestdori-provider'; +import type { BangDreamDataProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/data-provider'; +import { + BANGDREAM_DIFFICULTY_NAME_BY_ID, + BANGDREAM_SERVER_CODES, +} from '@/qqbot/plugins/bangDream/tsugu/models/bangdream-constants'; +import { + getServerByPriority, + Server, +} from '@/qqbot/plugins/bangDream/tsugu/models/server'; + +export interface SongJacketSource { + jacketImage: Array; + publishedAt: Array; + songId: number; +} + +const difficultyNameById: Record = + BANGDREAM_DIFFICULTY_NAME_BY_ID; + +/** + * 将服务器枚举值转换为 Bestdori 资源路径中的服务器编码。 + * + * @param server - 服务器枚举值。 + */ +function getServerCode(server: Server | undefined): string { + return server == null ? 'undefined' : Server[server]; +} + +export class SongResourceRepository { + constructor( + private readonly provider: BangDreamDataProvider = bangDreamBestdoriProvider, + ) {} + + /** + * 获取歌曲远端详情。 + * + * @param songId - 歌曲 ID。 + */ + async getDetail(songId: number): Promise> { + return await this.provider.getJson>( + `/api/songs/${songId}.json`, + ); + } + + /** + * 获取歌曲谱面数据。 + * + * @param songId - 歌曲 ID。 + * @param difficultyId - 难度 ID。 + */ + async getChart(songId: number, difficultyId: number): Promise { + return await this.provider.getJson( + `/api/charts/${songId}/${difficultyNameById[difficultyId]}.json`, + ); + } + + /** + * 获取歌曲封面资源批次。 + * + * @param songId - 歌曲 ID。 + */ + getSongRip(songId: number): number { + return Math.ceil(songId / 10) * 10; + } + + /** + * 获取歌曲封面资源路径。 + * + * @param source - 歌曲封面来源字段。 + * @param displayedServerList - 可展示服务器优先级。 + */ + getJacketImagePath( + source: SongJacketSource, + displayedServerList?: Server[], + ): string { + const { server, songRip } = this.getJacketServerAndRip( + source, + displayedServerList, + ); + const jacketImageName = this.getJacketImageName(source).toLowerCase(); + return this.buildJacketImagePath( + getServerCode(server), + songRip, + jacketImageName, + ); + } + + /** + * 获取歌曲封面完整 URL。 + * + * @param source - 歌曲封面来源字段。 + * @param displayedServerList - 可展示服务器优先级。 + */ + resolveJacketImageUrl( + source: SongJacketSource, + displayedServerList?: Server[], + ): string { + return this.provider.resolveUrl( + this.getJacketImagePath(source, displayedServerList), + ); + } + + /** + * 下载歌曲封面 Buffer,并在缺失时按服务器顺序回退。 + * + * @param source - 歌曲封面来源字段。 + * @param displayedServerList - 可展示服务器优先级。 + */ + async getJacketImageBuffer( + source: SongJacketSource, + displayedServerList: Server[] = [Server.jp, Server.cn], + ): Promise { + let jacketImageBuffer = await this.provider.getAsset( + this.getJacketImagePath(source, displayedServerList), + { memoryCache: false }, + ); + if (!jacketImageBuffer.equals(assetErrorImageBuffer)) { + return jacketImageBuffer; + } + + for (const retryPath of this.getFallbackJacketImagePaths(source)) { + jacketImageBuffer = await this.provider.getAsset(retryPath, { + ignoreError: true, + memoryCache: false, + retryCount: 1, + }); + if (!jacketImageBuffer.equals(assetErrorImageBuffer)) break; + } + return jacketImageBuffer; + } + + /** + * 计算歌曲封面优先服务器和资源批次。 + * + * @param source - 歌曲封面来源字段。 + * @param displayedServerList - 可展示服务器优先级。 + */ + private getJacketServerAndRip( + source: SongJacketSource, + displayedServerList?: Server[], + ): { server: Server | undefined; songRip: number } { + let server = getServerByPriority(source.publishedAt, displayedServerList); + let songRip = this.getSongRip(source.songId); + if (source.songId === 13 || source.songId === 40) { + songRip = 30; + } else if (source.songId === 273) { + server = Server.cn; + } + return { server, songRip }; + } + + /** + * 获取歌曲封面缺失时的服务器回退路径列表。 + * + * @param source - 歌曲封面来源字段。 + */ + private getFallbackJacketImagePaths(source: SongJacketSource): string[] { + const jacketImageName = this.getJacketImageName(source); + const songRip = this.getSongRip(source.songId); + return BANGDREAM_SERVER_CODES.map((serverCode) => + this.buildJacketImagePath(serverCode, songRip, jacketImageName), + ); + } + + /** + * 拼接歌曲封面资源路径。 + * + * @param serverCode - Bestdori 服务器编码。 + * @param songRip - 歌曲资源批次。 + * @param jacketImageName - 封面资源名称。 + */ + private buildJacketImagePath( + serverCode: string, + songRip: number, + jacketImageName: string, + ): string { + return `/assets/${serverCode}/musicjacket/musicjacket${songRip}_rip/assets-star-forassetbundle-startapp-musicjacket-musicjacket${songRip}-${jacketImageName}-jacket.png`; + } + + /** + * 获取歌曲封面资源名称。 + * + * @param source - 歌曲封面来源字段。 + */ + private getJacketImageName(source: SongJacketSource): string { + return source.jacketImage[source.jacketImage.length - 1]; + } +} + +export const songResourceRepository = new SongResourceRepository(); diff --git a/src/qqbot/plugins/bangDream/tsugu/models/song.ts b/src/qqbot/plugins/bangDream/tsugu/models/song.ts index 0c75452..5e079c7 100644 --- a/src/qqbot/plugins/bangDream/tsugu/models/song.ts +++ b/src/qqbot/plugins/bangDream/tsugu/models/song.ts @@ -1,19 +1,14 @@ import { Image, loadImage } from 'skia-canvas'; -import { bangDreamBestdoriProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/bestdori-provider'; -import { - getServerByPriority, - Server, -} from '@/qqbot/plugins/bangDream/tsugu/models/server'; +import { Server } from '@/qqbot/plugins/bangDream/tsugu/models/server'; import { bangDreamMainDataRepository } from '@/qqbot/plugins/bangDream/tsugu/models/main-data-repository'; import { stringToNumberArray } from '@/qqbot/plugins/bangDream/tsugu/models/model-utils'; -import { assetErrorImageBuffer } from '@/qqbot/plugins/bangDream/tsugu/canvas/image-utils'; import { BANGDREAM_DIFFICULTY_COLORS, BANGDREAM_DIFFICULTY_NAME_BY_ID, BANGDREAM_DIFFICULTY_NAMES, - BANGDREAM_SERVER_CODES, BANGDREAM_SONG_TAG_NAME, } from '@/qqbot/plugins/bangDream/tsugu/models/bangdream-constants'; +import { songResourceRepository } from '@/qqbot/plugins/bangDream/tsugu/models/song-resource-repository'; export const difficultyName: Record = BANGDREAM_DIFFICULTY_NAME_BY_ID; @@ -190,12 +185,7 @@ export class Song { * 在 Song 模型中请求当前模型的远端详情数据。 */ async getData() { - const songData = await bangDreamBestdoriProvider.getJson< - Record - >( - `/api/songs/${this.songId}.json`, - ); - return songData; + return await songResourceRepository.getDetail(this.songId); } /** * 在 Song 模型中获取歌曲资源批次。 @@ -203,7 +193,7 @@ export class Song { * @returns 计算后的数值。 */ getSongRip(): number { - return Math.ceil(this.songId / 10) * 10; + return songResourceRepository.getSongRip(this.songId); } /** * 在 Song 模型中获取歌曲封面图片。 @@ -214,22 +204,10 @@ export class Song { async getSongJacketImage( displayedServerList: Server[] = [Server.jp, Server.cn], ): Promise { - let jacketImageBuffer = await bangDreamBestdoriProvider.getAsset( - this.getSongJacketImagePath(displayedServerList), - { memoryCache: false }, + const jacketImageBuffer = await songResourceRepository.getJacketImageBuffer( + this, + displayedServerList, ); - //下载失败自动尝试切换服务器下载 - if (jacketImageBuffer.equals(assetErrorImageBuffer)) { - const jacketImageName = this.jacketImage[this.jacketImage.length - 1]; - for (const server of BANGDREAM_SERVER_CODES) { - const retryPath = `/assets/${server}/musicjacket/musicjacket${this.getSongRip()}_rip/assets-star-forassetbundle-startapp-musicjacket-musicjacket${this.getSongRip()}-${jacketImageName}-jacket.png`; - jacketImageBuffer = await bangDreamBestdoriProvider.getAsset( - retryPath, - { ignoreError: true, memoryCache: false, retryCount: 1 }, - ); - if (!jacketImageBuffer.equals(assetErrorImageBuffer)) break; - } - } return await loadImage(jacketImageBuffer); } /** @@ -239,8 +217,9 @@ export class Song { * @returns 格式化后的文本。 */ getSongJacketImageURL(displayedServerList?: Server[]): string { - return bangDreamBestdoriProvider.resolveUrl( - this.getSongJacketImagePath(displayedServerList), + return songResourceRepository.resolveJacketImageUrl( + this, + displayedServerList, ); } /** @@ -250,16 +229,7 @@ export class Song { * @returns 格式化后的资源路径。 */ getSongJacketImagePath(displayedServerList?: Server[]): string { - let server = getServerByPriority(this.publishedAt, displayedServerList); - const jacketImageName = this.jacketImage[this.jacketImage.length - 1]; - let songRip = this.getSongRip(); - if (this.songId == 13 || this.songId == 40) { - songRip = 30; - } else if (this.songId == 273) { - //针对273的修复 - server = Server.cn; - } - return `/assets/${Server[server]}/musicjacket/musicjacket${songRip}_rip/assets-star-forassetbundle-startapp-musicjacket-musicjacket${songRip}-${jacketImageName.toLowerCase()}-jacket.png`; + return songResourceRepository.getJacketImagePath(this, displayedServerList); } /** * 在 Song 模型中获取Tag名称。 @@ -279,10 +249,7 @@ export class Song { * @returns 异步处理结果。 */ async getSongChart(difficultyId: number): Promise { - const songChart = await bangDreamBestdoriProvider.getJson( - `/api/charts/${this.songId}/${difficultyName[difficultyId]}.json`, - ); - return songChart; + return await songResourceRepository.getChart(this.songId, difficultyId); } /* diff --git a/test/qqbot/plugins/bangDream/tsugu/song-resource-repository.spec.ts b/test/qqbot/plugins/bangDream/tsugu/song-resource-repository.spec.ts new file mode 100644 index 0000000..888462e --- /dev/null +++ b/test/qqbot/plugins/bangDream/tsugu/song-resource-repository.spec.ts @@ -0,0 +1,97 @@ +import { assetErrorImageBuffer } from '@/qqbot/plugins/bangDream/tsugu/canvas/image-utils'; +import type { BangDreamDataProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/data-provider'; +import { Server } from '@/qqbot/plugins/bangDream/tsugu/models/server'; +import { + SongResourceRepository, + type SongJacketSource, +} from '@/qqbot/plugins/bangDream/tsugu/models/song-resource-repository'; + +function createProviderMock(): jest.Mocked { + return { + getAsset: jest.fn(), + getJson: jest.fn(), + getTracker: jest.fn(), + name: 'MockBestdori', + resolveUrl: jest.fn((pathOrUrl) => `https://bestdori.example${pathOrUrl}`), + }; +} + +function createSongSource( + overrides: Partial = {}, +): SongJacketSource { + return { + jacketImage: ['SummerFest'], + publishedAt: [100, null, null, 200, null], + songId: 136, + ...overrides, + }; +} + +describe('BangDream song resource repository', () => { + it('routes song detail and chart requests through the provider', async () => { + const provider = createProviderMock(); + provider.getJson.mockResolvedValue({ ok: true }); + const repository = new SongResourceRepository(provider); + + await expect(repository.getDetail(136)).resolves.toEqual({ ok: true }); + await expect(repository.getChart(136, 3)).resolves.toEqual({ ok: true }); + + expect(provider.getJson).toHaveBeenNthCalledWith(1, '/api/songs/136.json'); + expect(provider.getJson).toHaveBeenNthCalledWith( + 2, + '/api/charts/136/expert.json', + ); + }); + + it('builds jacket paths from server priority and legacy song exceptions', () => { + const repository = new SongResourceRepository(createProviderMock()); + + expect( + repository.getJacketImagePath(createSongSource(), [Server.cn, Server.jp]), + ).toBe( + '/assets/cn/musicjacket/musicjacket140_rip/assets-star-forassetbundle-startapp-musicjacket-musicjacket140-summerfest-jacket.png', + ); + expect( + repository.getJacketImagePath( + createSongSource({ + publishedAt: [100, null, null, null, null], + songId: 13, + }), + [Server.jp], + ), + ).toContain('/assets/jp/musicjacket/musicjacket30_rip/'); + expect( + repository.getJacketImagePath( + createSongSource({ + publishedAt: [100, null, null, null, null], + songId: 273, + }), + [Server.jp], + ), + ).toContain('/assets/cn/musicjacket/musicjacket280_rip/'); + }); + + it('falls back through server jacket paths when the primary asset is missing', async () => { + const provider = createProviderMock(); + const validImage = Buffer.from('valid'); + provider.getAsset + .mockResolvedValueOnce(assetErrorImageBuffer) + .mockResolvedValueOnce(validImage); + const repository = new SongResourceRepository(provider); + + await expect( + repository.getJacketImageBuffer(createSongSource(), [Server.cn]), + ).resolves.toBe(validImage); + + expect(provider.getAsset).toHaveBeenNthCalledWith( + 1, + '/assets/cn/musicjacket/musicjacket140_rip/assets-star-forassetbundle-startapp-musicjacket-musicjacket140-summerfest-jacket.png', + { memoryCache: false }, + ); + expect(provider.getAsset).toHaveBeenNthCalledWith( + 2, + '/assets/jp/musicjacket/musicjacket140_rip/assets-star-forassetbundle-startapp-musicjacket-musicjacket140-SummerFest-jacket.png', + { ignoreError: true, memoryCache: false, retryCount: 1 }, + ); + }); +});