From 53a590b09d9ebcf48c2f582846bccd3920239236 Mon Sep 17 00:00:00 2001 From: sunlei Date: Sun, 5 Jul 2026 19:40:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81Pio=20Live2D=E6=A0=B9?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E7=B4=A2=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 3 +- README.md | 2 +- .../application/blog-live2d-asset.service.ts | 18 ++++ .../contract/blog-live2d-asset.controller.ts | 29 ++++++ .../asset/contract/blog-live2d-asset.dto.ts | 3 +- test/asset/blog-live2d-asset.service.spec.ts | 96 +++++++++++++++---- .../asset/asset-module-contract.spec.ts | 7 ++ 7 files changed, 136 insertions(+), 22 deletions(-) diff --git a/API.md b/API.md index 8c266da..fc72d18 100644 --- a/API.md +++ b/API.md @@ -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 管理 diff --git a/README.md b/README.md index cd621b0..6f6c268 100644 --- a/README.md +++ b/README.md @@ -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` 仍表示单次执行预算。 diff --git a/src/modules/asset/application/blog-live2d-asset.service.ts b/src/modules/asset/application/blog-live2d-asset.service.ts index 9e14c36..f1f391d 100644 --- a/src/modules/asset/application/blog-live2d-asset.service.ts +++ b/src/modules/asset/application/blog-live2d-asset.service.ts @@ -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 { + 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. diff --git a/src/modules/asset/contract/blog-live2d-asset.controller.ts b/src/modules/asset/contract/blog-live2d-asset.controller.ts index 49d9872..d10f909 100644 --- a/src/modules/asset/contract/blog-live2d-asset.controller.ts +++ b/src/modules/asset/contract/blog-live2d-asset.controller.ts @@ -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'; diff --git a/src/modules/asset/contract/blog-live2d-asset.dto.ts b/src/modules/asset/contract/blog-live2d-asset.dto.ts index 5314e60..ef93e67 100644 --- a/src/modules/asset/contract/blog-live2d-asset.dto.ts +++ b/src/modules/asset/contract/blog-live2d-asset.dto.ts @@ -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; } diff --git a/test/asset/blog-live2d-asset.service.spec.ts b/test/asset/blog-live2d-asset.service.spec.ts index e3e834a..8b2a47b 100644 --- a/test/asset/blog-live2d-asset.service.spec.ts +++ b/test/asset/blog-live2d-asset.service.spec.ts @@ -33,21 +33,22 @@ function createConfig(overrides: Record = {}) { 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', + 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': isJson ? 'application/json' : 'image/png', + }, + size: 2, }, - 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(); diff --git a/test/modules/asset/asset-module-contract.spec.ts b/test/modules/asset/asset-module-contract.spec.ts index 78ca901..b19a5fe 100644 --- a/test/modules/asset/asset-module-contract.spec.ts +++ b/test/modules/asset/asset-module-contract.spec.ts @@ -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,