feat: 支持Pio Live2D根目录索引

This commit is contained in:
sunlei 2026-07-05 19:40:29 +08:00
parent d6135a2012
commit 53a590b09d
7 changed files with 136 additions and 22 deletions

3
API.md
View File

@ -331,11 +331,12 @@ WordPress rewrite 未开启导致 `/wp-json/*` 返回 404 时,后端会回退
| `GET` | `/minio/resource-proxy` | 代理读取资源 |
| `GET` | `/minio/download` | 下载文件流 |
| `DELETE` | `/minio/remove` | 删除文件 |
| `GET` | `/blog/live2d/pio/catalog.json` | 公开读取 Pio Live2D 公共目录索引,按 Referer/Origin 白名单防盗链 |
| `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、反斜杠、`.`/`..` 和多重编码后的路径逃逸。
Blog Live2D 运行包读取入口不使用 Vben 响应包装,直接返回 MinIO 文件流。`catalog.json` 暴露当前 Pio 版本、目录规范和动作/贴图计数versioned asset 路由读取 `manifest.json`、runtime、model、motion、shader 和 source texture 文件。`BLOG_LIVE2D_ALLOWED_ORIGINS` 配置允许的 Blog 源,例如 `https://blog.kwitsukasa.top,http://localhost:5999``BLOG_LIVE2D_BUCKET` 和 `BLOG_LIVE2D_PREFIX` 决定对象位置。路由支持嵌套资源路径,如 `assets/textures/manifest.json` 和 `assets/model/motions/breath1.motion3.json`,并拒绝绝对 URL、反斜杠、`.`/`..` 和多重编码后的路径逃逸。
## QQBot 管理

View File

@ -71,7 +71,7 @@ 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 前拒绝。
Blog Live2D 运行包存放在 MinIO公开读取入口为 `/blog/live2d/pio/catalog.json` 和 `/blog/live2d/pio/:version/*assetPath`。`catalog.json` 是 Pio 公共目录索引versioned asset 路由读取 `v1/manifest.json`、runtime、model、motion、shader、source texture 等文件。`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` 仍表示单次执行预算。

View File

