feat: 增加Blog Live2D资源防盗链网关

This commit is contained in:
sunlei 2026-07-04 07:49:18 +08:00
parent ed7d15f5ad
commit 453d95ef35
10 changed files with 552 additions and 10 deletions

View File

@ -11,6 +11,9 @@ MINIO_PORT=9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=kt-template-online
BLOG_LIVE2D_ALLOWED_ORIGINS=https://blog.kwitsukasa.top,http://localhost:5999
BLOG_LIVE2D_BUCKET=kt-template-online
BLOG_LIVE2D_PREFIX=blog/live2d/pio
WORDPRESS_BASE_URL=http://localhost
WORDPRESS_HOST_HEADER=

5
API.md
View File

@ -93,7 +93,7 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管
| 分组 | 关键变量 |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` |
| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET` |
| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET`、`BLOG_LIVE2D_ALLOWED_ORIGINS`、`BLOG_LIVE2D_BUCKET`、`BLOG_LIVE2D_PREFIX` |
| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` |
| WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD` |
| Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_QUERY_SELECTOR` |
@ -331,9 +331,12 @@ WordPress rewrite 未开启导致 `/wp-json/*` 返回 404 时,后端会回退
| `GET` | `/minio/resource-proxy` | 代理读取资源 |
| `GET` | `/minio/download` | 下载文件流 |
| `DELETE` | `/minio/remove` | 删除文件 |
| `GET` | `/blog/live2d/pio/:version/*assetPath` | 公开读取 Pio Live2D 运行包资源,按 Referer/Origin 白名单防盗链 |
`bucketName` 不传时使用 `MINIO_BUCKET`
Blog Live2D 运行包读取入口不使用 Vben 响应包装,直接返回 MinIO 文件流。`BLOG_LIVE2D_ALLOWED_ORIGINS` 配置允许的 Blog 源,例如 `https://blog.kwitsukasa.top,http://localhost:5999``BLOG_LIVE2D_BUCKET` 和 `BLOG_LIVE2D_PREFIX` 决定对象位置。路由支持嵌套资源路径,如 `textures/texture_00.png``motions/idle.motion3.json`,并拒绝绝对 URL、反斜杠、`.`/`..` 和多重编码后的路径逃逸。
## QQBot 管理
QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT 事件总线、账号能力绑定、在线命令、自动回复规则、权限名单、发送/接收日志和插件生态。

View File

@ -27,7 +27,7 @@
| `qqbot/plugins/bilibili-card` | 解析 QQ/NapCat Bilibili 卡片和短链,按账号事件绑定回复封面图和视频文字摘要 |
| `qqbot/plugins/ff14-market` | XIVAPI + Universalis 物品解析和 FF14 市场查价 |
| `qqbot/plugins/fflogs` | FFLogs v2 GraphQL 角色排名和指定高难最近记录查询 |
| `minio` | Bucket 检查、上传、列表、临时 URL、代理下载、删除 |
| `minio` | Bucket 检查、上传、列表、临时 URL、代理下载、删除,以及 Blog Live2D 运行包受控读取入口 |
| `common` | 响应封装、异常过滤、请求日志、日期格式化、字典解码、Snowflake、工具服务 |
## 目录结构
@ -59,7 +59,7 @@ ci/ Jenkins Agent/Docker 辅助文件
| 分组 | 变量 |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` |
| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET` |
| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET`、`BLOG_LIVE2D_ALLOWED_ORIGINS`、`BLOG_LIVE2D_BUCKET`、`BLOG_LIVE2D_PREFIX` |
| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` |
| WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD`、`WORDPRESS_*_TIMEOUT_MS` |
| Logging/Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_*` |
@ -71,6 +71,8 @@ ci/ Jenkins Agent/Docker 辅助文件
`DB_SYNC=true` 只适合本地开发或明确允许自动同步表结构的环境;生产应关闭并使用 SQL/迁移脚本。
Blog Live2D 运行包存放在 MinIO公开读取入口为 `/blog/live2d/pio/:version/*assetPath`。`BLOG_LIVE2D_ALLOWED_ORIGINS` 是允许加载 Pio runtime 的 Blog 域名白名单,`BLOG_LIVE2D_BUCKET` 和 `BLOG_LIVE2D_PREFIX` 指向运行包对象位置;未通过 Referer/Origin 白名单的请求会在读取 MinIO 前拒绝。
QQBot 插件 worker 使用 BullMQ 队列串行执行同一插件安装实例的请求。K8s 生产清单包含内部服务 `kt-qqbot-plugin-redis`,生产 env 可将 `QQBOT_PLUGIN_QUEUE_REDIS_HOST` 配为该服务名。`QQBOT_PLUGIN_QUEUE_WAIT_TIMEOUT_MS` 控制排队等待窗口,插件 `operation.timeoutMs` 仍表示单次执行预算。
QQBot 插件定时任务由 manifest 的 `tasks` 声明,平台持久化到 `qqbot_plugin_task` / `qqbot_plugin_task_run`,通过 BullMQ Job Scheduler 调度并经插件 worker 的 `executeTask` 边界执行。`sql/qqbot-init.sql` 可为既有环境增量创建任务表和 Admin 菜单。Admin 页面路径为 `/qqbot/plugin-task`。定时任务队列可用 `QQBOT_PLUGIN_TASK_QUEUE_REDIS_*` 单独配置;留空时复用插件 worker 队列的 Redis 连接。BangDream Bestdori 主数据缓存使用 `BANGDREAM_TSUGU_CACHE_ROOT`,生产清单挂载到容器内 `/data/qqbot/plugins/bangdream/cache`,对应 k3d 节点可写 hostPath `/var/lib/rancher/k3s/kt-template-online-api/qqbot-plugins`

