refactor: 收口 BangDream 活动数据仓库

This commit is contained in:
sunlei 2026-06-06 19:47:59 +08:00
parent 9ad018d588
commit 4b3a2bf3b6
7 changed files with 305 additions and 135 deletions

View File

@ -290,8 +290,10 @@ export interface TsuguHook {
- 已新增 `models/main-data-repository.ts`、`song-repository.ts`、`card-repository.ts`、`event-repository.ts`、`gacha-repository.ts`、`player-repository.ts`。 - 已新增 `models/main-data-repository.ts`、`song-repository.ts`、`card-repository.ts`、`event-repository.ts`、`gacha-repository.ts`、`player-repository.ts`。
- `command-renderers/song-list.ts`、`event-list.ts`、`card-list.ts`、`gacha-detail.ts`、`player-detail.ts`、`cutoff-detail.ts` 已开始改走 repository 创建模型或读取主数据。 - `command-renderers/song-list.ts`、`event-list.ts`、`card-list.ts`、`gacha-detail.ts`、`player-detail.ts`、`cutoff-detail.ts` 已开始改走 repository 创建模型或读取主数据。
- `models/song.ts`、`card.ts`、`event.ts`、`gacha.ts`、`player.ts` 的 Bestdori API/素材请求已开始改走 Bestdori provider`models/cutoff.ts` 的 Bestdori/HHWX Tracker fallback 已改走 provider并在 fallback 时输出数据源切换日志。 - `models/song.ts`、`card.ts`、`event.ts`、`gacha.ts`、`player.ts` 的 Bestdori API/素材请求已开始改走 Bestdori provider`models/cutoff.ts` 的 Bestdori/HHWX Tracker fallback 已改走 provider并在 fallback 时输出数据源切换日志。
- 已新增 `models/event-data-repository.ts` 收口活动详情、横幅、背景、轮播、Logo、奖励表情和装饰素材请求`models/event.ts` 不再直接依赖 Bestdori provider 或 `loadImage`
- `drawEventDetail` 的活动真实底图改为轻量图片背景,避免长图走模糊三角纹理导致超时;活动搜索默认遵循 `BANGDREAM_TSUGU_USE_EASY_BG=false`,不再强制简易背景。
- 已新增 `test/qqbot/plugins/bangDream/tsugu/data-provider.spec.ts`,覆盖 provider URL 解析、Bestdori/HHWX mock 数据源、retry/cache wrapper。 - 已新增 `test/qqbot/plugins/bangDream/tsugu/data-provider.spec.ts`,覆盖 provider URL 解析、Bestdori/HHWX mock 数据源、retry/cache wrapper。
- 本地图片烟测已生成查歌 `136` 和查活动 `50` 图片,证明 provider/repository 第一段迁移后仍能输出非空图片。 - 本地图片烟测已生成查歌 `136`、查活动 `50` 简易背景和真实活动背景图片,证明 provider/repository 第一段迁移后仍能输出非空图片。
### Phase 4搜索 specification 和 matcher ### Phase 4搜索 specification 和 matcher

View File

@ -170,7 +170,7 @@ export class QqbotBangDreamRendererService implements OnApplicationBootstrap {
async searchEvent(input: QqbotBangDreamCommandInput) { async searchEvent(input: QqbotBangDreamCommandInput) {
const query = this.requireText(input, '请提供活动关键词或活动 ID'); const query = this.requireText(input, '请提供活动关键词或活动 ID');
const options = this.getRenderOptions(input, { useEasyBG: true }); const options = this.getRenderOptions(input);
const images = this.isInteger(query) const images = this.isInteger(query)
? await drawEventDetail( ? await drawEventDetail(
Number(query), Number(query),

View File

@ -162,6 +162,33 @@ export async function createEasyBackground({ width, height }) {
return canvas; return canvas;
} }
/**
* 使
*
* @param options1 -
*/
export async function createImageBackground({
image,
width,
height,
}: BackgroundOptions): Promise<Canvas> {
const backgroundBuffer = await spreadBackgroundImage(
image,
width,
height,
40,
);
const backgroundImage = await loadImage(backgroundBuffer);
const canvas = new Canvas(width, height);
const ctx = canvas.getContext('2d');
ctx.drawImage(backgroundImage, 0, 0);
ctx.fillStyle = 'rgba(255, 255, 255, 0.72)';
ctx.fillRect(0, 0, width, height);
return canvas;
}
/** /**
* Background * Background
* *

View File

@ -2,6 +2,7 @@ import { Canvas, Image } from 'skia-canvas';
import { import {
createBackground, createBackground,
createEasyBackground, createEasyBackground,
createImageBackground,
} from '@/qqbot/plugins/bangDream/tsugu/canvas/background'; } from '@/qqbot/plugins/bangDream/tsugu/canvas/background';
import { assetsRootPath } from '@/qqbot/plugins/bangDream/tsugu/runtime/config'; import { assetsRootPath } from '@/qqbot/plugins/bangDream/tsugu/runtime/config';
import * as path from 'path'; import * as path from 'path';
@ -22,6 +23,7 @@ export interface OutputFinalOptions {
startWithSpace?: boolean; startWithSpace?: boolean;
imageList: Array<Image | Canvas>; imageList: Array<Image | Canvas>;
useEasyBG?: boolean; useEasyBG?: boolean;
useImageBG?: boolean;
text?: string; text?: string;
BGimage?: Image | Canvas; BGimage?: Image | Canvas;
compress?: boolean; compress?: boolean;
@ -40,6 +42,7 @@ export const outputFinalCanv = async function ({
imageList, imageList,
startWithSpace = true, startWithSpace = true,
useEasyBG = true, useEasyBG = true,
useImageBG = false,
text = 'BanG Dream!', text = 'BanG Dream!',
BGimage = BGDefaultImage, BGimage = BGDefaultImage,
}: OutputFinalOptions): Promise<Canvas> { }: OutputFinalOptions): Promise<Canvas> {
@ -67,6 +70,16 @@ export const outputFinalCanv = async function ({
0, 0,
0, 0,
); );
} else if (useImageBG) {
ctx.drawImage(
await createImageBackground({
image: BGimage,
width: maxW,
height: allH,
}),
0,
0,
);
} else { } else {
ctx.drawImage( ctx.drawImage(
await createBackground({ await createBackground({
@ -104,6 +117,7 @@ export const outputFinalBuffer = async function ({
startWithSpace = true, startWithSpace = true,
imageList, imageList,
useEasyBG = true, useEasyBG = true,
useImageBG,
text, text,
BGimage, BGimage,
compress, compress,
@ -112,6 +126,7 @@ export const outputFinalBuffer = async function ({
startWithSpace, startWithSpace,
imageList, imageList,
useEasyBG, useEasyBG,
useImageBG,
text, text,
BGimage, BGimage,
}); });

View File

@ -488,6 +488,7 @@ export async function drawEventDetail(
return await createOutputFinalImages({ return await createOutputFinalImages({
useEasyBG, useEasyBG,
useImageBG: true,
BGimage, BGimage,
text: 'Event', text: 'Event',
compress, compress,

View File

@ -0,0 +1,247 @@
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 {
getServerByPriority,
Server,
} from '@/qqbot/plugins/bangDream/tsugu/models/server';
import { globalDefaultServer } from '@/qqbot/plugins/bangDream/tsugu/runtime/config';
export interface EventAssetContext {
assetBundleName: string;
bannerAssetBundleName?: string;
startAt: Array<number | null>;
}
export interface EventRewardContext {
pointRewards?: Array<Array<{
point: string;
rewardType: string;
rewardId?: number;
rewardQuantity: number;
}> | null>;
rankingRewards?: Array<Array<{
fromRank: number;
toRank: number;
rewardType: string;
rewardId: number;
rewardQuantity: number;
}> | null>;
startAt: Array<number | null>;
}
type RewardWithId = {
rewardId?: number;
rewardType: string;
};
class EventDataRepository {
/**
*
*
* @param eventId - ID
* @param update -
*/
async getDetail(eventId: number, update = true): Promise<Record<string, any>> {
const cacheTime = update ? 0 : 1 / 0;
return await bangDreamBestdoriProvider.getJson<Record<string, any>>(
`/api/events/${eventId}.json`,
{ cacheTime },
);
}
/**
* 退 homebanner
*
* @param event -
* @param displayedServerList -
*/
async getBannerImage(
event: EventAssetContext,
displayedServerList: Server[] = globalDefaultServer,
): Promise<Image> {
const server = getServerByPriority(event.startAt, displayedServerList);
try {
const bannerImageBuffer = await bangDreamBestdoriProvider.getAsset(
`/assets/${Server[server]}/event/${event.assetBundleName}/images_rip/banner.png`,
{ ignoreError: false },
);
return await loadImage(bannerImageBuffer);
} catch {
const bannerImageBuffer = await bangDreamBestdoriProvider.getAsset(
`/assets/jp/homebanner_rip/${event.bannerAssetBundleName}.png`,
);
return await loadImage(bannerImageBuffer);
}
}
/**
*
*
* @param event -
*/
async getBackgroundImage(event: EventAssetContext): Promise<Image> {
const server = getServerByPriority(event.startAt);
const bgImageBuffer = await bangDreamBestdoriProvider.getAsset(
`/assets/${Server[server]}/event/${event.assetBundleName}/topscreen_rip/bg_eventtop.png`,
);
return await loadImage(bgImageBuffer);
}
/**
*
*
* @param event -
* @param tempServer -
*/
async getSlideImages(
event: EventAssetContext,
tempServer: Server,
): Promise<Image[]> {
const server = getServerByPriority(event.startAt, [tempServer]);
const result: Image[] = [];
const basePath = `/assets/${Server[server]}/event/${event.assetBundleName}/slide_rip/`;
let ruleNumber = 1;
while (true) {
try {
const slideImageBuffer = await bangDreamBestdoriProvider.getAsset(
`${basePath}rule${ruleNumber}.png`,
{ ignoreError: false },
);
result.push(await loadImage(slideImageBuffer));
} catch {
break;
}
ruleNumber++;
}
return result;
}
/**
*
*
* @param event -
*/
async getTopscreenTrimImage(event: EventAssetContext): Promise<Image> {
const server = getServerByPriority(event.startAt);
const topscreenTrimImageBuffer = await bangDreamBestdoriProvider.getAsset(
`/assets/${Server[server]}/event/${event.assetBundleName}/topscreen_rip/trim_eventtop.png`,
);
return await loadImage(topscreenTrimImageBuffer);
}
/**
* Logo
*
* @param event -
* @param tempServer -
*/
async getLogoImage(
event: EventAssetContext,
tempServer: Server,
): Promise<Image> {
const server = getServerByPriority(event.startAt, [tempServer]);
const logoImageBuffer = await bangDreamBestdoriProvider.getAsset(
`/assets/${Server[server]}/event/${event.assetBundleName}/images_rip/logo.png`,
);
return await loadImage(logoImageBuffer);
}
/**
* undefined
*
* @param event -
* @param server -
*/
async getRewardStampImage(
event: EventRewardContext,
server: Server,
): Promise<Image | undefined> {
const allStamps = await bangDreamBestdoriProvider.getJson<
Record<string, any>
>('/api/stamps/all.2.json');
const rewardId = this.pickRewardId(event.pointRewards, 'stamp');
if (rewardId === undefined) return undefined;
const stampAssetName = this.pickServerValue<string>(
allStamps[rewardId]?.imageName,
server,
);
if (!stampAssetName) return undefined;
const serverName = this.pickReleasedServerName(event.startAt, server, 'jp');
try {
const stampBuffer = await bangDreamBestdoriProvider.getAsset(
`/assets/${serverName}/stamp/01_rip/${stampAssetName}.png`,
{ ignoreError: false },
);
return await loadImage(stampBuffer);
} catch {
return undefined;
}
}
/**
* undefined
*
* @param event -
* @param server -
*/
async getRewardDecoImage(
event: EventRewardContext,
server: Server,
): Promise<Image | undefined> {
if (!event.rankingRewards?.[server]) return undefined;
const rewardId = this.pickRewardId(event.rankingRewards, 'deco_pins');
if (rewardId === undefined) return undefined;
const allDeco = bangDreamMainDataRepository.getCollection<
Record<string, any>
>('deco');
const decoAssetName = allDeco[rewardId]?.assetBundleName;
if (!decoAssetName) return undefined;
const serverName = this.pickReleasedServerName(event.startAt, server, 'cn');
try {
const decoBuffer = await bangDreamBestdoriProvider.getAsset(
`/assets/${serverName}/deco/pins_rip/${decoAssetName}.png`,
{ ignoreError: false },
);
return await loadImage(decoBuffer);
} catch {
return undefined;
}
}
private pickRewardId(
rewardsByServer: Array<Array<RewardWithId> | null> | undefined,
rewardType: string,
): number | undefined {
const rewards = rewardsByServer?.filter(Boolean)[0];
return rewards?.find((reward) => reward.rewardType === rewardType)
?.rewardId;
}
private pickReleasedServerName(
startAt: Array<number | null>,
server: Server,
fallback: string,
) {
return startAt[server] && startAt[server] < Date.now()
? Server[server]
: fallback;
}
private pickServerValue<T>(
value: T | T[] | undefined | null,
server: Server,
): T | undefined {
if (Array.isArray(value)) {
return value[server] ?? value[Server.jp] ?? value.find(Boolean);
}
return value ?? undefined;
}
}
export const eventDataRepository = new EventDataRepository();

View File

@ -1,9 +1,5 @@
import { Image, loadImage } from 'skia-canvas'; import { Image } from 'skia-canvas';
import { bangDreamBestdoriProvider } from '@/qqbot/plugins/bangDream/tsugu/data-clients/bestdori-provider'; import { Server } from '@/qqbot/plugins/bangDream/tsugu/models/server';
import {
Server,
getServerByPriority,
} from '@/qqbot/plugins/bangDream/tsugu/models/server';
import { bangDreamMainDataRepository } from '@/qqbot/plugins/bangDream/tsugu/models/main-data-repository'; import { bangDreamMainDataRepository } from '@/qqbot/plugins/bangDream/tsugu/models/main-data-repository';
import { Attribute } from '@/qqbot/plugins/bangDream/tsugu/models/attribute'; import { Attribute } from '@/qqbot/plugins/bangDream/tsugu/models/attribute';
import { Character } from '@/qqbot/plugins/bangDream/tsugu/models/character'; import { Character } from '@/qqbot/plugins/bangDream/tsugu/models/character';
@ -13,6 +9,7 @@ import {
import { stringToNumberArray } from '@/qqbot/plugins/bangDream/tsugu/models/model-utils'; import { stringToNumberArray } from '@/qqbot/plugins/bangDream/tsugu/models/model-utils';
import { getProbableTimeDifference } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/list-time'; import { getProbableTimeDifference } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/list-time';
import { BANGDREAM_EVENT_TYPE_NAME } from '@/qqbot/plugins/bangDream/tsugu/models/bangdream-constants'; import { BANGDREAM_EVENT_TYPE_NAME } from '@/qqbot/plugins/bangDream/tsugu/models/bangdream-constants';
import { eventDataRepository } from '@/qqbot/plugins/bangDream/tsugu/models/event-data-repository';
const typeName: Record<string, string> = BANGDREAM_EVENT_TYPE_NAME; const typeName: Record<string, string> = BANGDREAM_EVENT_TYPE_NAME;
@ -197,14 +194,7 @@ export class Event {
* @param update - update参数使 * @param update - update参数使
*/ */
async getData(update: boolean = true) { async getData(update: boolean = true) {
const time = update ? 0 : 1 / 0; return await eventDataRepository.getDetail(this.eventId, update);
const eventData = await bangDreamBestdoriProvider.getJson<
Record<string, any>
>(
`/api/events/${this.eventId}.json`,
{ cacheTime: time },
);
return eventData;
} }
/** /**
* Event * Event
@ -216,20 +206,7 @@ export class Event {
displayedServerList: Server[] = globalDefaultServer, displayedServerList: Server[] = globalDefaultServer,
): Promise<Image> { ): Promise<Image> {
if (!displayedServerList) displayedServerList = globalDefaultServer; if (!displayedServerList) displayedServerList = globalDefaultServer;
const server = getServerByPriority(this.startAt, displayedServerList); return await eventDataRepository.getBannerImage(this, displayedServerList);
try {
const BannerImageBuffer = await bangDreamBestdoriProvider.getAsset(
`/assets/${Server[server]}/event/${this.assetBundleName}/images_rip/banner.png`,
{ ignoreError: false },
);
return await loadImage(BannerImageBuffer);
} catch {
const server = Server.jp;
const BannerImageBuffer = await bangDreamBestdoriProvider.getAsset(
`/assets/${Server[server]}/homebanner_rip/${this.bannerAssetBundleName}.png`,
);
return await loadImage(BannerImageBuffer);
}
} }
/** /**
* Event * Event
@ -237,11 +214,7 @@ export class Event {
* @returns * @returns
*/ */
async getEventBGImage(): Promise<Image> { async getEventBGImage(): Promise<Image> {
const server = getServerByPriority(this.startAt); return await eventDataRepository.getBackgroundImage(this);
const BGImageBuffer = await bangDreamBestdoriProvider.getAsset(
`/assets/${Server[server]}/event/${this.assetBundleName}/topscreen_rip/bg_eventtop.png`,
);
return await loadImage(BGImageBuffer);
} }
//活动规则轮播图 //活动规则轮播图
/** /**
@ -251,23 +224,7 @@ export class Event {
* @returns * @returns
*/ */
async getEventSlideImage(tempServer: Server): Promise<Image[]> { async getEventSlideImage(tempServer: Server): Promise<Image[]> {
const server = getServerByPriority(this.startAt, [tempServer]); return await eventDataRepository.getSlideImages(this, tempServer);
const result: Image[] = [];
const basePath = `/assets/${Server[server]}/event/${this.assetBundleName}/slide_rip/`;
let ruleNumber = 1;
while (true) {
try {
const SlideImageBuffer = await bangDreamBestdoriProvider.getAsset(
`${basePath}rule${ruleNumber}.png`,
{ ignoreError: false },
);
result.push(await loadImage(SlideImageBuffer));
} catch {
break;
}
ruleNumber++;
}
return result;
} }
//活动主界面trim //活动主界面trim
/** /**
@ -276,11 +233,7 @@ export class Event {
* @returns * @returns
*/ */
async getEventTopscreenTrimImage(): Promise<Image> { async getEventTopscreenTrimImage(): Promise<Image> {
const server = getServerByPriority(this.startAt); return await eventDataRepository.getTopscreenTrimImage(this);
const TopscreenTrimImageBuffer = await bangDreamBestdoriProvider.getAsset(
`/assets/${Server[server]}/event/${this.assetBundleName}/topscreen_rip/trim_eventtop.png`,
);
return await loadImage(TopscreenTrimImageBuffer);
} }
/** /**
* Event Logo图片 * Event Logo图片
@ -289,11 +242,7 @@ export class Event {
* @returns * @returns
*/ */
async getEventLogoImage(tempServer: Server): Promise<Image> { async getEventLogoImage(tempServer: Server): Promise<Image> {
const server = getServerByPriority(this.startAt, [tempServer]); return await eventDataRepository.getLogoImage(this, tempServer);
const LogoImageBuffer = await bangDreamBestdoriProvider.getAsset(
`/assets/${Server[server]}/event/${this.assetBundleName}/images_rip/logo.png`,
);
return await loadImage(LogoImageBuffer);
} }
/** /**
* Event * Event
@ -352,41 +301,7 @@ export class Event {
* @returns * @returns
*/ */
async getRewardStamp(server: Server): Promise<Image> { async getRewardStamp(server: Server): Promise<Image> {
const allStamps = await bangDreamBestdoriProvider.getJson< return await eventDataRepository.getRewardStampImage(this, server);
Record<string, any>
>(
`/api/stamps/all.2.json`,
);
const rewards = this.pointRewards.filter(Boolean)[0];
let rewardId = -1;
for (let i = 0; i < rewards?.length; i++) {
if (rewards[i].rewardType == 'stamp') {
rewardId = rewards[i].rewardId;
break;
}
}
let stampAssetName = '';
for (const i in allStamps) {
if (i == rewardId.toString()) {
stampAssetName = allStamps[i]['imageName'];
}
}
if (stampAssetName == '') {
return undefined;
}
let serverName = 'jp';
if (this.startAt[server] && this.startAt[server] < Date.now()) {
serverName = Server[server];
}
try {
const stampBuffer = await bangDreamBestdoriProvider.getAsset(
`/assets/${serverName}/stamp/01_rip/${stampAssetName}.png`,
{ ignoreError: false },
);
return await loadImage(stampBuffer);
} catch {
return undefined;
}
} }
/** /**
* Event Deco * Event Deco
@ -395,44 +310,7 @@ export class Event {
* @returns * @returns
*/ */
async getRewardDeco(server: Server): Promise<Image> { async getRewardDeco(server: Server): Promise<Image> {
const allDeco = bangDreamMainDataRepository.getCollection< return await eventDataRepository.getRewardDecoImage(this, server);
Record<string, any>
>('deco');
if (!this.rankingRewards[server]) {
// Undefined处理
return undefined;
}
const rewards = this.rankingRewards[server].filter(Boolean);
let rewardId = -1;
for (let i = 0; i < rewards?.length; i++) {
if (rewards[i].rewardType == 'deco_pins') {
rewardId = rewards[i].rewardId;
break;
}
}
if (rewardId == -1) return undefined;
let decoAssetName = '';
for (const i in allDeco) {
if (i == rewardId.toString()) {
decoAssetName = allDeco[i]['assetBundleName'];
}
}
if (decoAssetName == '') {
return undefined;
}
let serverName = 'cn';
if (this.startAt[server] && this.startAt[server] < Date.now()) {
serverName = Server[server];
}
try {
const decoBuffer = await bangDreamBestdoriProvider.getAsset(
`/assets/${serverName}/deco/pins_rip/${decoAssetName}.png`,
{ ignoreError: false },
);
return await loadImage(decoBuffer);
} catch {
return undefined;
}
} }
} }