2026-06-28-template-make-task.md 39 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: 构建真实可用的发型模板制作闭环,支持动态素材、模板制作任务页、step1/step2 AI 编排,以及成功后回写 hair_template。

Architecture: 后端以 hair_template 作为模板实体、以 hair_template_make_task 作为制作过程状态源;前端创建模板后进入新的任务详情页,持续轮询本系统后端;后端在任务详情查询时刷新 step1/step2 外部 AI 任务状态并推进下一阶段,避免引入额外的后台 worker。

Tech Stack: NestJS 11、Prisma 6、Jest、React 19、React Router 7、Vitest、Testing Library

Global Constraints

  • 数据库语义必须严格遵循 backend/docs/数据库设计.md 中的 hair_template、hair_template_make_task、system_config 定义。
  • 本次不新增后台常驻 worker、消息队列或定时补偿任务。
  • 模板新建页沿用现有 UI 骨架,不做大重构。
  • 参考发型图片上传类型固定为 ai-template-origin-image。
  • 模特、服装、背景都采用“预设或上传二选一”,最终统一提交 URL。
  • 模特来自 system_config.config_group = 'ai-model',服装来自 clothing,背景来自 background。
  • step1 模板地址来自 system_config.config_group = 'template-step1' 的 config_value。
  • backend/src/step2 只保存静态 config.json / content.json,运行时文件必须写入系统临时目录。
  • step2 成功后才回写 hair_template.cover_url 与 hair_template.template_url,此前模板不可视为可用模板。
  • 前端只轮询本系统后端,不能直连 AI 服务。
  • 测试优先覆盖真实回归风险:创建双写、详情推进、前端轮询、动态素材提交。

File Structure

Backend

  • Modify: backend/prisma/schema.prisma
    • 补 SystemConfig Prisma model,并为模板任务链路补必要的 enum/字段映射引用。
  • Modify: backend/src/modules/templates/templates.module.ts
    • 注入 ConfigModule、StorageModule、PrismaModule、AI client 和任务 runner。
  • Modify: backend/src/modules/templates/templates.controller.ts
    • 承载 GET /templates/assets、POST /templates/make-tasks、GET /templates/make-tasks/:taskId、模板列表/详情接口入口。
  • Modify: backend/src/modules/templates/templates.service.ts
    • 承载素材查询、创建任务、查询任务详情、模板列表/详情查询。
  • Create: backend/src/modules/templates/template-ai.client.ts
    • 抽象可复用的 executeTemplate() 和 getResult()。
  • Create: backend/src/modules/templates/template-task-runner.ts
    • 负责 step1 发起、step1 刷新、step2 组包发起、step2 刷新与回写。
  • Create: backend/src/modules/templates/template-task-files.ts
    • 负责临时目录、下载文件、复制 step2 静态文件、打 zip、清理目录。
  • Create: backend/src/modules/templates/dto/get-template-assets.dto.ts
    • 定义素材列表返回 DTO。
  • Create: backend/src/modules/templates/dto/create-template-make-task.dto.ts
    • 定义创建任务请求 DTO。
  • Create: backend/src/modules/templates/dto/template-make-task-detail.dto.ts
    • 定义任务详情返回 DTO。
  • Create: backend/src/modules/templates/dto/list-templates-item.dto.ts
    • 收敛模板列表项含任务状态的返回结构。
  • Create: backend/src/modules/templates/templates.service.spec.ts
    • 单元测试模板创建、状态推进与失败路径。
  • Create: backend/test/templates.e2e-spec.ts
    • E2E 覆盖素材接口、创建任务、查询任务详情。

Frontend

  • Modify: frontend/src/config/routes.ts
    • 增加模板任务页路由。
  • Modify: frontend/src/App.tsx
    • 注册新路由。
  • Modify: frontend/src/services/api/contracts.ts
    • 增加模板素材、创建任务、任务详情相关类型,并为模板列表增加 makeTaskId / makeTaskStatus。
  • Modify: frontend/src/services/api/templates.ts
    • 增加 getTemplateAssets()、createTemplateMakeTask()、getTemplateMakeTaskById()。
  • Modify: frontend/src/pages/dashboard/templates/TemplateFormPage.tsx
    • 改为动态素材与上传切换,提交后跳任务页。
  • Create: frontend/src/pages/dashboard/templates/TemplateMakeTaskPage.tsx
    • 展示任务进度并轮询。
  • Create: frontend/src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx
    • 覆盖动态素材、上传切换、提交跳转。
  • Create: frontend/src/pages/dashboard/templates/__tests__/TemplateMakeTaskPage.test.tsx
    • 覆盖处理中轮询、成功停止轮询、失败展示。
  • Modify: frontend/src/pages/dashboard/templates/TemplateListPage.tsx
    • 根据模板任务状态决定跳任务页或模板详情页。
  • Modify: frontend/src/__tests__/App.test.tsx
    • 覆盖模板任务页路由。
  • Modify: frontend/src/i18n/locales/zh/translation.json
  • Modify: frontend/src/i18n/locales/ja/translation.json
    • 增加任务页、动态素材、失败文案。
  • Modify: frontend/public/css/aitori.css
    • 追加模板任务页与素材来源切换样式。

Task 1: 搭好后端模板任务基础设施

Files:

  • Create: backend/src/modules/templates/template-ai.client.ts
  • Create: backend/src/modules/templates/dto/get-template-assets.dto.ts
  • Create: backend/src/modules/templates/dto/create-template-make-task.dto.ts
  • Create: backend/src/modules/templates/dto/template-make-task-detail.dto.ts
  • Modify: backend/prisma/schema.prisma
  • Modify: backend/src/modules/templates/templates.module.ts
  • Test: backend/src/modules/templates/templates.service.spec.ts

