refactor: 收口 BangDream 角色资源仓储

This commit is contained in:
sunlei 2026-06-07 02:06:58 +08:00
parent 00b6df9471
commit 00ff99d2b6
4 changed files with 176 additions and 17 deletions

View File

@ -302,6 +302,7 @@ export interface TsuguHook {
- 已新增 `models/card-resource-repository.ts`,把 `Card` 的详情请求、资源批次目录、图标/插画/trim 图片路径和重图缓存策略收口到 provider-backed repository`card-resource-repository.spec.ts` 覆盖 `/api/cards` 缓存参数、`9999` 后资源批次、CN 优先资源路径和非 icon 图片 `memoryCache=false`,本地 `/查卡 472` 图片 smoke 输出非空。
- 已新增 `models/gacha-resource-repository.ts`,把 `Gacha` 的详情请求、首页横幅、screen 背景、`bg1` fallback 和 Logo 资源路径收口到 provider-backed repository`gacha-resource-repository.spec.ts` 覆盖 `/api/gacha` 缓存参数、CN 优先 screen 路径、横幅缺失回退 Logo 和背景 fallback本地 `/查卡池 259`、`/抽卡模拟 10 259` 图片 smoke 输出非空。
- 已新增 `models/event-stage-data-repository.ts`,把 `EventStage` 的 festival stages/rotationMusics 数据请求从 `bestdoriUrl + callAPIAndCacheResponse` 收口到 provider-backed repository`event-stage-data-repository.spec.ts` 覆盖两个 festival API 路径和缓存参数,本地 `/查试炼 310` 保持拆成 5 张图片输出。
- 已新增 `models/character-resource-repository.ts`,把 `Character` 的详情请求、角色图标、KV 立绘和名称横幅资源路径从 `bestdoriUrl + callAPIAndCacheResponse/downloadFileCache` 收口到 provider-backed repository`character-resource-repository.spec.ts` 覆盖详情缓存策略和三类资源路径,本地 `/查角色 1` 图片 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,84 @@
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';
export class CharacterResourceRepository {
constructor(
private readonly provider: BangDreamDataProvider = bangDreamBestdoriProvider,
) {}
/**
*
*
* @param characterId - ID
* @param update -
*/
async getDetail(
characterId: number,
update: boolean = true,
): Promise<Record<string, any>> {
return await this.provider.getJson<Record<string, any>>(
`/api/characters/${characterId}.json`,
{ cacheTime: update ? 0 : 1 / 0 },
);
}
/**
*
*
* @param characterId - ID
*/
getIconPath(characterId: number): string {
return `/res/icon/chara_icon_${characterId}.png`;
}
/**
* KV
*
* @param characterId - ID
*/
getIllustrationPath(characterId: number): string {
return `/assets/jp/ui/character_kv_image/${formatNumber(characterId, 3)}_rip/image.png`;
}
/**
*
*
* @param characterId - ID
*/
getNameBannerPath(characterId: number): string {
return `/assets/jp/character_name_rip/name_top_chr${formatNumber(
characterId,
2,
)}.png`;
}
/**
*
*
* @param characterId - ID
*/
async getIconBuffer(characterId: number): Promise<Buffer> {
return await this.provider.getAsset(this.getIconPath(characterId));
}
/**
* KV
*
* @param characterId - ID
*/
async getIllustrationBuffer(characterId: number): Promise<Buffer> {
return await this.provider.getAsset(this.getIllustrationPath(characterId));
}
/**
*
*
* @param characterId - ID
*/
async getNameBannerBuffer(characterId: number): Promise<Buffer> {
return await this.provider.getAsset(this.getNameBannerPath(characterId));
}
}
export const characterResourceRepository = new CharacterResourceRepository();

View File

@ -1,9 +1,6 @@
import mainAPI from '@/qqbot/plugins/bangDream/tsugu/models/main-data-store';
import { callAPIAndCacheResponse } from '@/qqbot/plugins/bangDream/tsugu/data-clients/api-cache-client';
import { Image, loadImage } from 'skia-canvas';
import { downloadFileCache } from '@/qqbot/plugins/bangDream/tsugu/data-clients/asset-cache-client';
import { formatNumber } from '@/qqbot/plugins/bangDream/tsugu/models/model-utils';
import { bestdoriUrl } from '@/qqbot/plugins/bangDream/tsugu/runtime/config';
import { characterResourceRepository } from '@/qqbot/plugins/bangDream/tsugu/models/character-resource-repository';
export class Character {
characterId: number;
@ -98,12 +95,10 @@ export class Character {
* @param update - update参数使
*/
async getData(update: boolean = true) {
const time = update ? 0 : 1 / 0;
const cardData = await callAPIAndCacheResponse(
`${bestdoriUrl}/api/characters/${this.characterId}.json`,
time,
return await characterResourceRepository.getDetail(
this.characterId,
update,
);
return cardData;
}
/**
* Character
@ -111,8 +106,8 @@ export class Character {
* @returns
*/
async getIcon(): Promise<Image> {
const iconBuffer = await downloadFileCache(
`${bestdoriUrl}/res/icon/chara_icon_${this.characterId}.png`,
const iconBuffer = await characterResourceRepository.getIconBuffer(
this.characterId,
);
return await loadImage(iconBuffer);
}
@ -122,9 +117,8 @@ export class Character {
* @returns
*/
async getIllustration(): Promise<Image> {
const illustrationBuffer = await downloadFileCache(
`${bestdoriUrl}/assets/jp/ui/character_kv_image/${formatNumber(this.characterId, 3)}_rip/image.png`,
);
const illustrationBuffer =
await characterResourceRepository.getIllustrationBuffer(this.characterId);
return await loadImage(illustrationBuffer);
}
/**
@ -133,9 +127,8 @@ export class Character {
* @returns
*/
async getNameBanner(): Promise<Image> {
const nameBannerBuffer = await downloadFileCache(
`${bestdoriUrl}/assets/jp/character_name_rip/name_top_chr${formatNumber(this.characterId, 2)}.png`,
);
const nameBannerBuffer =
await characterResourceRepository.getNameBannerBuffer(this.characterId);
return await loadImage(nameBannerBuffer);
}
/**

View File

@ -0,0 +1,81 @@
import type { BangDreamDataProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/data-provider';
import { CharacterResourceRepository } from '@/qqbot/plugins/bangDream/tsugu/models/character-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}`),
};
}
describe('BangDream character resource repository', () => {
it('routes character detail requests through the provider with explicit cache policy', async () => {
const provider = createProviderMock();
provider.getJson.mockResolvedValue({ ok: true });
const repository = new CharacterResourceRepository(provider);
await expect(repository.getDetail(1, true)).resolves.toEqual({ ok: true });
await expect(repository.getDetail(1, false)).resolves.toEqual({
ok: true,
});
expect(provider.getJson).toHaveBeenNthCalledWith(
1,
'/api/characters/1.json',
{ cacheTime: 0 },
);
expect(provider.getJson).toHaveBeenNthCalledWith(
2,
'/api/characters/1.json',
{ cacheTime: Infinity },
);
});
it('builds icon, illustration, and name banner paths', () => {
const repository = new CharacterResourceRepository(createProviderMock());
expect(repository.getIconPath(1)).toBe('/res/icon/chara_icon_1.png');
expect(repository.getIllustrationPath(1)).toBe(
'/assets/jp/ui/character_kv_image/001_rip/image.png',
);
expect(repository.getNameBannerPath(1)).toBe(
'/assets/jp/character_name_rip/name_top_chr01.png',
);
});
it('downloads character assets through the provider', async () => {
const provider = createProviderMock();
const iconBuffer = Buffer.from('icon');
const illustrationBuffer = Buffer.from('illustration');
const nameBannerBuffer = Buffer.from('name-banner');
provider.getAsset
.mockResolvedValueOnce(iconBuffer)
.mockResolvedValueOnce(illustrationBuffer)
.mockResolvedValueOnce(nameBannerBuffer);
const repository = new CharacterResourceRepository(provider);
await expect(repository.getIconBuffer(1)).resolves.toBe(iconBuffer);
await expect(repository.getIllustrationBuffer(1)).resolves.toBe(
illustrationBuffer,
);
await expect(repository.getNameBannerBuffer(1)).resolves.toBe(
nameBannerBuffer,
);
expect(provider.getAsset).toHaveBeenNthCalledWith(
1,
'/res/icon/chara_icon_1.png',
);
expect(provider.getAsset).toHaveBeenNthCalledWith(
2,
'/assets/jp/ui/character_kv_image/001_rip/image.png',
);
expect(provider.getAsset).toHaveBeenNthCalledWith(
3,
'/assets/jp/character_name_rip/name_top_chr01.png',
);
});
});