View File

@ -0,0 +1,190 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MinioClientService } from './asset-minio.service';
import type {
BlogLive2DAssetResult,
BlogLive2DRuntimeAssetPath,
} from '../domain/blog-live2d-asset.types';
const DEFAULT_ALLOWED_ORIGINS = 'https://blog.kwitsukasa.top';
const DEFAULT_LIVE2D_BUCKET = 'kt-template-online';
const DEFAULT_LIVE2D_PREFIX = 'blog/live2d/pio';
const MAX_DECODE_DEPTH = 6;
@Injectable()
export class BlogLive2DAssetService {
/**
* Creates the guarded Blog Live2D asset service.
* @param minioClientService - Existing MinIO helper used to stream versioned Pio runtime files.
* @param configService - Runtime config source for bucket, object prefix, and allowed browser origins.
*/
constructor(
private readonly minioClientService: MinioClientService,
private readonly configService: ConfigService,
) {}
/**
* Rejects browser asset requests that do not come from the configured Blog origins.
* @param referer - Browser Referer header; used for normal `<script>/<img>/<canvas>` asset loads.
* @param origin - Browser Origin header; used by CORS-capable requests when Referer is absent.
*/
assertAllowedRequest(referer?: string, origin?: string): void {
const candidates = [referer, origin].filter(Boolean) as string[];
if (!candidates.length) {
throw new BadRequestException('Live2D asset referer is required');
}
const allowed = this.getAllowedOrigins();
const allSourcesAllowed = candidates.every((candidate) =>
allowed.includes(this.toOrigin(candidate)),
);
if (!allSourcesAllowed) {
throw new BadRequestException('Live2D asset request is not allowed');
}
}
/**
* Streams one Pio runtime asset from the configured MinIO bucket and prefix.
* @param version - Runtime release segment such as `v1`; kept separate from object path to prevent route traversal.
* @param objectPath - Route tail below the version, including nested paths such as `textures/texture_00.png`.
* @returns MinIO stream and stat metadata for the requested object.
*/
async getRuntimeObject(
version: string,
objectPath: BlogLive2DRuntimeAssetPath,
): Promise<BlogLive2DAssetResult> {
return this.minioClientService.getObject(
this.resolveRuntimeObjectPath(version, objectPath),
this.getBucketName(),
);
}
/**
* Builds the MinIO object key for a versioned Pio runtime file.
* @param version - Runtime release segment supplied by the route.
* @param objectPath - Route tail supplied by the wildcard parameter.
* @returns Full MinIO object key under the configured Pio prefix.
*/
resolveRuntimeObjectPath(
version: string,
objectPath: BlogLive2DRuntimeAssetPath,
): string {
const prefix = this.getPrefixSegments();
const versionSegment = this.normalizeRouteSegments(version, 'version');
const assetSegments = this.normalizeRouteSegments(objectPath, 'asset path');
return [...prefix, ...versionSegment, ...assetSegments].join('/');
}
/**
* @returns Configured MinIO bucket for Blog Live2D runtime files.
*/
private getBucketName(): string {
return (
this.configService.get<string>('BLOG_LIVE2D_BUCKET') ||
this.minioClientService.getDefaultBucket?.() ||
DEFAULT_LIVE2D_BUCKET
);
}
/**
* @returns Sanitized MinIO object-key prefix for Pio runtime files.
*/
private getPrefixSegments(): string[] {
return this.normalizeRouteSegments(
this.configService.get<string>('BLOG_LIVE2D_PREFIX') ||
DEFAULT_LIVE2D_PREFIX,
'prefix',
);
}
/**
* @returns Allowed absolute origins for Blog Live2D asset requests.
*/
private getAllowedOrigins(): string[] {
return (
this.configService.get<string>('BLOG_LIVE2D_ALLOWED_ORIGINS') ||
DEFAULT_ALLOWED_ORIGINS
)
.split(',')
.map((item) => item.trim())
.filter(Boolean)
.map((item) => this.toOrigin(item));
}
/**
* Converts a header value or configured URL to an absolute origin string.
* @param value - Referer, Origin, or allowlist entry that must parse as an HTTP(S) URL.
* @returns Protocol and host pair used for allowlist comparison.
*/
private toOrigin(value: string): string {
try {
const url = new URL(value);
if (!['http:', 'https:'].includes(url.protocol)) {
throw new Error('unsupported protocol');
}
return `${url.protocol}//${url.host}`;
} catch {
throw new BadRequestException('Live2D asset request source is invalid');
}
}
/**
* Normalizes route and config path values into safe object-key segments.
* @param input - String or wildcard array supplied by Nest/path-to-regexp or runtime config.
* @param label - Human-readable source name used in the rejection message.
* @returns Decoded path segments with traversal, absolute URL, and backslash escapes rejected.
*/
private normalizeRouteSegments(
input: BlogLive2DRuntimeAssetPath,
label: string,
): string[] {
const raw = Array.isArray(input) ? input.join('/') : String(input || '');
const decoded = this.decodeRepeated(raw.trim(), label);
if (
!decoded ||
decoded.includes('\\') ||
decoded.startsWith('/') ||
decoded.startsWith('//') ||
/^[a-z][a-z0-9+.-]*:/i.test(decoded)
) {
throw new BadRequestException(`Invalid Live2D ${label}`);
}
const segments = decoded.split('/').filter(Boolean);
if (
!segments.length ||
segments.some((segment) => segment === '.' || segment === '..')
) {
throw new BadRequestException(`Invalid Live2D ${label}`);
}
return segments;
}
/**
* Decodes route path text until stable so encoded traversal cannot pass through.
* @param value - Raw path text from a route segment or config value.
* @param label - Human-readable source name used in the rejection message.
* @returns Fully decoded path text when decoding converges within the bounded depth.
*/
private decodeRepeated(value: string, label: string): string {
try {
let decoded = value;
for (let index = 0; index < MAX_DECODE_DEPTH; index += 1) {
const next = decodeURIComponent(decoded);
if (next === decoded) {
return next;
}
decoded = next;
}
} catch {
throw new BadRequestException(`Invalid Live2D ${label}`);
}
throw new BadRequestException(`Invalid Live2D ${label}`);
}
}