Interfaces:

  • Consumes: PrismaService, ConfigService, StorageService
  • Produces: TemplateAiClient.executeTemplate(template: string, inputs: Record<string, unknown>): Promise<{ taskId: string }>
  • Produces: TemplateAiClient.getResult(taskId: string): Promise<{ status: 'processing' | 'successes' | 'failed'; message: string; images: string[] }>
  • Produces: CreateTemplateMakeTaskDto
  • Produces: TemplateAssetsDto
  • Produces: Prisma model SystemConfig

  • [ ] Step 1: 写后端单元测试骨架,锁定将来依赖的接口名

    describe('TemplatesService', () => {
    it('lists grouped template assets from system_config', async () => {
    const result = await service.getTemplateAssets();
    
    expect(result).toEqual({
      models: expect.any(Array),
      clothes: expect.any(Array),
      backgrounds: expect.any(Array),
    });
    });
    });
    

Run: cd /Users/yuchen/Documents/hairstylist/backend && npm test -- --runInBand src/modules/templates/templates.service.spec.ts Expected: FAIL with Cannot find module './templates.service.spec.ts' or missing method/type errors.

  • [ ] Step 2: 在 Prisma schema 中补 SystemConfig model

    model SystemConfig {
    id          BigInt            @id @default(autoincrement()) @db.UnsignedBigInt
    configKey   String            @map("config_key") @db.VarChar(100)
    configName  String            @map("config_name") @db.VarChar(200)
    configGroup String            @map("config_group") @db.VarChar(50)
    configValue String?           @map("config_value") @db.Text
    valueType   SystemConfigValueType @default(STRING) @map("value_type")
    sort        Int               @default(0)
    remark      String?           @default("") @db.VarChar(500)
    createTime  DateTime          @default(now()) @map("create_time")
    updateTime  DateTime          @updatedAt @map("update_time")
    isDeleted   Int               @default(0) @map("is_deleted") @db.UnsignedTinyInt
    
    @@unique([configKey, isDeleted], map: "uk_config_key")
    @@index([configGroup, isDeleted], map: "idx_config_group")
    @@map("system_config")
    }
    

Run: cd /Users/yuchen/Documents/hairstylist/backend && npm run prisma:validate Expected: PASS with The schema at prisma/schema.prisma is valid.

  • [ ] Step 3: 创建模板任务 DTO 与通用 AI client

    export class CreateTemplateMakeTaskDto {
    @IsString()
    templateName!: string;
    
    @IsIn(['LONG', 'MEDIUM', 'SHORT', 'DYE'])
    category!: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE';
    
    @IsIn(['DRAFT', 'PUBLIC'])
    status!: 'DRAFT' | 'PUBLIC';
    
    @IsOptional()
    @IsString()
    description?: string;
    
    @IsOptional()
    @IsArray()
    @IsString({ each: true })
    tags?: string[];
    
    @IsUrl()
    refHairUrl!: string;
    
    @IsUrl()
    modelUrl!: string;
    
    @IsUrl()
    clothesUrl!: string;
    
    @IsUrl()
    backgroundUrl!: string;
    }
    
    @Injectable()
    export class TemplateAiClient {
    constructor(private readonly configService: ConfigService) {}
    
    async executeTemplate(template: string, inputs: Record<string, unknown>) {
    const response = await fetch(`${this.configService.getOrThrow<string>('aiSimulation.baseUrl')}/execute`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ template, inputs }),
    });
    
    if (!response.ok) {
      throw new ServiceUnavailableException('AI 模板任务创建失败');
    }
    
    const data = (await response.json()) as { taskId?: string };
    if (!data.taskId) {
      throw new ServiceUnavailableException('AI 模板任务创建失败');
    }
    
    return { taskId: data.taskId };
    }
    }
    
  • [ ] Step 4: 把新依赖接入 TemplatesModule

    @Module({
    imports: [AuthModule, PrismaModule, ConfigModule, StorageModule],
    controllers: [TemplatesController],
    providers: [TemplatesService, TemplateAiClient, TemplateTaskRunner],
    })
    export class TemplatesModule {}
    
  • [ ] Step 5: 让测试通过到“素材接口可被定义”的程度

    async getTemplateAssets() {
    return {
    models: [],
    clothes: [],
    backgrounds: [],
    };
    }
    

Run: cd /Users/yuchen/Documents/hairstylist/backend && npm test -- --runInBand src/modules/templates/templates.service.spec.ts Expected: PASS for the new skeleton test.

  • [ ] Step 6: 提交本任务

    cd /Users/yuchen/Documents/hairstylist
    git add backend/prisma/schema.prisma backend/src/modules/templates
    git commit -m "feat: scaffold template make task backend"
    

Task 2: 实现后端素材接口与创建任务双写

Files:

  • Modify: backend/src/modules/templates/templates.controller.ts
  • Modify: backend/src/modules/templates/templates.service.ts
  • Modify: backend/src/modules/templates/templates.service.spec.ts
  • Create: backend/src/modules/templates/dto/list-templates-item.dto.ts
  • Test: backend/test/templates.e2e-spec.ts

