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
0,不再展示假默认值。Files:
backend/prisma/schema.prismabackend/docs/数据库设计.mdbackend/src/modules/templates/templates.controller.tsbackend/src/modules/templates/templates.service.tsfrontend/src/services/api/templates.tsbackend/src/modules/templates/templates.service.spec.tsbackend/test/templates.e2e-spec.tsInterfaces:
TemplatesService.list(query: ListTemplatesQueryDto & { stylistId: bigint })TemplatesService.getSummary(stylistId: bigint): Promise<{ total: number; published: number; weeklyUsed: number; draft: number }>GET /api/v1/templates/summaryProduces: 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"
Files:
backend/src/modules/templates/templates.service.tsbackend/src/modules/templates/templates.service.spec.tsbackend/test/templates.e2e-spec.tsInterfaces:
TemplatesService.getSummary(stylistId)TemplatesService.toTemplateListItem(template, taskMap)list() 返回的 usedCount 来自 hair_template.used_countProduces: 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"
Files:
backend/src/modules/simulations/simulations.service.tsbackend/src/modules/simulations/simulations.service.spec.tsInterfaces:
this.aiSimulationClient.getResult(task.taskId)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"
Files:
frontend/src/pages/dashboard/templates/TemplateListPage.tsxfrontend/src/pages/dashboard/templates/__tests__/TemplateListPage.test.tsxfrontend/src/services/api/templates.tsfrontend/src/services/api/contracts.tsInterfaces:
getTemplateDashboardSummary(): Promise<TemplateDashboardSummary>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 成功与失败两类断言
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"
usedCount、顶部 4 统计、GET /templates/summary、AI 模拟成功自增、前后端测试与失败回退行为。TBD、TODO、类似 Task N 等占位描述;代码步骤均给出明确片段和命令。usedCount 作为前后端字段名、getSummary(stylistId) 作为服务方法名、GET /api/v1/templates/summary 作为统计接口路径。