refactor: 收口 BangDream 卡牌资源仓储

This commit is contained in:
sunlei 2026-06-07 01:33:13 +08:00
parent 4493174ad3
commit 742dae2349
4 changed files with 238 additions and 45 deletions

View File

@ -14,7 +14,7 @@
## 当前事实
- Tsugu 源码目录:`src/qqbot/plugins/bangDream/tsugu`
- TS 文件:初始基线 92当前 `tsugu` 源码 132
- TS 文件:初始基线 92当前 `tsugu` 源码 133
- 函数节点481其中稳定函数 410匿名/内联回调 71
- 源码 JSDoc稳定函数 410/410 已覆盖
- 变量声明1896
@ -299,6 +299,8 @@ export interface TsuguHook {
- 已新增 `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 输出非空。
- 已新增 `models/card-resource-repository.ts`,把 `Card` 的详情请求、资源批次目录、图标/插画/trim 图片路径和重图缓存策略收口到 provider-backed repository`card-resource-repository.spec.ts` 覆盖 `/api/cards` 缓存参数、`9999` 后资源批次、CN 优先资源路径和非 icon 图片 `memoryCache=false`,本地 `/查卡 472` 图片 smoke 输出非空。
- 固化Windows 下不要用反斜杠测试路径直接调用 Jest pattern容易出现 `Pattern ... - 0 matches`;指定文件测试统一使用 `pnpm exec jest --runInBand --runTestsByPath test/qqbot/plugins/bangDream/tsugu/<file>.spec.ts ...`,路径用正斜杠。
### Phase 4搜索 specification 和 matcher

View File

@ -0,0 +1,113 @@
import { bangDreamBestdoriProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/bestdori-provider';
import type { BangDreamDataProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/data-provider';
import { formatNumber } from '@/qqbot/plugins/bangDream/tsugu/models/model-utils';
import {
getServerByPriority,
Server,
} from '@/qqbot/plugins/bangDream/tsugu/models/server';
import { globalDefaultServer } from '@/qqbot/plugins/bangDream/tsugu/runtime/config';
export interface CardResourceSource {
cardId: number;
releasedAt: Array<number | null>;
resourceSetName: string;
}
export type CardImageType = 'icon' | 'illustration' | 'trim';
/**
*
*
* @param trainingStatus -
*/
function toTrainingSuffix(trainingStatus: boolean): string {
return trainingStatus ? '_after_training' : '_normal';
}
/**
* Bestdori
*
* @param server -
*/
function toServerCode(server: Server | undefined): string {
return server == null ? 'undefined' : Server[server];
}
export class CardResourceRepository {
constructor(
private readonly provider: BangDreamDataProvider = bangDreamBestdoriProvider,
) {}
/**
*
*
* @param cardId - ID
* @param update -
*/
async getDetail(
cardId: number,
update: boolean = true,
): Promise<Record<string, any>> {
return await this.provider.getJson<Record<string, any>>(
`/api/cards/${cardId}.json`,
{ cacheTime: update ? 0 : 1 / 0 },
);
}
/**
*
*
* @param cardId - ID
*/
getRip(cardId: number): string {
if (cardId >= 9999) return '200_rip';
const cardResourceSetId = Math.floor(cardId / 50);
return `${formatNumber(cardResourceSetId, 3)}_rip`;
}
/**
*
*
* @param source -
* @param imageType -
* @param trainingStatus - 使
* @param displayedServerList -
*/
getImagePath(
source: CardResourceSource,
imageType: CardImageType,
trainingStatus: boolean,
displayedServerList: Server[] = globalDefaultServer,
): string {
const serverCode = toServerCode(
getServerByPriority(source.releasedAt, displayedServerList),
);
const trainingSuffix = toTrainingSuffix(trainingStatus);
if (imageType === 'icon') {
return `/assets/${serverCode}/thumb/chara/card00${this.getRip(source.cardId)}/${source.resourceSetName}${trainingSuffix}.png`;
}
const fileName =
imageType === 'trim' ? `trim${trainingSuffix}` : `card${trainingSuffix}`;
return `/assets/${serverCode}/characters/resourceset/${source.resourceSetName}_rip/${fileName}.png`;
}
/**
*
*
* @param source -
* @param imageType -
* @param trainingStatus - 使
*/
async getImageBuffer(
source: CardResourceSource,
imageType: CardImageType,
trainingStatus: boolean,
): Promise<Buffer> {
return await this.provider.getAsset(
this.getImagePath(source, imageType, trainingStatus),
imageType === 'icon' ? undefined : { memoryCache: false },
);
}
}
export const cardResourceRepository = new CardResourceRepository();

View File

@ -8,14 +8,11 @@ import {
import { Gacha } from '@/qqbot/plugins/bangDream/tsugu/models/gacha';
import { Event } from '@/qqbot/plugins/bangDream/tsugu/models/event';
import { Image, loadImage } from 'skia-canvas';
import { bangDreamBestdoriProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/bestdori-provider';
import { bangDreamMainDataRepository } from '@/qqbot/plugins/bangDream/tsugu/models/main-data-repository';
import { globalDefaultServer } from '@/qqbot/plugins/bangDream/tsugu/runtime/config';
import {
stringToNumberArray,
formatNumber,
} from '@/qqbot/plugins/bangDream/tsugu/models/model-utils';
import { stringToNumberArray } from '@/qqbot/plugins/bangDream/tsugu/models/model-utils';
import { BANGDREAM_CARD_TYPE_NAME } from '@/qqbot/plugins/bangDream/tsugu/models/bangdream-constants';
import { cardResourceRepository } from '@/qqbot/plugins/bangDream/tsugu/models/card-resource-repository';
export interface Stat {
//综合力
@ -177,14 +174,7 @@ export class Card {
* @param update - update参数使
*/
async getData(update: boolean = true) {
const time = update ? 0 : 1 / 0;
const cardData = await bangDreamBestdoriProvider.getJson<
Record<string, any>
>(
`/api/cards/${this.cardId}.json`,
{ cacheTime: time },
);
return cardData;
return await cardResourceRepository.getDetail(this.cardId, update);
}
/**
@ -366,15 +356,7 @@ export class Card {
* @returns
*/
getRip(): string {
let cardResourceSetId: string;
if (this.cardId < 9999) {
//确定目录位置50为一组
const cardResourceSetIdNumber: number = Math.floor(this.cardId / 50);
cardResourceSetId = formatNumber(cardResourceSetIdNumber, 3);
} else {
cardResourceSetId = '200';
}
return cardResourceSetId + '_rip';
return cardResourceRepository.getRip(this.cardId);
}
/**
* Card
@ -384,10 +366,10 @@ export class Card {
*/
async getCardIconImage(trainingStatus: boolean): Promise<Image> {
trainingStatus = this.ableToTraining(trainingStatus);
const trainingString = trainingStatus ? '_after_training' : '_normal';
const tempServer = this.getFirstReleasedServer();
const cardIconImageBuffer = await bangDreamBestdoriProvider.getAsset(
`/assets/${Server[tempServer]}/thumb/chara/card00${this.getRip()}/${this.resourceSetName}${trainingString}.png`,
const cardIconImageBuffer = await cardResourceRepository.getImageBuffer(
this,
'icon',
trainingStatus,
);
return await loadImage(cardIconImageBuffer);
}
@ -399,12 +381,12 @@ export class Card {
*/
async getCardIllustrationImage(trainingStatus: boolean): Promise<Image> {
trainingStatus = this.ableToTraining(trainingStatus);
const trainingString = trainingStatus ? '_after_training' : '_normal';
const tempServer = this.getFirstReleasedServer();
const cardIllustrationImageBuffer = await bangDreamBestdoriProvider.getAsset(
`/assets/${Server[tempServer]}/characters/resourceset/${this.resourceSetName}_rip/card${trainingString}.png`,
{ memoryCache: false },
);
const cardIllustrationImageBuffer =
await cardResourceRepository.getImageBuffer(
this,
'illustration',
trainingStatus,
);
return await loadImage(cardIllustrationImageBuffer);
}
/**
@ -417,13 +399,11 @@ export class Card {
trainingStatus: boolean,
): Promise<Buffer> {
trainingStatus = this.ableToTraining(trainingStatus);
const trainingString = trainingStatus ? '_after_training' : '_normal';
const tempServer = this.getFirstReleasedServer();
const cardIllustration = await bangDreamBestdoriProvider.getAsset(
`/assets/${Server[tempServer]}/characters/resourceset/${this.resourceSetName}_rip/card${trainingString}.png`,
{ memoryCache: false },
return await cardResourceRepository.getImageBuffer(
this,
'illustration',
trainingStatus,
);
return cardIllustration;
}
/**
* Card Trim图片
@ -433,12 +413,8 @@ export class Card {
*/
async getCardTrimImage(trainingStatus: boolean): Promise<Image> {
trainingStatus = this.ableToTraining(trainingStatus);
const trainingString = trainingStatus ? '_after_training' : '_normal';
const tempServer = this.getFirstReleasedServer();
const cardIllustrationImageBuffer = await bangDreamBestdoriProvider.getAsset(
`/assets/${Server[tempServer]}/characters/resourceset/${this.resourceSetName}_rip/trim${trainingString}.png`,
{ memoryCache: false },
);
const cardIllustrationImageBuffer =
await cardResourceRepository.getImageBuffer(this, 'trim', trainingStatus);
return await loadImage(cardIllustrationImageBuffer);
}
/**

View File

@ -0,0 +1,102 @@
import type { BangDreamDataProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/data-provider';
import {
CardResourceRepository,
type CardResourceSource,
} from '@/qqbot/plugins/bangDream/tsugu/models/card-resource-repository';
import { Server } from '@/qqbot/plugins/bangDream/tsugu/models/server';
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 createCardSource(
overrides: Partial<CardResourceSource> = {},
): CardResourceSource {
return {
cardId: 472,
releasedAt: [100, null, null, 200, null],
resourceSetName: 'res0472',
...overrides,
};
}
describe('BangDream card resource repository', () => {
it('routes card detail requests through the provider with explicit cache policy', async () => {
const provider = createProviderMock();
provider.getJson.mockResolvedValue({ ok: true });
const repository = new CardResourceRepository(provider);
await expect(repository.getDetail(472, true)).resolves.toEqual({
ok: true,
});
await expect(repository.getDetail(472, false)).resolves.toEqual({
ok: true,
});
expect(provider.getJson).toHaveBeenNthCalledWith(1, '/api/cards/472.json', {
cacheTime: 0,
});
expect(provider.getJson).toHaveBeenNthCalledWith(2, '/api/cards/472.json', {
cacheTime: Infinity,
});
});
it('builds card resource rip directories from card ids', () => {
const repository = new CardResourceRepository(createProviderMock());
expect(repository.getRip(472)).toBe('009_rip');
expect(repository.getRip(9999)).toBe('200_rip');
});
it('builds icon, illustration, and trim asset paths from server priority', () => {
const repository = new CardResourceRepository(createProviderMock());
const source = createCardSource();
const serverPriority = [Server.cn, Server.jp];
expect(repository.getImagePath(source, 'icon', false, serverPriority)).toBe(
'/assets/cn/thumb/chara/card00009_rip/res0472_normal.png',
);
expect(
repository.getImagePath(source, 'illustration', true, serverPriority),
).toBe(
'/assets/cn/characters/resourceset/res0472_rip/card_after_training.png',
);
expect(repository.getImagePath(source, 'trim', false, serverPriority)).toBe(
'/assets/cn/characters/resourceset/res0472_rip/trim_normal.png',
);
});
it('keeps heavy card artwork outside provider memory cache', async () => {
const provider = createProviderMock();
const assetBuffer = Buffer.from('asset');
provider.getAsset.mockResolvedValue(assetBuffer);
const repository = new CardResourceRepository(provider);
const source = createCardSource();
const expectedIconPath =
'/assets/cn/thumb/chara/card00009_rip/res0472_normal.png';
const expectedIllustrationPath =
'/assets/cn/characters/resourceset/res0472_rip/card_after_training.png';
await expect(
repository.getImageBuffer(source, 'icon', false),
).resolves.toBe(assetBuffer);
await repository.getImageBuffer(source, 'illustration', true);
expect(provider.getAsset).toHaveBeenNthCalledWith(
1,
expectedIconPath,
undefined,
);
expect(provider.getAsset).toHaveBeenNthCalledWith(
2,
expectedIllustrationPath,
{ memoryCache: false },
);
});
});