Interfaces:

  • Consumes: CreateTemplateMakeTaskDto, TemplateAiClient.executeTemplate()
  • Produces: TemplatesService.getTemplateAssets(): Promise<{ models: AssetOptionDto[]; clothes: AssetOptionDto[]; backgrounds: AssetOptionDto[] }>
  • Produces: TemplatesService.createTemplateMakeTask(stylistId: bigint, dto: CreateTemplateMakeTaskDto): Promise<{ templateId: string; taskId: string; totalTaskId: string; taskStatus: 'WAIT' | 'PROCESSING' | 'FAILED' }>
  • Produces: GET /api/v1/templates/assets
  • Produces: POST /api/v1/templates/make-tasks

  • [ ] Step 1: 先写创建任务单元测试

    it('creates hair_template and hair_template_make_task, then starts step1', async () => {
    prisma.systemConfig.findMany.mockResolvedValue([
    { id: BigInt(9), configGroup: 'template-step1', configValue: 'https://cdn.example.com/step1.json', isDeleted: 0 },
    ]);
    aiClient.executeTemplate.mockResolvedValue({ taskId: 'step1-task-1' });
    prisma.$transaction.mockImplementation(async (callback) =>
    callback({
      hairTemplate: { create: jest.fn().mockResolvedValue({ id: BigInt(101) }) },
      hairTemplateMakeTask: { create: jest.fn().mockResolvedValue({ id: BigInt(88) }) },
    }),
    );
    
    const result = await service.createTemplateMakeTask(BigInt(1), dto);
    
    expect(result).toEqual({
    templateId: '101',
    taskId: '88',
    totalTaskId: expect.stringContaining('tmpl_'),
    taskStatus: 'PROCESSING',
    });
    });
    

Run: cd /Users/yuchen/Documents/hairstylist/backend && npm test -- --runInBand src/modules/templates/templates.service.spec.ts Expected: FAIL with createTemplateMakeTask is not a function or unmet mock expectations.

  • [ ] Step 2: 实现素材接口

    async getTemplateAssets() {
    const rows = await this.prisma.systemConfig.findMany({
    where: {
      isDeleted: 0,
      configGroup: { in: ['ai-model', 'clothing', 'background'] },
    },
    orderBy: [{ sort: 'asc' }, { id: 'asc' }],
    });
    
    const mapRow = (row: SystemConfig) => ({
    id: row.id.toString(),
    name: row.configName,
    url: row.configValue ?? '',
    configKey: row.configKey,
    });
    
    return {
    models: rows.filter((row) => row.configGroup === 'ai-model').map(mapRow),
    clothes: rows.filter((row) => row.configGroup === 'clothing').map(mapRow),
    backgrounds: rows.filter((row) => row.configGroup === 'background').map(mapRow),
    };
    }
    
  • [ ] Step 3: 实现创建任务双写与 step1 发起

    async createTemplateMakeTask(stylistId: bigint, dto: CreateTemplateMakeTaskDto) {
    const step1Template = await this.getRequiredStep1TemplateUrl();
    const totalTaskId = `tmpl_${Date.now()}_${randomUUID()}`;
    
    const created = await this.prisma.$transaction(async (tx) => {
    const template = await tx.hairTemplate.create({
      data: {
        templateName: dto.templateName,
        coverUrl: '',
        status: dto.status,
        category: dto.category,
        tags: (dto.tags ?? []).join(','),
        description: dto.description ?? null,
        modelUrl: dto.modelUrl,
        clothesUrl: dto.clothesUrl,
        backgroundUrl: dto.backgroundUrl,
        refHairUrl: dto.refHairUrl,
        hairstylistId: stylistId,
        templateUrl: '',
        isSystemTemplate: 'NO',
      },
    });
    
    const task = await tx.hairTemplateMakeTask.create({
      data: {
        totalTaskId,
        step1TaskId: '',
        step2TaskId: '',
        taskStatus: 'WAIT',
        templateId: template.id,
        step1Resource: JSON.stringify({ templateUrl: step1Template, lastError: null }),
        step2Resource: JSON.stringify({ uploadedZipUrl: null, lastError: null }),
        refHairUrl: dto.refHairUrl,
        modelUrl: dto.modelUrl,
        clothesUrl: dto.clothesUrl,
        backgroundUrl: dto.backgroundUrl,
      },
    });
    
    return { template, task };
    });
    
    const step1 = await this.templateAiClient.executeTemplate(step1Template, {
    image: dto.refHairUrl,
    });
    
    await this.prisma.hairTemplateMakeTask.update({
    where: { id: created.task.id },
    data: {
      step1TaskId: step1.taskId,
      taskStatus: 'PROCESSING',
      step1Resource: JSON.stringify({ templateUrl: step1Template, lastError: null }),
    },
    });
    
    return {
    templateId: created.template.id.toString(),
    taskId: created.task.id.toString(),
    totalTaskId,
    taskStatus: 'PROCESSING' as const,
    };
    }
    
  • [ ] Step 4: 暴露控制器接口

    @UseGuards(JwtAuthGuard)
    @Controller('templates')
    export class TemplatesController {
    @Get('assets')
    assets() {
    return this.templatesService.getTemplateAssets();
    }
    
    @Post('make-tasks')
    createMakeTask(@Req() request: AuthenticatedRequest, @Body() body: CreateTemplateMakeTaskDto) {
    return this.templatesService.createTemplateMakeTask(BigInt(request.user.stylistId), body);
    }
    }
    
  • [ ] Step 5: 补 E2E 测试

    it('/api/v1/templates/assets (GET) returns grouped asset options', async () => {
    const token = await signToken();
    
    await prisma.systemConfig.createMany({
    data: [
      { configKey: 'e2e-model', configName: '模特', configGroup: 'ai-model', configValue: 'https://cdn.example.com/model.jpg' },
      { configKey: 'e2e-clothes', configName: '服装', configGroup: 'clothing', configValue: 'https://cdn.example.com/clothes.jpg' },
      { configKey: 'e2e-background', configName: '背景', configGroup: 'background', configValue: 'https://cdn.example.com/background.jpg' },
    ] as never,
    });
    
    const response = await request(app.getHttpServer())
    .get('/api/v1/templates/assets')
    .set('Authorization', `Bearer ${token}`)
    .expect(200);
    
    expect(response.body.data.models[0]).toMatchObject({ name: '模特' });
    });
    
    it('/api/v1/templates/make-tasks (POST) creates template and task rows', async () => {
    const token = await signToken();
    executeTemplate.mockResolvedValueOnce({ taskId: 'template-e2e-step1' });
    
    const response = await request(app.getHttpServer())
    .post('/api/v1/templates/make-tasks')
    .set('Authorization', `Bearer ${token}`)
    .send({
      templateName: 'e2e-template',
      category: 'LONG',
      status: 'DRAFT',
      refHairUrl: 'https://cdn.example.com/ref.jpg',
      modelUrl: 'https://cdn.example.com/model.jpg',
      clothesUrl: 'https://cdn.example.com/clothes.jpg',
      backgroundUrl: 'https://cdn.example.com/background.jpg',
    })
    .expect(201);
    
    expect(response.body.data.taskStatus).toBe('PROCESSING');
    });
    

