2026-06-28-template-usage-summary.md 21 KB

模板使用量与顶部统计 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<TemplateDashboardSummary>

  • [ ] Step 1: 在后端服务单测里写出 summary 接口的失败测试

    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 与数据库设计文档

    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
    }
    
    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 路由

    @Get('summary')
    summary(@Req() request: AuthenticatedRequest) {
    return this.templatesService.getSummary(BigInt(request.user.stylistId));
    }
    
  • [ ] Step 6: 在模板服务中实现 summary 方法骨架

    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 接口

    export async function getTemplateDashboardSummary(): Promise<TemplateDashboardSummary> {
    return http.get<TemplateDashboardSummary>('/templates/summary')
    }
    
  • [ ] Step 8: 再次运行后端模板单测,确认 getSummary 测试变成 weeklyUsed 逻辑失败

Run: npm test -- --runInBand backend/src/modules/templates/templates.service.spec.ts

Expected: FAIL,weeklyUsed 断言不通过,当前实际值为 0

  • [ ] Step 9: 提交第一段骨架改动

    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

    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 过滤与时间范围过滤

    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

    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<string, { id: bigint; taskStatus: string }>,
    ) {
    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() 中实现本周成功任务统计

    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 接口断言

    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

    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 统计逻辑

    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: 在模拟服务单测中写出首次成功累加使用量的失败测试

    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: 写出重复成功不重复累加的失败测试

    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: 在任务成功刷新分支中补充一次性递增逻辑

    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: 保持“只在首次成功时累加一次”的边界

    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 模拟成功累计逻辑

    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<TemplateDashboardSummary>
  • Consumes: listTemplates(): Promise<PaginatedTemplateList>
  • Produces: TemplateListPage 使用真实 summary,失败回退为 0

  • [ ] Step 1: 在前端列表页测试中写出失败断言,要求初始值为 0 而不是假默认值

    it('renders zero summary values when the summary request fails', async () => {
    getTemplateDashboardSummary.mockRejectedValueOnce(new Error('boom'))
    
    render(
    <HelmetProvider>
      <MemoryRouter>
        <TemplateListPage />
      </MemoryRouter>
    </HelmetProvider>,
    )
    
    expect(await screen.findByText('已登记模板')).toBeInTheDocument()
    expect(screen.getAllByText('0').length).toBeGreaterThanOrEqual(4)
    })
    
  • [ ] Step 2: 在前端列表页测试中锁定真实 summary 与真实使用量文案

    it('renders real summary values and template usedCount from the API', async () => {
    render(
    <HelmetProvider>
      <MemoryRouter>
        <TemplateListPage />
      </MemoryRouter>
    </HelmetProvider>,
    )
    
    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

    const initialSummary: TemplateDashboardSummary = {
    total: 0,
    published: 0,
    weeklyUsed: 0,
    draft: 0,
    }
    
  • [ ] Step 5: 保持失败时回退到全 0,继续使用真实接口结果

    useEffect(() => {
    getTemplateDashboardSummary()
    .then(setSummary)
    .catch(() => setSummary(initialSummary))
    }, [])
    
  • [ ] Step 6: 确认前端 API 适配器仍对 usedCount 做数字兜底

    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: 提交前端接线改动

    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 作为统计接口路径。