# 模板使用量与顶部统计 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 为“我的发型模板”页面补齐真实的模板使用量与顶部 4 个统计,并在 AI 发型模拟成功时自动累计模板使用次数。 **Architecture:** 以 `hair_template.used_count` 保存模板累计使用量,列表接口直接读取该字段;顶部 `weeklyUsed` 保持周增量语义,单独从 `ai_hair_simulation_task` 统计本周成功任务数。后端在模板模块新增 `GET /templates/summary`,前端移除假默认统计并接入真实数据;AI 模拟成功刷新链路中补充一次性 `used_count + 1`。 **Tech Stack:** NestJS, Prisma, Jest, Supertest, React, TypeScript, Vitest, Testing Library ## Global Constraints - 模板列表 UI 结构不变,只替换数据来源。 - “使用量”统一定义为“成功用于 AI 发型模拟的次数”。 - “本周使用”统一定义为当前自然周内的 AI 发型模拟成功任务数,不是累计值。 - 当前项目没有历史数据,本轮不做历史数据回填。 - 数据库新增字段必须保留注释。 - 前端 summary 请求失败时回退到 `0`,不再展示假默认值。 --- ### Task 1: 数据模型与模板统计接口骨架 **Files:** - Modify: `backend/prisma/schema.prisma` - Modify: `backend/docs/数据库设计.md` - Modify: `backend/src/modules/templates/templates.controller.ts` - Modify: `backend/src/modules/templates/templates.service.ts` - Modify: `frontend/src/services/api/templates.ts` - Test: `backend/src/modules/templates/templates.service.spec.ts` - Test: `backend/test/templates.e2e-spec.ts` **Interfaces:** - Consumes: `TemplatesService.list(query: ListTemplatesQueryDto & { stylistId: bigint })` - Produces: `TemplatesService.getSummary(stylistId: bigint): Promise<{ total: number; published: number; weeklyUsed: number; draft: number }>` - Produces: `GET /api/v1/templates/summary` - Produces: `getTemplateDashboardSummary(): Promise` - [ ] **Step 1: 在后端服务单测里写出 summary 接口的失败测试** ```ts it('returns template summary for the current stylist', async () => { prisma.hairTemplate.count .mockResolvedValueOnce(6) .mockResolvedValueOnce(4) .mockResolvedValueOnce(2); prisma.aiHairSimulationTask.count.mockResolvedValue(9); prisma.hairTemplate.findMany.mockResolvedValue([ { id: BigInt(101) }, { id: BigInt(102) }, ]); await expect(service.getSummary(BigInt(1))).resolves.toEqual({ total: 6, published: 4, weeklyUsed: 9, draft: 2, }); }); ``` - [ ] **Step 2: 运行单测,确认 `getSummary` 还不存在** Run: `npm test -- --runInBand backend/src/modules/templates/templates.service.spec.ts` Expected: FAIL,报错包含 `service.getSummary is not a function` 或 `Property 'getSummary' does not exist` - [ ] **Step 3: 更新 Prisma schema 与数据库设计文档** ```prisma model HairTemplate { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt templateName String @map("template_name") @db.VarChar(100) coverUrl String @default("") @map("cover_url") @db.VarChar(500) status HairTemplateStatus category HairTemplateCategory tags String @default("") @db.VarChar(500) description String? @db.Text modelUrl String @default("") @map("model_url") @db.VarChar(500) clothesUrl String @default("") @map("clothes_url") @db.VarChar(500) backgroundUrl String @default("") @map("background_url") @db.VarChar(500) refHairUrl String @default("") @map("ref_hair_url") @db.VarChar(500) hairstylistId BigInt? @map("hairstylist_id") @db.UnsignedBigInt templateUrl String @default("") @map("template_url") @db.VarChar(500) usedCount Int @default(0) @map("used_count") @db.UnsignedInt isSystemTemplate HairTemplateSystemFlag @default(NO) @map("is_system_template") createTime DateTime @default(now()) @map("create_time") updateTime DateTime @updatedAt @map("update_time") isDeleted Int @default(0) @map("is_deleted") @db.UnsignedTinyInt } ``` ```sql used_count int unsigned default 0 not null comment '模板累计使用次数', ``` - [ ] **Step 4: 生成 Prisma migration** Run: `npx prisma migrate dev --name add_template_used_count` Expected: PASS,输出包含 `Applying migration` 与 `Generated Prisma Client` - [ ] **Step 5: 在模板控制器中新增 summary 路由** ```ts @Get('summary') summary(@Req() request: AuthenticatedRequest) { return this.templatesService.getSummary(BigInt(request.user.stylistId)); } ``` - [ ] **Step 6: 在模板服务中实现 summary 方法骨架** ```ts async getSummary(stylistId: bigint): Promise<{ total: number; published: number; weeklyUsed: number; draft: number; }> { const [total, published, draft] = await Promise.all([ this.prisma.hairTemplate.count({ where: { hairstylistId: stylistId, isDeleted: 0 }, }), this.prisma.hairTemplate.count({ where: { hairstylistId: stylistId, isDeleted: 0, status: 'PUBLIC' }, }), this.prisma.hairTemplate.count({ where: { hairstylistId: stylistId, isDeleted: 0, status: 'DRAFT' }, }), ]); return { total, published, weeklyUsed: 0, draft, }; } ``` - [ ] **Step 7: 更新前端 API 路径到真实 summary 接口** ```ts export async function getTemplateDashboardSummary(): Promise { return http.get('/templates/summary') } ``` - [ ] **Step 8: 再次运行后端模板单测,确认 `getSummary` 测试变成 weeklyUsed 逻辑失败** Run: `npm test -- --runInBand backend/src/modules/templates/templates.service.spec.ts` Expected: FAIL,`weeklyUsed` 断言不通过,当前实际值为 `0` - [ ] **Step 9: 提交第一段骨架改动** ```bash git add backend/prisma/schema.prisma "backend/docs/数据库设计.md" backend/src/modules/templates/templates.controller.ts backend/src/modules/templates/templates.service.ts frontend/src/services/api/templates.ts git commit -m "feat: add template usage summary endpoint skeleton" ``` ### Task 2: 完成模板列表真实使用量与 summary 统计 **Files:** - Modify: `backend/src/modules/templates/templates.service.ts` - Modify: `backend/src/modules/templates/templates.service.spec.ts` - Modify: `backend/test/templates.e2e-spec.ts` **Interfaces:** - Consumes: `TemplatesService.getSummary(stylistId)` - Consumes: `TemplatesService.toTemplateListItem(template, taskMap)` - Produces: `list()` 返回的 `usedCount` 来自 `hair_template.used_count` - Produces: `getSummary()` 真实返回 `weeklyUsed` - [ ] **Step 1: 扩展模板服务单测,锁定列表项读取真实 `usedCount`** ```ts it('maps usedCount from hair_template.used_count when listing templates', async () => { prisma.hairTemplate.findMany.mockResolvedValue([ { id: BigInt(102), templateName: '空气感中长发', category: 'MEDIUM', status: 'PUBLIC', description: '适合脸周轻盈修饰', tags: '空气感,脸周', coverUrl: 'https://cdn.example.com/cover-102.jpg', usedCount: 7, isDeleted: 0, }, ]); prisma.hairTemplate.count.mockResolvedValue(1); prisma.hairTemplateMakeTask.findMany.mockResolvedValue([]); const result = await service.list({ stylistId: BigInt(1), q: '', category: '', status: '', page: 1, pageSize: 8, }); expect(result.items[0]?.usedCount).toBe(7); }); ``` - [ ] **Step 2: 扩展 summary 单测,锁定本周模板 ID 过滤与时间范围过滤** ```ts expect(prisma.hairTemplate.findMany).toHaveBeenCalledWith({ where: { hairstylistId: BigInt(1), isDeleted: 0, }, select: { id: true, }, }); expect(prisma.aiHairSimulationTask.count).toHaveBeenCalledWith({ where: expect.objectContaining({ taskStatus: 'SUCCESSES', isDeleted: 0, templateId: { in: ['101', '102'] }, }), }); ``` - [ ] **Step 3: 运行模板服务单测,确认列表与 summary 统计还未通过** Run: `npm test -- --runInBand backend/src/modules/templates/templates.service.spec.ts` Expected: FAIL,`usedCount` 仍为 `0`,或 `weeklyUsed` 统计调用参数不匹配 - [ ] **Step 4: 在 `toTemplateListItem()` 中返回真实 `usedCount`** ```ts private toTemplateListItem( template: { id: bigint; templateName: string; category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE'; status: 'DRAFT' | 'PUBLIC'; description: string | null; tags: string; coverUrl: string; usedCount: number; }, taskMap: Map, ) { const task = taskMap.get(template.id.toString()); return { id: template.id.toString(), name: template.templateName, category: template.category, status: template.status, description: template.description ?? '', tags: template.tags ? template.tags.split(',').map((tag) => tag.trim()).filter(Boolean) : [], image: template.coverUrl, makeTaskId: task?.id.toString(), makeTaskStatus: task?.taskStatus, usedCount: template.usedCount ?? 0, }; } ``` - [ ] **Step 5: 在 `getSummary()` 中实现本周成功任务统计** ```ts async getSummary(stylistId: bigint): Promise<{ total: number; published: number; weeklyUsed: number; draft: number; }> { const now = new Date(); const day = now.getUTCDay(); const diffToMonday = day === 0 ? 6 : day - 1; const startOfWeek = new Date( Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - diffToMonday), ); const endOfWeek = new Date( Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - diffToMonday + 7), ); const [total, published, draft, templateRows] = await Promise.all([ this.prisma.hairTemplate.count({ where: { hairstylistId: stylistId, isDeleted: 0 }, }), this.prisma.hairTemplate.count({ where: { hairstylistId: stylistId, isDeleted: 0, status: 'PUBLIC' }, }), this.prisma.hairTemplate.count({ where: { hairstylistId: stylistId, isDeleted: 0, status: 'DRAFT' }, }), this.prisma.hairTemplate.findMany({ where: { hairstylistId: stylistId, isDeleted: 0 }, select: { id: true }, }), ]); const templateIds = templateRows.map((item) => item.id.toString()); const weeklyUsed = templateIds.length ? await this.prisma.aiHairSimulationTask.count({ where: { taskStatus: 'SUCCESSES', isDeleted: 0, templateId: { in: templateIds }, taskFinishTime: { gte: startOfWeek, lt: endOfWeek, }, }, }) : 0; return { total, published, weeklyUsed, draft }; } ``` - [ ] **Step 6: 在 E2E 中新增真实 summary 接口断言** ```ts it('/api/v1/templates/summary (GET) returns real template dashboard summary', async () => { const token = await signToken(); hairTemplates.push( { id: 101n, templateName: '空气感中长发', status: 'PUBLIC', hairstylistId: 1n, usedCount: 5, isDeleted: 0 }, { id: 102n, templateName: '厚重短发', status: 'DRAFT', hairstylistId: 1n, usedCount: 1, isDeleted: 0 }, ); aiHairSimulationTasks.push( { id: 501n, templateId: '101', taskStatus: 'SUCCESSES', taskFinishTime: new Date(), isDeleted: 0, }, ); const response = await request(app.getHttpServer()) .get('/api/v1/templates/summary') .set('Authorization', `Bearer ${token}`) .expect(200); expect(response.body.data).toEqual({ total: 2, published: 1, weeklyUsed: 1, draft: 1, }); }); ``` - [ ] **Step 7: 更新现有列表 E2E 断言为真实 `usedCount`** ```ts expect(response.body.data).toEqual({ items: [ { id: '101', name: '空气感中长发', category: 'MEDIUM', status: 'PUBLIC', description: '空气感提案', tags: ['空气感', '脸周'], image: 'https://cdn.example.com/101.jpg', makeTaskId: '88', makeTaskStatus: 'STEP2_SUCCESS', usedCount: 5, }, ], total: 1, page: 1, pageSize: 1, }); ``` - [ ] **Step 8: 运行后端模板定向测试** Run: `npm test -- --runInBand backend/src/modules/templates/templates.service.spec.ts backend/test/templates.e2e-spec.ts` Expected: PASS,输出包含 `PASS backend/src/modules/templates/templates.service.spec.ts` 和 `PASS backend/test/templates.e2e-spec.ts` - [ ] **Step 9: 提交模板列表与 summary 统计逻辑** ```bash git add backend/src/modules/templates/templates.service.ts backend/src/modules/templates/templates.service.spec.ts backend/test/templates.e2e-spec.ts git commit -m "feat: add real template usage summary data" ``` ### Task 3: 在 AI 模拟成功链路中累计模板使用量 **Files:** - Modify: `backend/src/modules/simulations/simulations.service.ts` - Modify: `backend/src/modules/simulations/simulations.service.spec.ts` **Interfaces:** - Consumes: `this.aiSimulationClient.getResult(task.taskId)` - Consumes: `this.prisma.aiHairSimulationTask.update(...)` - Produces: 首次成功时 `this.prisma.hairTemplate.update({ data: { usedCount: { increment: 1 } } })` - [ ] **Step 1: 在模拟服务单测中写出首次成功累加使用量的失败测试** ```ts it('increments template usedCount when a processing task first becomes successes', async () => { prisma.aiHairSimulation.findFirst.mockResolvedValue({ id: BigInt(201), hairstylistId: BigInt(1), customerId: null, originImage: 'https://cdn.example.com/origin.jpg', simulateRemark: null, createTime: new Date('2026-06-28T12:00:00.000Z'), isDeleted: 0, }); prisma.aiHairSimulationTask.findMany .mockResolvedValueOnce([ { id: BigInt(301), simulationId: BigInt(201), templateId: '101', taskId: 'task-101', taskStatus: 'PROCESSING', updateTime: new Date(Date.now() - 20_000), isDeleted: 0, }, ]) .mockResolvedValueOnce([ { id: BigInt(301), simulationId: BigInt(201), templateId: '101', taskId: 'task-101', taskStatus: 'SUCCESSES', taskFinishTime: new Date(), resultImages: '[]', isDeleted: 0, }, ]); aiSimulationClient.getResult.mockResolvedValue({ status: 'successes', images: ['https://cdn.example.com/result-1.jpg'], message: '', }); await service.getSimulationDetail(BigInt(1), '201'); expect(prisma.hairTemplate.update).toHaveBeenCalledWith({ where: { id: BigInt(101) }, data: { usedCount: { increment: 1 } }, }); }); ``` - [ ] **Step 2: 写出重复成功不重复累加的失败测试** ```ts it('does not increment template usedCount for tasks already marked as successes', async () => { prisma.aiHairSimulation.findFirst.mockResolvedValue({ id: BigInt(201), hairstylistId: BigInt(1), customerId: null, originImage: 'https://cdn.example.com/origin.jpg', simulateRemark: null, createTime: new Date('2026-06-28T12:00:00.000Z'), isDeleted: 0, }); prisma.aiHairSimulationTask.findMany.mockResolvedValue([ { id: BigInt(301), simulationId: BigInt(201), templateId: '101', taskId: 'task-101', taskStatus: 'SUCCESSES', updateTime: new Date(), taskFinishTime: new Date(), resultImages: '[]', isDeleted: 0, }, ]); await service.getSimulationDetail(BigInt(1), '201'); expect(prisma.hairTemplate.update).not.toHaveBeenCalled(); }); ``` - [ ] **Step 3: 运行模拟服务单测,确认当前不会触发 `hairTemplate.update`** Run: `npm test -- --runInBand backend/src/modules/simulations/simulations.service.spec.ts` Expected: FAIL,报错包含 `Expected spy to have been called` - [ ] **Step 4: 在任务成功刷新分支中补充一次性递增逻辑** ```ts if (result.status === 'successes') { const finishTime = new Date(); await this.prisma.aiHairSimulationTask.update({ where: { id: task.id }, data: { taskStatus: 'SUCCESSES', resultImages: JSON.stringify(result.images), taskFinishTime: finishTime, errorMsg: null, }, }); try { const numericTemplateId = BigInt(task.templateId); await this.prisma.hairTemplate.update({ where: { id: numericTemplateId }, data: { usedCount: { increment: 1, }, }, }); } catch { this.logger.warn(`Failed to increment template usage for task ${task.taskId}`); } continue; } ``` - [ ] **Step 5: 保持“只在首次成功时累加一次”的边界** ```ts for (const task of tasks) { if (task.taskStatus !== 'PROCESSING') { continue; } if (!this.shouldRefreshTask(task.updateTime)) { continue; } const result = await this.aiSimulationClient.getResult(task.taskId); if (result.status === 'processing') { continue; } // 只有 PROCESSING -> SUCCESSES 的这条路径才会进入 usedCount 自增 } ``` - [ ] **Step 6: 运行模拟服务单测** Run: `npm test -- --runInBand backend/src/modules/simulations/simulations.service.spec.ts` Expected: PASS,包含新增 2 个测试通过 - [ ] **Step 7: 提交 AI 模拟成功累计逻辑** ```bash git add backend/src/modules/simulations/simulations.service.ts backend/src/modules/simulations/simulations.service.spec.ts git commit -m "feat: increment template usage after successful simulation" ``` ### Task 4: 前端接入真实顶部统计与使用量展示 **Files:** - Modify: `frontend/src/pages/dashboard/templates/TemplateListPage.tsx` - Modify: `frontend/src/pages/dashboard/templates/__tests__/TemplateListPage.test.tsx` - Modify: `frontend/src/services/api/templates.ts` - Modify: `frontend/src/services/api/contracts.ts` **Interfaces:** - Consumes: `getTemplateDashboardSummary(): Promise` - Consumes: `listTemplates(): Promise` - Produces: `TemplateListPage` 使用真实 summary,失败回退为 `0` - [ ] **Step 1: 在前端列表页测试中写出失败断言,要求初始值为 0 而不是假默认值** ```tsx it('renders zero summary values when the summary request fails', async () => { getTemplateDashboardSummary.mockRejectedValueOnce(new Error('boom')) render( , ) expect(await screen.findByText('已登记模板')).toBeInTheDocument() expect(screen.getAllByText('0').length).toBeGreaterThanOrEqual(4) }) ``` - [ ] **Step 2: 在前端列表页测试中锁定真实 summary 与真实使用量文案** ```tsx it('renders real summary values and template usedCount from the API', async () => { render( , ) expect(await screen.findByText('3')).toBeInTheDocument() expect(screen.getByText('2')).toBeInTheDocument() expect(screen.getByText('使用 2次')).toBeInTheDocument() }) ``` - [ ] **Step 3: 运行前端定向测试,确认当前仍依赖假默认值** Run: `npm test -- --runInBand frontend/src/pages/dashboard/templates/__tests__/TemplateListPage.test.tsx` Expected: FAIL,断言 `0` summary 或接口路径相关预期不通过 - [ ] **Step 4: 将列表页 summary 初始值改为全 0** ```ts const initialSummary: TemplateDashboardSummary = { total: 0, published: 0, weeklyUsed: 0, draft: 0, } ``` - [ ] **Step 5: 保持失败时回退到全 0,继续使用真实接口结果** ```ts useEffect(() => { getTemplateDashboardSummary() .then(setSummary) .catch(() => setSummary(initialSummary)) }, []) ``` - [ ] **Step 6: 确认前端 API 适配器仍对 `usedCount` 做数字兜底** ```ts return { ...data, items: data.items.map((item) => ({ ...item, tags: item.tags || [], image: item.image || '/images/aitori-simulation-result.png', usedCount: typeof item.usedCount === 'number' ? item.usedCount : 0, })), } ``` - [ ] **Step 7: 运行前端模板列表测试** Run: `npm test -- --runInBand frontend/src/pages/dashboard/templates/__tests__/TemplateListPage.test.tsx` Expected: PASS,包含 summary 成功与失败两类断言 - [ ] **Step 8: 做一轮相关文件诊断检查** Run: `npm run lint -- src/pages/dashboard/templates/TemplateListPage.tsx src/services/api/templates.ts` Expected: PASS,无新增 lint 错误 - [ ] **Step 9: 提交前端接线改动** ```bash git add frontend/src/pages/dashboard/templates/TemplateListPage.tsx frontend/src/pages/dashboard/templates/__tests__/TemplateListPage.test.tsx frontend/src/services/api/templates.ts git commit -m "feat: show real template usage summary in list page" ``` ## Self-Review - **Spec coverage:** 已覆盖数据库字段、模板列表真实 `usedCount`、顶部 4 统计、`GET /templates/summary`、AI 模拟成功自增、前后端测试与失败回退行为。 - **Placeholder scan:** 计划中未使用 `TBD`、`TODO`、`类似 Task N` 等占位描述;代码步骤均给出明确片段和命令。 - **Type consistency:** 统一使用 `usedCount` 作为前后端字段名、`getSummary(stylistId)` 作为服务方法名、`GET /api/v1/templates/summary` 作为统计接口路径。