Run: cd /Users/yuchen/Documents/hairstylist/backend && npm test -- --runInBand src/modules/templates/templates.service.spec.ts && npm run test:e2e -- templates.e2e-spec.ts Expected: PASS for assets/create task tests.

  • [ ] Step 6: 提交本任务

    cd /Users/yuchen/Documents/hairstylist
    git add backend/src/modules/templates backend/test/templates.e2e-spec.ts
    git commit -m "feat: add template make task creation APIs"
    

Task 3: 实现 step1/step2 推进、zip 组装与任务详情接口

Files:

  • Create: backend/src/modules/templates/template-task-files.ts
  • Create: backend/src/modules/templates/template-task-runner.ts
  • Modify: backend/src/modules/templates/templates.service.ts
  • Modify: backend/src/modules/templates/templates.controller.ts
  • Modify: backend/src/modules/templates/templates.service.spec.ts
  • Modify: backend/test/templates.e2e-spec.ts

Interfaces:

  • Consumes: TemplateAiClient.getResult(), StorageService.uploadBuffer()
  • Produces: TemplateTaskRunner.refreshTask(stylistId: bigint, taskId: string): Promise<TemplateMakeTaskDetailDto>
  • Produces: GET /api/v1/templates/make-tasks/:taskId
  • Produces: helper prepareStep2Bundle(input: PrepareStep2BundleInput): Promise<{ zipUrl: string; localFiles: Record<string, string> }>

  • [ ] Step 1: 先写任务推进单元测试

    it('promotes a task from PROCESSING to STEP1_SUCCESS when step1 finishes', async () => {
    prisma.hairTemplateMakeTask.findFirst.mockResolvedValue({
    id: BigInt(88),
    templateId: BigInt(101),
    taskStatus: 'PROCESSING',
    step1TaskId: 'step1-task-1',
    step2TaskId: '',
    step1ResultImg: '',
    refHairUrl: 'https://cdn.example.com/ref.jpg',
    modelUrl: 'https://cdn.example.com/model.jpg',
    clothesUrl: 'https://cdn.example.com/clothes.jpg',
    backgroundUrl: 'https://cdn.example.com/background.jpg',
    step1Resource: JSON.stringify({ templateUrl: 'https://cdn.example.com/step1.json' }),
    step2Resource: JSON.stringify({}),
    updateTime: new Date(Date.now() - 20_000),
    });
    aiClient.getResult.mockResolvedValue({
    status: 'successes',
    message: '',
    images: ['https://cdn.example.com/step1-result.jpg'],
    });
    fileRunner.downloadToTempFile.mockResolvedValue('/tmp/task-1/hairstyle.jpg');
    
    const detail = await service.getTemplateMakeTaskDetail(BigInt(1), '88');
    
    expect(prisma.hairTemplateMakeTask.update).toHaveBeenCalledWith(
    expect.objectContaining({
      data: expect.objectContaining({
        taskStatus: 'STEP1_SUCCESS',
        step1ResultImg: 'https://cdn.example.com/step1-result.jpg',
      }),
    }),
    );
    expect(detail.step1.resultImage).toBe('https://cdn.example.com/step1-result.jpg');
    });
    

