refactor: 收口 BangDream 服装资源仓储

This commit is contained in:
sunlei 2026-06-07 02:46:32 +08:00
parent ac5812e079
commit 4816354344
4 changed files with 127 additions and 16 deletions

View File

@ -307,6 +307,7 @@ export interface TsuguHook {
- 已新增 `models/attribute-resource-repository.ts`,把 `Attribute` 的属性图标 SVG 资源路径从 `bestdoriUrl + downloadFileCache` 收口到 provider-backed repository`attribute-resource-repository.spec.ts` 覆盖属性图标路径和下载调用,本地 `/查卡 472` 图片 smoke 保持非空输出。
- 已新增 `models/server-resource-repository.ts`,把 `Server` 的服务器图标 SVG 资源路径从 `bestdoriUrl + downloadFileCache` 收口到 provider-backed repository并把台服本地图标路径改走 asset manifest`server-resource-repository.spec.ts` 覆盖服务器图标路径、台服本地图标路径和下载调用,本地 `/查卡 472` 图片 smoke 保持非空输出。
- 已新增 `render-blocks/card-art-resource-repository.ts`,把 `card-art.ts` 的卡牌小图/插画边框资源下载从 `bestdoriUrl + downloadFileCache` 收口到 provider-backed repository`card-art-spec.ts` 改为只生成 `/res/image/...` 相对资源路径;`card-art-resource-repository.spec.ts` 覆盖边框下载调用,本地 `/查卡 472` 图片 smoke 保持非空输出。
- 已新增 `models/costume-resource-repository.ts`,把 `Costume` 的服装详情请求和 Live2D 缩略图素材路径从 `bestdoriUrl + callAPIAndCacheResponse/downloadFile` 收口到 provider-backed repository`costume-resource-repository.spec.ts` 覆盖详情路径、`sdchara.png` 路径和非内存缓存下载调用,本地 `/查卡 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,67 @@
import { bangDreamBestdoriProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/bestdori-provider';
import type { BangDreamDataProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/data-provider';
import {
getServerByPriority,
Server,
} from '@/qqbot/plugins/bangDream/tsugu/models/server';
import { globalDefaultServer } from '@/qqbot/plugins/bangDream/tsugu/runtime/config';
export interface CostumeSdResourceSource {
publishedAt: Array<number | null>;
sdResourceName: string;
}
function toServerCode(server: Server | undefined): string {
return server == null ? 'undefined' : Server[server];
}
export class CostumeResourceRepository {
constructor(
private readonly provider: BangDreamDataProvider = bangDreamBestdoriProvider,
) {}
/**
*
*
* @param costumeId - ID
*/
async getDetail(costumeId: number): Promise<Record<string, any>> {
return await this.provider.getJson<Record<string, any>>(
`/api/costumes/${costumeId}.json`,
);
}
/**
* Live2D
*
* @param source -
* @param displayedServerList -
*/
getSdCharacterPath(
source: CostumeSdResourceSource,
displayedServerList: Server[] = globalDefaultServer,
): string {
const serverCode = toServerCode(
getServerByPriority(source.publishedAt, displayedServerList),
);
return `/assets/${serverCode}/characters/livesd/${source.sdResourceName}_rip/sdchara.png`;
}
/**
* Live2D
*
* @param source -
* @param displayedServerList -
*/
async getSdCharacterBuffer(
source: CostumeSdResourceSource,
displayedServerList: Server[] = globalDefaultServer,
): Promise<Buffer> {
return await this.provider.getAsset(
this.getSdCharacterPath(source, displayedServerList),
{ memoryCache: false },
);
}
}
export const costumeResourceRepository = new CostumeResourceRepository();

View File

@ -1,16 +1,9 @@
import { callAPIAndCacheResponse } from '@/qqbot/plugins/bangDream/tsugu/data-clients/api-cache-client';
import { downloadFile } from '@/qqbot/plugins/bangDream/tsugu/data-clients/asset-cache-client';
import {
bestdoriUrl,
globalDefaultServer,
} from '@/qqbot/plugins/bangDream/tsugu/runtime/config';
import { globalDefaultServer } from '@/qqbot/plugins/bangDream/tsugu/runtime/config';
import mainAPI from '@/qqbot/plugins/bangDream/tsugu/models/main-data-store';
import {
Server,
getServerByPriority,
} from '@/qqbot/plugins/bangDream/tsugu/models/server';
import { Server } from '@/qqbot/plugins/bangDream/tsugu/models/server';
import { Image, loadImage } from 'skia-canvas';
import { stringToNumberArray } from '@/qqbot/plugins/bangDream/tsugu/models/model-utils';
import { costumeResourceRepository } from '@/qqbot/plugins/bangDream/tsugu/models/costume-resource-repository';
export class Costume {
costumeId: number;
@ -48,8 +41,8 @@ export class Costume {
if (this.isInitfull) {
return;
}
const costumeData = await callAPIAndCacheResponse(
`${bestdoriUrl}/api/costumes/${this.costumeId}.json`,
const costumeData = await costumeResourceRepository.getDetail(
this.costumeId,
);
this.data = costumeData;
this.isExist = true;
@ -71,10 +64,11 @@ export class Costume {
displayedServerList: Server[] = globalDefaultServer,
): Promise<Image> {
if (!displayedServerList) displayedServerList = globalDefaultServer;
const server = getServerByPriority(this.publishedAt, displayedServerList);
const sdCharacterBuffer = await downloadFile(
`${bestdoriUrl}/assets/${Server[server]}/characters/livesd/${this.sdResourceName}_rip/sdchara.png`,
);
const sdCharacterBuffer =
await costumeResourceRepository.getSdCharacterBuffer(
this,
displayedServerList,
);
return await loadImage(sdCharacterBuffer);
}
}

View File

@ -0,0 +1,49 @@
import type { BangDreamDataProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/data-provider';
import { CostumeResourceRepository } from '@/qqbot/plugins/bangDream/tsugu/models/costume-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}`),
};
}
describe('BangDream costume resource repository', () => {
it('loads costume details through the provider', async () => {
const provider = createProviderMock();
const detail = { costumeId: 472, sdResourceName: 'sd_001' };
provider.getJson.mockResolvedValue(detail);
const repository = new CostumeResourceRepository(provider);
await expect(repository.getDetail(472)).resolves.toBe(detail);
expect(provider.getJson).toHaveBeenCalledWith('/api/costumes/472.json');
});
it('builds and downloads sd character assets through the provider', async () => {
const provider = createProviderMock();
const sdCharacterBuffer = Buffer.from('sd-character');
provider.getAsset.mockResolvedValue(sdCharacterBuffer);
const repository = new CostumeResourceRepository(provider);
const source = {
publishedAt: [100, null, null, 200, null],
sdResourceName: 'sd_001',
};
expect(repository.getSdCharacterPath(source, [Server.cn])).toBe(
'/assets/cn/characters/livesd/sd_001_rip/sdchara.png',
);
await expect(
repository.getSdCharacterBuffer(source, [Server.cn]),
).resolves.toBe(sdCharacterBuffer);
expect(provider.getAsset).toHaveBeenCalledWith(
'/assets/cn/characters/livesd/sd_001_rip/sdchara.png',
{ memoryCache: false },
);
});
});