refactor: 收口 BangDream 歌曲资源仓储

This commit is contained in:
sunlei 2026-06-07 01:21:03 +08:00
parent 286f57f63a
commit 4493174ad3
4 changed files with 303 additions and 46 deletions

View File

@ -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

View File

@ -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<string>;
publishedAt: Array<number | null>;
songId: number;
}
const difficultyNameById: Record<number, string> =
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<Record<string, any>> {
return await this.provider.getJson<Record<string, any>>(
`/api/songs/${songId}.json`,
);
}
/**
*
*
* @param songId - ID
* @param difficultyId - ID
*/
async getChart(songId: number, difficultyId: number): Promise<object> {
return await this.provider.getJson<object>(
`/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<Buffer> {
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();

View File

@ -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<number, string> =
BANGDREAM_DIFFICULTY_NAME_BY_ID;
@ -190,12 +185,7 @@ export class Song {
* Song
*/
async getData() {
const songData = await bangDreamBestdoriProvider.getJson<
Record<string, any>
>(
`/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<Image> {
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<object> {
const songChart = await bangDreamBestdoriProvider.getJson<object>(
`/api/charts/${this.songId}/${difficultyName[difficultyId]}.json`,
);
return songChart;
return await songResourceRepository.getChart(this.songId, difficultyId);
}
/*

View File

@ -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<BangDreamDataProvider> {
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> = {},
): 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 },
);
});
});