Run: cd /Users/yuchen/Documents/hairstylist/backend && npm test -- --runInBand src/modules/templates/templates.service.spec.ts Expected: FAIL because getTemplateMakeTaskDetail and file helpers are missing.

  • [ ] Step 2: 实现临时文件和 zip 组装 helper

    export async function prepareStep2Bundle(input: PrepareStep2BundleInput) {
    const workDir = await mkdtemp(join(tmpdir(), `template-make-${input.totalTaskId}-`));
    
    await copyFile(input.step2ConfigPath, join(workDir, 'config.json'));
    await copyFile(input.step2ContentPath, join(workDir, 'content.json'));
    await downloadFile(input.step1ResultImg, join(workDir, 'hairstyle.jpg'));
    await downloadFile(input.clothesUrl, join(workDir, 'input-outfit.jpg'));
    await downloadFile(input.backgroundUrl, join(workDir, 'input-background.jpg'));
    
    const zipPath = join(workDir, 'template.zip');
    await zipDirectory(workDir, zipPath, ['template.zip']);
    
    return {
    workDir,
    zipPath,
    localFiles: {
      step1Image: join(workDir, 'hairstyle.jpg'),
      clothes: join(workDir, 'input-outfit.jpg'),
      background: join(workDir, 'input-background.jpg'),
      zip: zipPath,
    },
    };
    }
    
  • [ ] Step 3: 实现 TemplateTaskRunner.refreshTask()

    async refreshTask(stylistId: bigint, taskId: string) {
    const task = await this.getOwnedTask(stylistId, taskId);
    
    if (task.taskStatus === 'WAIT') {
    return this.tryStartStep1(task);
    }
    
    if (task.taskStatus === 'PROCESSING' && task.step1TaskId && !task.step1ResultImg) {
    return this.refreshStep1(task);
    }
    
    if (task.taskStatus === 'STEP1_SUCCESS' && !task.step2TaskId) {
    return this.startStep2(task);
    }
    
    if (task.taskStatus === 'PROCESSING' && task.step2TaskId) {
    return this.refreshStep2(task);
    }
    
    return this.buildDetail(task);
    }
    
  • [ ] Step 4: 在 step2 成功时回写 hair_template

    await this.prisma.$transaction([
    this.prisma.hairTemplate.update({
    where: { id: task.templateId! },
    data: {
      coverUrl: resultImage,
      templateUrl: uploadedZipUrl,
    },
    }),
    this.prisma.hairTemplateMakeTask.update({
    where: { id: task.id },
    data: {
      taskStatus: 'STEP2_SUCCESS',
      step2ResultImg: resultImage,
      step2Resource: JSON.stringify({
        uploadedZipUrl,
        resultImage,
        lastError: null,
      }),
    },
    }),
    ]);
    
  • [ ] Step 5: 暴露详情接口并补 e2e

    @Get('make-tasks/:taskId')
    detail(@Req() request: AuthenticatedRequest, @Param('taskId') taskId: string) {
    return this.templatesService.getTemplateMakeTaskDetail(BigInt(request.user.stylistId), taskId);
    }
    
    it('/api/v1/templates/make-tasks/:taskId (GET) promotes step1 and step2 states', async () => {
    getResult
    .mockResolvedValueOnce({ status: 'successes', message: '', images: ['https://cdn.example.com/step1.jpg'] })
    .mockResolvedValueOnce({ status: 'successes', message: '', images: ['https://cdn.example.com/final-cover.jpg'] });
    
    const response = await request(app.getHttpServer())
    .get(`/api/v1/templates/make-tasks/${taskId}`)
    .set('Authorization', `Bearer ${token}`)
    .expect(200);
    
    expect(response.body.data.taskStatus).toMatch(/STEP1_SUCCESS|PROCESSING|STEP2_SUCCESS/);
    });
    

Run: cd /Users/yuchen/Documents/hairstylist/backend && npm test -- --runInBand src/modules/templates/templates.service.spec.ts && npm run test:e2e -- templates.e2e-spec.ts Expected: PASS for step promotion, bundle upload, and detail endpoint tests.

  • [ ] Step 6: 提交本任务

    cd /Users/yuchen/Documents/hairstylist
    git add backend/src/modules/templates backend/test/templates.e2e-spec.ts
    git commit -m "feat: add template make task polling workflow"
    

Task 4: 接入前端 API、路由与任务页骨架

Files:

  • Modify: frontend/src/config/routes.ts
  • Modify: frontend/src/App.tsx
  • Modify: frontend/src/services/api/contracts.ts
  • Modify: frontend/src/services/api/templates.ts
  • Create: frontend/src/pages/dashboard/templates/TemplateMakeTaskPage.tsx
  • Modify: frontend/src/__tests__/App.test.tsx

Interfaces:

  • Consumes: GET /templates/assets, POST /templates/make-tasks, GET /templates/make-tasks/:taskId
  • Produces: TemplateAssetOption, TemplateMakeTaskCreateResult, TemplateMakeTaskDetail
  • Produces: route /:locale/templates/tasks/:taskId

  • [ ] Step 1: 先写前端路由测试

    it('renders template make task route', async () => {
    window.history.replaceState({}, '', '/zh/templates/tasks/88');
    
    render(<App />);
    
    await waitFor(() => {
    expect(screen.getByText('template-make-task-page')).toBeInTheDocument();
    });
    });
    

Run: cd /Users/yuchen/Documents/hairstylist/frontend && npm test -- src/__tests__/App.test.tsx Expected: FAIL because the new page route is not registered.

  • [ ] Step 2: 扩展 contracts 与 API 方法

    export type TemplateAssetOption = {
    id: string;
    name: string;
    url: string;
    configKey: string;
    };
    
    export type TemplateMakeTaskDetail = {
    id: string;
    templateId: string;
    templateName: string;
    taskStatus: 'WAIT' | 'PROCESSING' | 'STEP1_SUCCESS' | 'STEP2_SUCCESS' | 'FAILED';
    errorMessage: string | null;
    createTime: string;
    refHairUrl: string;
    modelUrl: string;
    clothesUrl: string;
    backgroundUrl: string;
    step1: {
    taskId: string;
    status: string;
    templateUrl: string | null;
    resultImage: string | null;
    };
    step2: {
    taskId: string;
    status: string;
    bundleUrl: string | null;
    resultImage: string | null;
    };
    template: {
    coverUrl: string;
    templateUrl: string;
    status: 'DRAFT' | 'PUBLIC';
    };
    };
    
    export type TemplateEntity = {
    id: string;
    name: string;
    category: string;
    status: string;
    description: string;
    tags: string[];
    image?: string;
    makeTaskId?: string;
    makeTaskStatus?: 'WAIT' | 'PROCESSING' | 'STEP1_SUCCESS' | 'STEP2_SUCCESS' | 'FAILED';
    templateUrl?: string;
    };
    
    export async function getTemplateAssets() {
    return http.get<{ models: TemplateAssetOption[]; clothes: TemplateAssetOption[]; backgrounds: TemplateAssetOption[] }>('/templates/assets');
    }
    
    export async function createTemplateMakeTask(input: CreateTemplateMakeTaskInput) {
    return http.post<TemplateMakeTaskCreateResult>('/templates/make-tasks', input);
    }
    
    export async function getTemplateMakeTaskById(taskId: string) {
    return http.get<TemplateMakeTaskDetail>(`/templates/make-tasks/${encodeURIComponent(taskId)}`);
    }
    
  • [ ] Step 3: 注册路由与占位页面

    export function TemplateMakeTaskPage() {
    return <div>template-make-task-page</div>;
    }
    
    <Route
    path={routes.templateTaskDetail}
    element={
    <RequireAuth>
      <TemplateMakeTaskPage />
    </RequireAuth>
    }
    />
    
    templateTaskDetail: '/:locale/templates/tasks/:taskId',
    
  • [ ] Step 4: 让路由测试通过