View File

@ -2,11 +2,16 @@ import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AdminAuthGuardModule } from '@/modules/admin/identity/auth/admin-auth-guard.module';
import { MinioClientService } from './application/asset-minio.service';
import { BlogLive2DAssetService } from './application/blog-live2d-asset.service';
import { BlogLive2DAssetController } from './contract/blog-live2d-asset.controller';
import { MinioClientController } from './contract/asset-minio.controller';
export const ASSET_CONTROLLERS = [MinioClientController];
export const ASSET_CONTROLLERS = [
MinioClientController,
BlogLive2DAssetController,
];
export const ASSET_PROVIDERS = [MinioClientService];
export const ASSET_PROVIDERS = [MinioClientService, BlogLive2DAssetService];
export const ASSET_DOMAIN_CONTRACT = {
tables: [

View File

@ -0,0 +1,62 @@
import { Controller, Get, Headers, Param, Res } from '@nestjs/common';
import { ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
import { Response } from 'express';
import { ApiFileDownloadResponse, Public } from '@/common';
import { BlogLive2DAssetService } from '../application/blog-live2d-asset.service';
import type { BlogLive2DRuntimeAssetPath } from '../domain/blog-live2d-asset.types';
@Controller('blog/live2d')
@ApiTags('Blog - Live2D')
export class BlogLive2DAssetController {
/**
* Creates the public Blog Live2D asset controller.
* @param blogLive2DAssetService - Service that validates browser source headers and streams MinIO objects.
*/
constructor(private readonly blogLive2DAssetService: BlogLive2DAssetService) {}
/**
* Streams a versioned Pio runtime asset after Origin/Referer validation.
* @param version - Runtime version segment such as `v1`.
* @param assetPath - Wildcard asset path under the version, including nested texture or motion folders.
* @param referer - Browser Referer header used by the hotlink protection policy.
* @param origin - Browser Origin header used when the request type supplies it.
* @param res - Express response that receives MinIO content headers and stream bytes.
* @returns Promise resolved after the stream is attached to the Express response; no Vben body is returned.
*/
@Get('pio/:version/*assetPath')
@ApiOperation({ summary: '获取 Pio Live2D 运行时资源' })
@ApiParam({ name: 'version', example: 'v1' })
@ApiParam({ name: 'assetPath', example: 'textures/texture_00.png' })
@ApiFileDownloadResponse('Pio Live2D runtime asset stream')
@Public()
async getPioAsset(
@Param('version') version: string,
@Param('assetPath') assetPath: BlogLive2DRuntimeAssetPath,
@Headers('referer') referer: string | undefined,
@Headers('origin') origin: string | undefined,
@Res() res: Response,
) {
this.blogLive2DAssetService.assertAllowedRequest(referer, origin);
const { stream, stat, objectName } =
await this.blogLive2DAssetService.getRuntimeObject(version, assetPath);
res.setHeader(
'Content-Type',
stat.metaData?.['content-type'] || 'application/octet-stream',
);
res.setHeader('Cache-Control', this.getCacheControl(objectName));
stream.pipe(res);
}
/**
* Chooses browser cache policy based on the streamed Live2D object name.
* @param objectName - MinIO object key returned by the asset service.
* @returns Short cache for manifests and immutable cache for hashed or versioned runtime assets.
*/
private getCacheControl(objectName: string): string {
return objectName.endsWith('manifest.json') ||
objectName.endsWith('.model3.json')
? 'public, max-age=60'
: 'public, max-age=31536000, immutable';
}
}

View File

@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
export class BlogLive2DManifestDto {
@ApiProperty({ example: 'pio' })
character: string;
@ApiProperty({ example: 'v1' })
version: string;
@ApiProperty({ example: true })
desktopOnly: boolean;
@ApiProperty({
example: '/api/blog/live2d/pio/v1/pio.model3.json',
})
model3: string;
}

View File

@ -0,0 +1,13 @@
import type { MinioObjectResult } from './asset-minio.types';
export type BlogLive2DRuntimeAssetPath = string | string[];
export type BlogLive2DAssetRequest = {
objectPath: BlogLive2DRuntimeAssetPath;
origin?: string;
referer?: string;
version: string;
};
export type BlogLive2DAssetResult = MinioObjectResult;

View File

@ -0,0 +1,225 @@
import { Readable } from 'node:stream';
import { BadRequestException, HttpStatus } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import * as request from 'supertest';
import { MinioClientService } from '../../src/modules/asset/application/asset-minio.service';
import { BlogLive2DAssetService } from '../../src/modules/asset/application/blog-live2d-asset.service';
import { BlogLive2DAssetController } from '../../src/modules/asset/contract/blog-live2d-asset.controller';
/**
* Creates the config facade used by Blog Live2D asset tests.
* @param overrides - Runtime config values that should replace the default fixture.
* @returns ConfigService-compatible object for direct service construction.
*/
function createConfig(overrides: Record<string, string | undefined> = {}) {
const values: Record<string, string | undefined> = {
BLOG_LIVE2D_ALLOWED_ORIGINS:
'https://blog.kwitsukasa.top,http://localhost:5999',
BLOG_LIVE2D_BUCKET: 'kt-template-online',
BLOG_LIVE2D_PREFIX: 'blog/live2d/pio',
...overrides,
};
return {
get: jest.fn((key: string) => values[key]),
};
}
/**
* Creates a MinIO client facade that records requested object names.
* @returns Mocked MinIO client service used by the service and controller tests.
*/
function createMinio() {
return {
getDefaultBucket: jest.fn(() => 'kt-template-online'),
getObject: jest.fn(async (objectName: string, bucketName?: string) => ({
bucketName,
objectName,
stat: {
etag: 'etag-1',
lastModified: new Date('2026-07-04T00:00:00.000Z'),
metaData: {
'content-type': objectName.endsWith('.json')
? 'application/json'
: 'image/png',
},
size: 2,
},
stream: Readable.from(['ok']),
})),
};
}
describe('BlogLive2DAssetService', () => {
it('allows configured blog referer', () => {
const service = new BlogLive2DAssetService(
createMinio() as never,
createConfig() as never,
);
expect(() =>
service.assertAllowedRequest(
'https://blog.kwitsukasa.top/post/1',
undefined,
),
).not.toThrow();
});
it('allows configured origin when referer is absent', () => {
const service = new BlogLive2DAssetService(
createMinio() as never,
createConfig() as never,
);
expect(() =>
service.assertAllowedRequest(undefined, 'http://localhost:5999'),
).not.toThrow();
});
it('rejects external referer', () => {
const service = new BlogLive2DAssetService(
createMinio() as never,
createConfig() as never,
);
expect(() =>
service.assertAllowedRequest('https://example.com/hotlink', undefined),
).toThrow(BadRequestException);
});
it('rejects missing or malformed request source', () => {
const service = new BlogLive2DAssetService(
createMinio() as never,
createConfig() as never,
);
expect(() => service.assertAllowedRequest(undefined, undefined)).toThrow(
BadRequestException,
);
expect(() => service.assertAllowedRequest('not-a-url', undefined)).toThrow(
BadRequestException,
);
});
it('maps nested runtime files below the configured MinIO prefix', async () => {
const minio = createMinio();
const service = new BlogLive2DAssetService(
minio as never,
createConfig() as never,
);
await service.getRuntimeObject('v1', ['textures', 'texture_00.png']);
expect(minio.getObject).toHaveBeenCalledWith(
'blog/live2d/pio/v1/textures/texture_00.png',
'kt-template-online',
);
});
it('rejects runtime path traversal before touching MinIO', async () => {
const minio = createMinio();
const service = new BlogLive2DAssetService(
minio as never,
createConfig() as never,
);
await expect(
service.getRuntimeObject('v1', ['%252e%252e', 'secret.env']),
).rejects.toThrow(BadRequestException);
expect(minio.getObject).not.toHaveBeenCalled();
});
it.each([
['absolute URL', 'v1', ['https://evil.test', 'texture.png']],
['absolute path', 'v1', ['/textures', 'texture.png']],
['backslash path', 'v1', ['textures\\texture.png']],
['dot segment', 'v1', ['.', 'texture.png']],
['version traversal', '../v1', ['texture.png']],
])(
'rejects unsafe %s runtime paths before touching MinIO',
async (_name, version, objectPath) => {
const minio = createMinio();
const service = new BlogLive2DAssetService(
minio as never,
createConfig() as never,
);
await expect(service.getRuntimeObject(version, objectPath)).rejects.toThrow(
BadRequestException,
);
expect(minio.getObject).not.toHaveBeenCalled();
},
);
});
describe('BlogLive2DAssetController', () => {
it('streams nested Pio runtime assets for allowed blog requests', async () => {
const minio = createMinio();
const moduleRef = await Test.createTestingModule({
controllers: [BlogLive2DAssetController],
providers: [
BlogLive2DAssetService,
{
provide: MinioClientService,
useValue: minio,
},
{
provide: ConfigService,
useValue: createConfig(),
},
],
}).compile();
const app = moduleRef.createNestApplication();
await app.init();
try {
const response = await request(app.getHttpServer())
.get('/blog/live2d/pio/v1/textures/texture_00.png')
.set('Referer', 'https://blog.kwitsukasa.top/post/1')
.expect(HttpStatus.OK);
expect(Buffer.from(response.body).toString()).toBe('ok');
expect(response.headers['content-type']).toContain('image/png');
expect(response.headers['cache-control']).toBe(
'public, max-age=31536000, immutable',
);
expect(minio.getObject).toHaveBeenCalledWith(
'blog/live2d/pio/v1/textures/texture_00.png',
'kt-template-online',
);
} finally {
await app.close();
}
});
it('rejects external hotlink requests before streaming assets', async () => {
const minio = createMinio();
const moduleRef = await Test.createTestingModule({
controllers: [BlogLive2DAssetController],
providers: [
BlogLive2DAssetService,
{
provide: MinioClientService,
useValue: minio,
},
{
provide: ConfigService,
useValue: createConfig(),
},
],
}).compile();
const app = moduleRef.createNestApplication();
await app.init();
try {
await request(app.getHttpServer())
.get('/blog/live2d/pio/v1/pio.model3.json')
.set('Referer', 'https://example.com/post/1')
.expect(HttpStatus.BAD_REQUEST);
expect(minio.getObject).not.toHaveBeenCalled();
} finally {
await app.close();
}
});
});

View File

@ -4,6 +4,7 @@ jest.mock('../../../src/modules/qqbot/core/qqbot-core.module', () => ({
import { MODULE_METADATA } from '@nestjs/common/constants';
import { ConfigModule } from '@nestjs/config';
import { IS_PUBLIC_KEY } from '../../../src/common';
import { AppModule } from '../../../src/app.module';
import { AdminAuthGuardModule } from '../../../src/modules/admin/identity/auth/admin-auth-guard.module';
import {
@ -13,6 +14,8 @@ import {
AssetModule,
} from '../../../src/modules/asset/asset.module';
import { MinioClientService } from '../../../src/modules/asset/application/asset-minio.service';
import { BlogLive2DAssetService } from '../../../src/modules/asset/application/blog-live2d-asset.service';
import { BlogLive2DAssetController } from '../../../src/modules/asset/contract/blog-live2d-asset.controller';
import { MinioClientController } from '../../../src/modules/asset/contract/asset-minio.controller';
import { AdminPlatformConfigModule } from '../../../src/modules/admin/platform-config/admin-platform-config.module';
import {
@ -61,6 +64,7 @@ describe('Asset module contract', () => {
'GET /minio/resource-proxy',
'GET /minio/download',
'DELETE /minio/remove',
'GET /blog/live2d/pio/:version/*assetPath',
]),
);
});
@ -97,20 +101,37 @@ describe('Asset module contract', () => {
);
expect(getModuleMetadata(AssetModule, MODULE_METADATA.CONTROLLERS)).toEqual(
expect.arrayContaining([MinioClientController]),
expect.arrayContaining([
MinioClientController,
BlogLive2DAssetController,
]),
);
expect(getModuleMetadata(AssetModule, MODULE_METADATA.PROVIDERS)).toEqual(
expect.arrayContaining([MinioClientService]),
expect.arrayContaining([MinioClientService, BlogLive2DAssetService]),
);
expect(assetExports).toEqual(
expect.arrayContaining([MinioClientService, BlogLive2DAssetService]),
);
expect(assetExports).toEqual(expect.arrayContaining([MinioClientService]));
expect(ASSET_CONTROLLERS).toEqual(
expect.arrayContaining([MinioClientController]),
expect.arrayContaining([
MinioClientController,
BlogLive2DAssetController,
]),
);
expect(ASSET_PROVIDERS).toEqual(
expect.arrayContaining([MinioClientService]),
expect.arrayContaining([MinioClientService, BlogLive2DAssetService]),
);
});
it('marks the Blog Live2D runtime stream as an explicit public asset route', () => {
expect(
Reflect.getMetadata(
IS_PUBLIC_KEY,
BlogLive2DAssetController.prototype.getPioAsset,
),
).toBe(true);
});
it('matches the real Batch 3 Asset SQL schema and boundary contract', () => {
expect(ASSET_DOMAIN_CONTRACT.tables).toEqual([
'asset_bucket',