@ -89,6 +89,24 @@ export class BlogLive2DAssetService {
}
}
/**
* Streams the Pio root catalog from the configured MinIO prefix.
* @returns MinIO stream and stat metadata for `catalog.json`.
*/
async getCatalogObject(): Promise<BlogLive2DAssetResult> {
try {
return await this.minioClientService.getObject(
[...this.getPrefixSegments(), 'catalog.json'].join('/'),
this.getBucketName(),
);
} catch (error) {
if (isMinioObjectNotFound(error)) {
throw new NotFoundException('Live2D runtime asset not found');
}
throw error;
}
}
/**
* Builds the MinIO object key for a versioned Pio runtime file.
* @param version - Runtime release segment supplied by the route.

View File

@ -14,6 +14,34 @@ export class BlogLive2DAssetController {
*/
constructor(private readonly blogLive2DAssetService: BlogLive2DAssetService) {}
/**
* Streams the public Pio root catalog after Origin/Referer validation.
* @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/catalog.json')
@ApiOperation({ summary: '获取 Pio Live2D 目录规范索引' })
@ApiFileDownloadResponse('Pio Live2D root catalog stream')
@Public()
async getPioCatalog(
@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.getCatalogObject();
res.setHeader(
'Content-Type',
stat.metaData?.['content-type'] || 'application/json',
);
res.setHeader('Cache-Control', this.getCacheControl(objectName));
stream.pipe(res);
}
/**
* Streams a versioned Pio runtime asset after Origin/Referer validation.
* @param version - Runtime version segment such as `v1`.
@ -55,6 +83,7 @@ export class BlogLive2DAssetController {
*/
private getCacheControl(objectName: string): string {
return objectName.endsWith('manifest.json') ||
objectName.endsWith('catalog.json') ||
objectName.endsWith('.model3.json')
? 'public, max-age=60'
: 'public, max-age=31536000, immutable';

View File

@ -11,7 +11,8 @@ export class BlogLive2DManifestDto {
desktopOnly: boolean;
@ApiProperty({
example: '/api/blog/live2d/pio/v1/pio.model3.json',
example:
'/api/blog/live2d/pio/v1/assets/model/pio.moc-reconstructed.model3.json',
})
model3: string;
}

View File

@ -33,21 +33,22 @@ function createConfig(overrides: Record<string, string | undefined> = {}) {
function createMinio() {
return {
getDefaultBucket: jest.fn(() => 'kt-template-online'),
getObject: jest.fn(async (objectName: string, bucketName?: string) => ({
getObject: jest.fn(async (objectName: string, bucketName?: string) => {
const isJson = objectName.endsWith('.json');
return {
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',
'content-type': isJson ? 'application/json' : 'image/png',
},
size: 2,
},
stream: Readable.from(['ok']),
})),
stream: Readable.from([isJson ? '{"ok":true}' : 'ok']),
};
}),
};
}
@ -102,6 +103,21 @@ describe('BlogLive2DAssetService', () => {
);
});
it('maps the root catalog below the configured MinIO prefix', async () => {
const minio = createMinio();
const service = new BlogLive2DAssetService(
minio as never,
createConfig() as never,
);
await service.getCatalogObject();
expect(minio.getObject).toHaveBeenCalledWith(
'blog/live2d/pio/catalog.json',
'kt-template-online',
);
});
it('maps nested runtime files below the configured MinIO prefix', async () => {
const minio = createMinio();
const service = new BlogLive2DAssetService(
@ -109,10 +125,15 @@ describe('BlogLive2DAssetService', () => {
createConfig() as never,
);
await service.getRuntimeObject('v1', ['textures', 'texture_00.png']);
await service.getRuntimeObject('v1', [
'assets',
'model',
'motions',
'breath1.motion3.json',
]);
expect(minio.getObject).toHaveBeenCalledWith(
'blog/live2d/pio/v1/textures/texture_00.png',
'blog/live2d/pio/v1/assets/model/motions/breath1.motion3.json',
'kt-template-online',
);
});
@ -176,6 +197,43 @@ describe('BlogLive2DAssetService', () => {
});
describe('BlogLive2DAssetController', () => {
it('streams the Pio root catalog 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/catalog.json')
.set('Referer', 'https://blog.kwitsukasa.top/post/1')
.expect(HttpStatus.OK);
expect(response.body).toEqual({ ok: true });
expect(response.headers['content-type']).toContain('application/json');
expect(response.headers['cache-control']).toBe('public, max-age=60');
expect(minio.getObject).toHaveBeenCalledWith(
'blog/live2d/pio/catalog.json',
'kt-template-online',
);
} finally {
await app.close();
}
});
it('streams nested Pio runtime assets for allowed blog requests', async () => {
const minio = createMinio();
const moduleRef = await Test.createTestingModule({
@ -197,7 +255,7 @@ describe('BlogLive2DAssetController', () => {
try {
const response = await request(app.getHttpServer())
.get('/blog/live2d/pio/v1/textures/texture_00.png')
.get('/blog/live2d/pio/v1/assets/textures/default-costume.png')
.set('Referer', 'https://blog.kwitsukasa.top/post/1')
.expect(HttpStatus.OK);
@ -207,7 +265,7 @@ describe('BlogLive2DAssetController', () => {
'public, max-age=31536000, immutable',
);
expect(minio.getObject).toHaveBeenCalledWith(
'blog/live2d/pio/v1/textures/texture_00.png',
'blog/live2d/pio/v1/assets/textures/default-costume.png',
'kt-template-online',
);
} finally {
@ -236,7 +294,7 @@ describe('BlogLive2DAssetController', () => {
try {
await request(app.getHttpServer())
.get('/blog/live2d/pio/v1/pio.model3.json')
.get('/blog/live2d/pio/v1/assets/model/pio.moc-reconstructed.model3.json')
.set('Referer', 'https://example.com/post/1')
.expect(HttpStatus.BAD_REQUEST);
expect(minio.getObject).not.toHaveBeenCalled();

View File

@ -64,6 +64,7 @@ describe('Asset module contract', () => {
'GET /minio/resource-proxy',
'GET /minio/download',
'DELETE /minio/remove',
'GET /blog/live2d/pio/catalog.json',
'GET /blog/live2d/pio/:version/*assetPath',
]),
);
@ -124,6 +125,12 @@ describe('Asset module contract', () => {
});
it('marks the Blog Live2D runtime stream as an explicit public asset route', () => {
expect(
Reflect.getMetadata(
IS_PUBLIC_KEY,
BlogLive2DAssetController.prototype.getPioCatalog,
),
).toBe(true);
expect(
Reflect.getMetadata(
IS_PUBLIC_KEY,