Run: cd /Users/yuchen/Documents/hairstylist/frontend && npm test -- src/__tests__/App.test.tsx Expected: PASS with the new route rendered.

  • [ ] Step 5: 提交本任务

    cd /Users/yuchen/Documents/hairstylist
    git add frontend/src/config/routes.ts frontend/src/App.tsx frontend/src/services/api frontend/src/pages/dashboard/templates/TemplateMakeTaskPage.tsx frontend/src/__tests__/App.test.tsx
    git commit -m "feat: scaffold template make task frontend route"
    

Task 5: 改造模板新建页为动态素材与任务跳转

Files:

  • Modify: frontend/src/pages/dashboard/templates/TemplateFormPage.tsx
  • Create: frontend/src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx
  • Modify: frontend/src/services/api/media.ts
  • Modify: frontend/src/i18n/locales/zh/translation.json
  • Modify: frontend/src/i18n/locales/ja/translation.json
  • Modify: frontend/public/css/aitori.css

Interfaces:

  • Consumes: uploadMedia(file, type), getTemplateAssets(), createTemplateMakeTask()
  • Produces: form submit payload { templateName, category, status, description, tags, refHairUrl, modelUrl, clothesUrl, backgroundUrl }
  • Produces: custom upload media type ai-template-origin-image

  • [ ] Step 1: 先写模板新建页测试

    it('loads dynamic assets, uploads custom files, and redirects to the task page', async () => {
    getTemplateAssets.mockResolvedValue({
    models: [{ id: 'm1', name: '模特A', url: 'https://cdn.example.com/model-a.jpg', configKey: 'm1' }],
    clothes: [{ id: 'c1', name: '服装A', url: 'https://cdn.example.com/clothes-a.jpg', configKey: 'c1' }],
    backgrounds: [{ id: 'b1', name: '背景A', url: 'https://cdn.example.com/background-a.jpg', configKey: 'b1' }],
    });
    uploadMedia.mockResolvedValue({ url: 'https://cdn.example.com/uploaded-model.jpg' });
    createTemplateMakeTask.mockResolvedValue({ taskId: '88', templateId: '101', totalTaskId: 'tmpl_x', taskStatus: 'PROCESSING' });
    
    render(<TemplateFormPage />);
    
    expect(await screen.findByText('模特A')).toBeInTheDocument();
    await userEvent.click(screen.getByRole('radio', { name: '上传自定义模特' }));
    await userEvent.upload(screen.getByLabelText('上传模特图片'), new File(['x'], 'model.jpg', { type: 'image/jpeg' }));
    await userEvent.click(screen.getByRole('button', { name: 'AI生成' }));
    
    await waitFor(() => {
    expect(createTemplateMakeTask).toHaveBeenCalledWith(expect.objectContaining({
      modelUrl: 'https://cdn.example.com/uploaded-model.jpg',
    }));
    expect(navigate).toHaveBeenCalledWith('/zh/templates/tasks/88');
    });
    });
    

Run: cd /Users/yuchen/Documents/hairstylist/frontend && npm test -- src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx Expected: FAIL because getTemplateAssets and task redirect behavior do not exist.

  • [ ] Step 2: 调整上传类型和素材加载逻辑

    const [assets, setAssets] = useState<{ models: TemplateAssetOption[]; clothes: TemplateAssetOption[]; backgrounds: TemplateAssetOption[] }>({
    models: [],
    clothes: [],
    backgrounds: [],
    });
    
    useEffect(() => {
    getTemplateAssets().then(setAssets).catch(() => setAssets({ models: [], clothes: [], backgrounds: [] }));
    }, []);
    
    const referenceImage = (await uploadMedia(referenceFile, 'ai-template-origin-image')).url;
    
  • [ ] Step 3: 为模特、服装、背景增加“预设/上传”切换

    type AssetSource = 'preset' | 'upload';
    
    const [modelSource, setModelSource] = useState<AssetSource>('preset');
    const [uploadedModelUrl, setUploadedModelUrl] = useState('');
    
    const finalModelUrl =
    modelSource === 'upload'
    ? uploadedModelUrl
    : assets.models.find((item) => item.id === selectedModelId)?.url ?? '';
    
    <button type="button" onClick={() => setModelSource('preset')}>使用预设</button>
    <button type="button" onClick={() => setModelSource('upload')}>上传自定义模特</button>
    
  • [ ] Step 4: 改为调用创建任务接口并跳任务页

    const task = await createTemplateMakeTask({
    templateName: name,
    category:
    category === 'Long'
      ? 'LONG'
      : category === 'Medium'
        ? 'MEDIUM'
        : category === 'Short'
          ? 'SHORT'
          : 'DYE',
    status: status === 'Published' ? 'PUBLIC' : 'DRAFT',
    description,
    tags: tags.split(',').map((tag) => tag.trim()).filter(Boolean),
    refHairUrl: referenceImage,
    modelUrl: finalModelUrl,
    clothesUrl: finalClothesUrl,
    backgroundUrl: finalBackgroundUrl,
    });
    
    navigate(path(`/templates/tasks/${task.taskId}`));
    
  • [ ] Step 5: 更新文案与样式

    "assetSource": {
    "preset": "使用预设",
    "upload": "上传自定义"
    }
    
    .template-source-toggle {
    display: flex;
    gap: 12px;
    margin-bottom: 16px;
    }
    
  • [ ] Step 6: 运行测试

Run: cd /Users/yuchen/Documents/hairstylist/frontend && npm test -- src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx && npm run typecheck Expected: PASS with dynamic asset and redirect behavior verified.

  • [ ] Step 7: 提交本任务

    cd /Users/yuchen/Documents/hairstylist
    git add frontend/src/pages/dashboard/templates/TemplateFormPage.tsx frontend/src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx frontend/src/services/api/media.ts frontend/src/i18n/locales frontend/public/css/aitori.css
    git commit -m "feat: wire template form to make task flow"
    

Task 6: 完成模板任务页轮询与模板列表跳转分流

Files:

  • Modify: frontend/src/pages/dashboard/templates/TemplateMakeTaskPage.tsx
  • Create: frontend/src/pages/dashboard/templates/__tests__/TemplateMakeTaskPage.test.tsx
  • Modify: frontend/src/pages/dashboard/templates/TemplateListPage.tsx
  • Modify: frontend/src/i18n/locales/zh/translation.json
  • Modify: frontend/src/i18n/locales/ja/translation.json
  • Modify: frontend/public/css/aitori.css

Interfaces:

  • Consumes: getTemplateMakeTaskById(taskId: string): Promise<TemplateMakeTaskDetail>
  • Consumes: TemplateEntity.makeTaskId?: string
  • Consumes: TemplateEntity.makeTaskStatus?: 'WAIT' | 'PROCESSING' | 'STEP1_SUCCESS' | 'STEP2_SUCCESS' | 'FAILED'
  • Produces: processing poll interval 1500ms
  • Produces: list item navigation rule makeTaskStatus in ('WAIT','PROCESSING','STEP1_SUCCESS','FAILED') -> /templates/tasks/:makeTaskId

  • [ ] Step 1: 先写任务页轮询测试

    it('polls until the task reaches STEP2_SUCCESS and then shows the detail CTA', async () => {
    vi.useFakeTimers();
    getTemplateMakeTaskById
    .mockResolvedValueOnce({
      id: '88',
      templateId: '101',
      templateName: '柔和层次长发',
      taskStatus: 'PROCESSING',
      errorMessage: null,
      createTime: '2026-06-28T12:00:00.000Z',
      refHairUrl: 'https://cdn.example.com/ref.jpg',
      modelUrl: 'https://cdn.example.com/model.jpg',
      clothesUrl: 'https://cdn.example.com/clothes.jpg',
      backgroundUrl: 'https://cdn.example.com/background.jpg',
      step1: { taskId: 'step1', status: 'PROCESSING', templateUrl: 'https://cdn.example.com/step1.json', resultImage: null },
      step2: { taskId: '', status: 'WAIT', bundleUrl: null, resultImage: null },
      template: { coverUrl: '', templateUrl: '', status: 'DRAFT' },
    })
    .mockResolvedValueOnce({
      id: '88',
      templateId: '101',
      templateName: '柔和层次长发',
      taskStatus: 'STEP2_SUCCESS',
      errorMessage: null,
      createTime: '2026-06-28T12:00:00.000Z',
      refHairUrl: 'https://cdn.example.com/ref.jpg',
      modelUrl: 'https://cdn.example.com/model.jpg',
      clothesUrl: 'https://cdn.example.com/clothes.jpg',
      backgroundUrl: 'https://cdn.example.com/background.jpg',
      step1: { taskId: 'step1', status: 'STEP1_SUCCESS', templateUrl: 'https://cdn.example.com/step1.json', resultImage: 'https://cdn.example.com/step1.jpg' },
      step2: { taskId: 'step2', status: 'STEP2_SUCCESS', bundleUrl: 'https://cdn.example.com/template.zip', resultImage: 'https://cdn.example.com/final.jpg' },
      template: { coverUrl: 'https://cdn.example.com/final.jpg', templateUrl: 'https://cdn.example.com/template.zip', status: 'DRAFT' },
    });
    
    render(<TemplateMakeTaskPage />);
    
    await act(async () => {
    await Promise.resolve();
    });
    
    await act(async () => {
    await vi.advanceTimersByTimeAsync(1500);
    });
    
    expect(getTemplateMakeTaskById).toHaveBeenCalledTimes(2);
    expect(screen.getByRole('link', { name: '查看模板详情' })).toBeInTheDocument();
    });
    

Run: cd /Users/yuchen/Documents/hairstylist/frontend && npm test -- src/pages/dashboard/templates/__tests__/TemplateMakeTaskPage.test.tsx Expected: FAIL because the page is still a stub.

  • [ ] Step 2: 实现任务页轮询和状态渲染

    useEffect(() => {
    let cancelled = false;
    let timer: number | undefined;
    
    const load = async () => {
    const data = await getTemplateMakeTaskById(params.taskId!);
    if (cancelled) return;
    setTask(data);
    
    if (['WAIT', 'PROCESSING', 'STEP1_SUCCESS'].includes(data.taskStatus)) {
      timer = window.setTimeout(() => {
        void load();
      }, 1500);
    }
    };
    
    void load();
    
    return () => {
    cancelled = true;
    if (timer) window.clearTimeout(timer);
    };
    }, [params.taskId]);
    
    {task.taskStatus === 'STEP2_SUCCESS' ? (
    <Link to={path(`/templates/${task.templateId}`)} className="btn btn-large btn-rounded btn-base-color">
    {pageCopy.actions.viewTemplate}
    </Link>
    ) : null}
    
  • [ ] Step 3: 为模板列表增加跳转分流

    const getTemplateTarget = (template: TemplateEntity) => {
    if (
    template.makeTaskId &&
    ['WAIT', 'PROCESSING', 'STEP1_SUCCESS', 'FAILED'].includes(template.makeTaskStatus ?? '')
    ) {
    return path(`/templates/tasks/${template.makeTaskId}`);
    }
    
    return path(`/templates/${template.id}`);
    };
    
    <Link to={getTemplateTarget(template)} className="d-block text-decoration-none">
    
  • [ ] Step 4: 更新任务页文案与样式

    "templateTaskPage": {
    "title": "模板制作任务",
    "actions": {
    "viewTemplate": "查看模板详情"
    }
    }
    
    .template-task-layout {
    display: grid;
    grid-template-columns: minmax(0, 380px) minmax(0, 1fr);
    gap: 24px;
    }
    
    .template-task-step-card.is-success {
    border-color: rgba(82, 196, 26, 0.28);
    }
    
  • [ ] Step 5: 跑前端回归测试

Run: cd /Users/yuchen/Documents/hairstylist/frontend && npm test -- src/pages/dashboard/templates/__tests__/TemplateMakeTaskPage.test.tsx src/__tests__/App.test.tsx && npm run typecheck Expected: PASS with polling and routing behavior verified.

  • [ ] Step 6: 提交本任务

    cd /Users/yuchen/Documents/hairstylist
    git add frontend/src/pages/dashboard/templates/TemplateMakeTaskPage.tsx frontend/src/pages/dashboard/templates/TemplateListPage.tsx frontend/src/pages/dashboard/templates/__tests__/TemplateMakeTaskPage.test.tsx frontend/src/i18n/locales frontend/public/css/aitori.css
    git commit -m "feat: add template make task page"
    

Task 7: 全量验证、文档同步与收尾

Files:

  • Modify: docs/superpowers/specs/2026-06-28-template-make-task-design.md (only if implementation diverges)
  • Modify: backend/docs/数据库设计.md (only if doc drift is found and user-approved)

Interfaces:

  • Consumes: all prior tasks
  • Produces: verified working feature branch state

  • [ ] Step 1: 运行后端验证

Run: cd /Users/yuchen/Documents/hairstylist/backend && npm run lint && npm test -- --runInBand src/modules/templates/templates.service.spec.ts && npm run test:e2e -- templates.e2e-spec.ts Expected: PASS for lint, unit tests, and e2e tests.

  • Step 2: 运行前端验证

Run: cd /Users/yuchen/Documents/hairstylist/frontend && npm run typecheck && npm test -- src/pages/dashboard/templates/__tests__/TemplateFormPage.test.tsx src/pages/dashboard/templates/__tests__/TemplateMakeTaskPage.test.tsx src/__tests__/App.test.tsx Expected: PASS for typecheck and targeted template flow tests.

  • [ ] Step 3: 手动冒烟检查

    1. 登录后台并打开 /zh/templates/new
    2. 选择预设模特,上传自定义服装与背景,提交任务
    3. 确认页面跳转到 /zh/templates/tasks/:taskId
    4. 观察 step1 -> step2 状态推进
    5. 成功后点击“查看模板详情”进入模板详情页
    6. 在模板列表里确认该模板卡片可进入正确页面
    

Expected: 整条链路无阻塞,成功产出封面图与模板包 URL。

  • [ ] Step 4: 如实现偏离 spec,则同步更新文档

    - 若最终接口字段或任务页文案与 spec 不一致,立即更新 `docs/superpowers/specs/2026-06-28-template-make-task-design.md`
    - 若数据库文档存在新增事实字段或语义修正,先确认后再更新 `backend/docs/数据库设计.md`
    
  • [ ] Step 5: 提交收尾

    cd /Users/yuchen/Documents/hairstylist
    git add .
    git commit -m "feat: finish template make task flow"
    

Self-Review Checklist

  • Spec coverage:
    • 动态素材接口 -> Task 2
    • 创建模板双写 -> Task 2
    • step1/step2 推进、zip 上传、回写模板 -> Task 3
    • 前端新任务页、轮询 -> Task 4 / Task 6
    • 模板新建页动态素材与跳转 -> Task 5
    • 模板列表跳转分流 -> Task 6
    • 验证与文档同步 -> Task 7
  • Placeholder scan:
    • 未使用 TBD、TODO、later
    • 每个任务包含明确文件、接口、命令和预期结果
  • Type consistency:
    • 统一使用 CreateTemplateMakeTaskDto / TemplateMakeTaskDetail
    • 统一使用 TemplateAiClient.executeTemplate() / getResult()
    • 路由统一为 /:locale/templates/tasks/:taskId