瀏覽代碼

docs: add ai simulation implementation plans

ChenYu 4 月之前
父節點
當前提交
f68e7cbd04

+ 1913 - 0
docs/superpowers/plans/2026-06-28-ai-hair-simulation.md

@@ -0,0 +1,1913 @@
+# AI Hair Simulation 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:** Build a real AI hair simulation flow that lets stylists optionally bind a customer, upload an origin image, choose stylist/system templates from `hair_template`, create `ai_hair_simulation` + `ai_hair_simulation_task` records, and view per-template task progress in a new detail page.
+
+**Architecture:** The backend owns task orchestration: it validates customer/template access, calls the external AI service, persists one simulation row plus multiple task rows, and refreshes `PROCESSING` tasks when the detail endpoint is polled. The frontend only talks to backend simulation endpoints, rewrites the create/detail pages around the new DTOs, and keeps `SimulationEditPage` / `AftercareResultPage` as undeveloped shells.
+
+**Tech Stack:** NestJS 11, Prisma + MySQL, React 19, React Router 7, Vitest, Jest, GCS upload API, external AI HTTP API
+
+## Global Constraints
+
+- Implement only the AI hair simulation main flow; do not implement real `aftercare`.
+- Keep `SimulationEditPage` and `AftercareResultPage`, but mark both as undeveloped and remove old business logic.
+- The uploaded origin image must use upload type `ai-user-origin-image`.
+- Template data must come from `hair_template`; AI `POST /execute` must use `hair_template.template_url`.
+- Template list queries must only return rows where `is_deleted = 0`, `status = 'PUBLIC'`, and `template_url` is non-empty.
+- Control-panel entry defaults to “no customer”; customer-detail entry preselects that customer but still allows switching or clearing.
+- The task detail route must be `/:locale/simulation/result/:simulationId`.
+- The task detail page must show child tasks individually, including processing / success / failure and result images.
+- The frontend must poll backend detail only; the backend decides when to refresh `PROCESSING` tasks from the AI service.
+- Treat `ai_hair_simulation_task.template_id` as a string in DTOs and frontend contracts, converting to `BigInt` only inside backend data access.
+- Preserve existing customer detail UI skeleton and only turn its simulation button into a real link.
+- If any SQL migration is created while implementing this plan, every field must keep complete column comments.
+
+---
+
+## File Structure
+
+### Backend
+
+- Modify: `backend/prisma/schema.prisma`
+  - Align Prisma models with the latest `hair_template` design (`templateUrl`, `isSystemTemplate`, enum types).
+- Modify: `backend/src/config/env.schema.ts`
+  - Add AI base URL and refresh/timing environment validation.
+- Modify: `backend/src/config/app.config.ts`
+  - Expose typed `aiSimulation` config object.
+- Modify: `backend/src/config/app.config.spec.ts`
+  - Cover the new config mapping.
+- Create: `backend/src/modules/simulations/dto/list-simulation-templates-query.dto.ts`
+  - Query DTO for template source/category/search/pagination.
+- Create: `backend/src/modules/simulations/dto/simulation-template-option.dto.ts`
+  - Template list response DTO shape.
+- Create: `backend/src/modules/simulations/dto/simulation-detail.dto.ts`
+  - Detail DTO with customer summary, counts, overall status, child tasks.
+- Create: `backend/src/modules/simulations/ai-simulation.client.ts`
+  - External AI HTTP client (`execute`, `getResult`).
+- Modify: `backend/src/modules/simulations/dto/create-simulation.dto.ts`
+  - Replace old `customerMode/simulationType` contract with the new request shape.
+- Modify: `backend/src/modules/simulations/simulations.controller.ts`
+  - Add `GET /simulations/template-options` and `GET /simulations/:id`.
+- Modify: `backend/src/modules/simulations/simulations.service.ts`
+  - Implement template listing, create flow, detail aggregation, refresh throttling.
+- Modify: `backend/src/modules/simulations/simulations.module.ts`
+  - Register AI client and any needed providers/imports.
+- Create: `backend/src/modules/simulations/simulations.service.spec.ts`
+  - Unit tests for template filters, create flow, detail refresh, overall status.
+- Create: `backend/test/simulations.e2e-spec.ts`
+  - Auth + create/detail E2E coverage.
+
+### Frontend
+
+- Modify: `frontend/src/config/routes.ts`
+  - Add `simulationResultDetail` route with `:simulationId`.
+- Modify: `frontend/src/App.tsx`
+  - Point simulation result route to the new path parameter.
+- Modify: `frontend/src/services/api/contracts.ts`
+  - Add `SimulationTemplateOption`, `AiHairSimulationDetail`, `AiHairSimulationTaskDetail`.
+- Modify: `frontend/src/services/api/simulations.ts`
+  - Remove obsolete helpers and expose only template list / create / detail.
+- Modify: `frontend/src/pages/dashboard/simulation/StylistSimulationPage.tsx`
+  - Rewrite as the create page for optional customer + upload + template source tabs.
+- Modify: `frontend/src/pages/dashboard/simulation/SimulationResultPage.tsx`
+  - Rewrite as the task detail page with polling.
+- Modify: `frontend/src/pages/dashboard/simulation/SimulationEditPage.tsx`
+  - Replace old adjustment flow with undeveloped state.
+- Modify: `frontend/src/pages/dashboard/simulation/AftercareResultPage.tsx`
+  - Replace old aftercare flow with undeveloped state.
+- Modify: `frontend/src/pages/dashboard/customers/CustomerDetailPage.tsx`
+  - Turn the simulation CTA into a live navigation link.
+- Modify: `frontend/src/i18n/locales/zh/translation.json`
+  - Add/replace copy for the new create/detail pages and undeveloped shells.
+- Modify: `frontend/src/i18n/locales/ja/translation.json`
+  - Mirror the new copy in Japanese.
+- Modify: `frontend/public/css/aitori.css`
+  - Add styles for template tabs, task cards, counters, and undeveloped states if existing utility classes are insufficient.
+- Create: `frontend/src/pages/dashboard/simulation/__tests__/StylistSimulationPage.test.tsx`
+  - Create-page tests for customer preselect/clear, upload type, submit navigation.
+- Create: `frontend/src/pages/dashboard/simulation/__tests__/SimulationResultPage.test.tsx`
+  - Detail-page tests for polling and task rendering.
+- Create: `frontend/src/pages/dashboard/simulation/__tests__/SimulationPlaceholderPages.test.tsx`
+  - Undeveloped shell coverage for edit/aftercare pages.
+- Modify: `frontend/src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx`
+  - Assert the simulation CTA navigates with `customerId`.
+- Modify: `frontend/src/__tests__/App.test.tsx`
+  - Update route expectations for `/:locale/simulation/result/:simulationId`.
+
+## Task 1: Create Branch And Align Backend Config Surface
+
+**Files:**
+- Modify: `backend/prisma/schema.prisma`
+- Modify: `backend/src/config/env.schema.ts`
+- Modify: `backend/src/config/app.config.ts`
+- Modify: `backend/src/config/app.config.spec.ts`
+
+**Interfaces:**
+- Consumes: Existing `ConfigModule.forRoot()` behavior and current Prisma models.
+- Produces:
+  - `config.aiSimulation.baseUrl: string`
+  - `config.aiSimulation.refreshIntervalMs: number`
+  - `config.aiSimulation.httpTimeoutMs: number`
+  - Prisma `HairTemplate.templateUrl: string`
+  - Prisma `HairTemplate.isSystemTemplate: 'YES' | 'NO'`
+
+- [ ] **Step 1: Create the feature branch**
+
+Run:
+
+```bash
+git switch -c feature/ai-hair-simulation
+```
+
+Expected: terminal prints `Switched to a new branch 'feature/ai-hair-simulation'`
+
+- [ ] **Step 2: Write the failing backend config test**
+
+Add to `backend/src/config/app.config.spec.ts`:
+
+```ts
+it('maps ai simulation config from env', () => {
+  process.env.AI_SIMULATION_BASE_URL = 'http://localhost:8084';
+  process.env.AI_SIMULATION_REFRESH_INTERVAL_MS = '10000';
+  process.env.AI_SIMULATION_HTTP_TIMEOUT_MS = '15000';
+
+  const config = appConfig();
+
+  expect(config.aiSimulation).toEqual({
+    baseUrl: 'http://localhost:8084',
+    refreshIntervalMs: 10000,
+    httpTimeoutMs: 15000,
+  });
+});
+```
+
+- [ ] **Step 3: Run the config spec to verify it fails**
+
+Run:
+
+```bash
+cd /Users/yuchen/Documents/hairstylist/backend
+npm test -- src/config/app.config.spec.ts --runInBand
+```
+
+Expected: FAIL because `aiSimulation` is missing from the config object
+
+- [ ] **Step 4: Implement config + schema alignment**
+
+Update `backend/src/config/env.schema.ts`:
+
+```ts
+AI_SIMULATION_BASE_URL: z.string().url().default('http://localhost:8084'),
+AI_SIMULATION_REFRESH_INTERVAL_MS: z.coerce.number().int().positive().default(10000),
+AI_SIMULATION_HTTP_TIMEOUT_MS: z.coerce.number().int().positive().default(15000),
+```
+
+Update `backend/src/config/app.config.ts`:
+
+```ts
+aiSimulation: {
+  baseUrl: process.env.AI_SIMULATION_BASE_URL ?? 'http://localhost:8084',
+  refreshIntervalMs: Number(process.env.AI_SIMULATION_REFRESH_INTERVAL_MS ?? 10000),
+  httpTimeoutMs: Number(process.env.AI_SIMULATION_HTTP_TIMEOUT_MS ?? 15000),
+},
+```
+
+Update `backend/prisma/schema.prisma` in `HairTemplate`:
+
+```prisma
+enum HairTemplateStatus {
+  DRAFT
+  PUBLIC
+}
+
+enum HairTemplateCategory {
+  LONG
+  MEDIUM
+  SHORT
+  DYE
+}
+
+enum HairTemplateSystemFlag {
+  YES
+  NO
+}
+
+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)
+  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
+
+  @@index([category, isDeleted], map: "idx_category")
+  @@index([hairstylistId, isDeleted], map: "idx_hairstylist_id")
+  @@index([status, isDeleted], map: "idx_status")
+  @@map("hair_template")
+}
+```
+
+- [ ] **Step 5: Run config and Prisma validation**
+
+Run:
+
+```bash
+cd /Users/yuchen/Documents/hairstylist/backend
+npm test -- src/config/app.config.spec.ts --runInBand
+npm run prisma:validate
+```
+
+Expected:
+
+```text
+PASS src/config/app.config.spec.ts
+The schema at prisma/schema.prisma is valid
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add backend/prisma/schema.prisma backend/src/config/env.schema.ts backend/src/config/app.config.ts backend/src/config/app.config.spec.ts
+git commit -m "chore: align ai simulation config and prisma schema"
+```
+
+## Task 2: Build Backend Template Options Endpoint
+
+**Files:**
+- Create: `backend/src/modules/simulations/dto/list-simulation-templates-query.dto.ts`
+- Create: `backend/src/modules/simulations/dto/simulation-template-option.dto.ts`
+- Modify: `backend/src/modules/simulations/simulations.controller.ts`
+- Modify: `backend/src/modules/simulations/simulations.service.ts`
+- Create: `backend/src/modules/simulations/simulations.service.spec.ts`
+
+**Interfaces:**
+- Consumes:
+  - `HairTemplate.status`
+  - `HairTemplate.isSystemTemplate`
+  - logged-in stylist id from existing auth context service/controller pattern
+- Produces:
+  - `GET /api/v1/simulations/template-options`
+  - `listTemplateOptions(stylistId: bigint, query: ListSimulationTemplateOptionsQueryDto): Promise<{ items: SimulationTemplateOptionDto[]; total: number; page: number; pageSize: number }>`
+
+- [ ] **Step 1: Write the failing unit tests for source filtering**
+
+Create `backend/src/modules/simulations/simulations.service.spec.ts` with:
+
+```ts
+it('returns only public stylist templates for the current stylist', async () => {
+  prisma.hairTemplate.findMany.mockResolvedValue([
+    {
+      id: BigInt(11),
+      templateName: 'Layer Bob',
+      coverUrl: 'https://cdn.example.com/bob.png',
+      category: 'SHORT',
+      tags: 'airy,bob',
+      description: 'short bob',
+      templateUrl: 'https://cdn.example.com/bob.zip',
+      isSystemTemplate: 'NO',
+    },
+  ] as any);
+  prisma.hairTemplate.count.mockResolvedValue(1);
+
+  const result = await service.listTemplateOptions(BigInt(7), {
+    source: 'STYLIST',
+    page: 1,
+    pageSize: 12,
+  });
+
+  expect(prisma.hairTemplate.findMany).toHaveBeenCalledWith(
+    expect.objectContaining({
+      where: expect.objectContaining({
+        isDeleted: 0,
+        status: 'PUBLIC',
+        isSystemTemplate: 'NO',
+        hairstylistId: BigInt(7),
+      }),
+    }),
+  );
+  expect(result.items[0]).toMatchObject({
+    id: '11',
+    templateName: 'Layer Bob',
+    tags: ['airy', 'bob'],
+    isSystemTemplate: false,
+  });
+});
+
+it('returns only public system templates for the system tab', async () => {
+  prisma.hairTemplate.findMany.mockResolvedValue([
+    {
+      id: BigInt(21),
+      templateName: 'Classic Medium',
+      coverUrl: '',
+      category: 'MEDIUM',
+      tags: '',
+      description: null,
+      templateUrl: 'https://cdn.example.com/medium.zip',
+      isSystemTemplate: 'YES',
+    },
+  ] as any);
+  prisma.hairTemplate.count.mockResolvedValue(1);
+
+  const result = await service.listTemplateOptions(BigInt(7), {
+    source: 'SYSTEM',
+    page: 1,
+    pageSize: 12,
+  });
+
+  expect(prisma.hairTemplate.findMany).toHaveBeenCalledWith(
+    expect.objectContaining({
+      where: expect.objectContaining({
+        isDeleted: 0,
+        status: 'PUBLIC',
+        isSystemTemplate: 'YES',
+      }),
+    }),
+  );
+  expect(result.items[0].id).toBe('21');
+});
+```
+
+- [ ] **Step 2: Run the service spec to verify it fails**
+
+Run:
+
+```bash
+cd /Users/yuchen/Documents/hairstylist/backend
+npm test -- src/modules/simulations/simulations.service.spec.ts --runInBand
+```
+
+Expected: FAIL because `listTemplateOptions()` and the DTOs do not exist
+
+- [ ] **Step 3: Implement the query DTOs and endpoint**
+
+Create `backend/src/modules/simulations/dto/list-simulation-templates-query.dto.ts`:
+
+```ts
+import { Transform } from 'class-transformer';
+import { IsEnum, IsInt, IsOptional, IsString, Min } from 'class-validator';
+
+export class ListSimulationTemplateOptionsQueryDto {
+  @IsEnum(['STYLIST', 'SYSTEM'])
+  source!: 'STYLIST' | 'SYSTEM';
+
+  @IsOptional()
+  @IsEnum(['LONG', 'MEDIUM', 'SHORT', 'DYE'])
+  category?: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE';
+
+  @IsOptional()
+  @IsString()
+  q?: string;
+
+  @Transform(({ value }) => Number(value ?? 1))
+  @IsInt()
+  @Min(1)
+  page = 1;
+
+  @Transform(({ value }) => Number(value ?? 12))
+  @IsInt()
+  @Min(1)
+  pageSize = 12;
+}
+```
+
+Create `backend/src/modules/simulations/dto/simulation-template-option.dto.ts`:
+
+```ts
+export type SimulationTemplateOptionDto = {
+  id: string;
+  templateName: string;
+  coverUrl: string;
+  category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE';
+  tags: string[];
+  description: string | null;
+  templateUrl: string;
+  isSystemTemplate: boolean;
+};
+```
+
+Add to `backend/src/modules/simulations/simulations.controller.ts`:
+
+```ts
+@Get('template-options')
+listTemplateOptions(
+  @CurrentUser() user: AuthUser,
+  @Query() query: ListSimulationTemplateOptionsQueryDto,
+) {
+  return this.simulationsService.listTemplateOptions(BigInt(user.userId), query);
+}
+```
+
+Add to `backend/src/modules/simulations/simulations.service.ts`:
+
+```ts
+async listTemplateOptions(
+  hairstylistId: bigint,
+  query: ListSimulationTemplateOptionsQueryDto,
+) {
+  const where = {
+    isDeleted: 0,
+    status: 'PUBLIC' as const,
+    templateUrl: { not: '' },
+    ...(query.category ? { category: query.category } : {}),
+    ...(query.q
+      ? { templateName: { contains: query.q } }
+      : {}),
+    ...(query.source === 'STYLIST'
+      ? { isSystemTemplate: 'NO' as const, hairstylistId }
+      : { isSystemTemplate: 'YES' as const }),
+  };
+
+  const [items, total] = await Promise.all([
+    this.prisma.hairTemplate.findMany({
+      where,
+      orderBy: { id: 'desc' },
+      skip: (query.page - 1) * query.pageSize,
+      take: query.pageSize,
+    }),
+    this.prisma.hairTemplate.count({ where }),
+  ]);
+
+  return {
+    items: items.map((item) => ({
+      id: item.id.toString(),
+      templateName: item.templateName,
+      coverUrl: item.coverUrl,
+      category: item.category,
+      tags: item.tags
+        ? item.tags.split(',').map((tag) => tag.trim()).filter(Boolean)
+        : [],
+      description: item.description,
+      templateUrl: item.templateUrl,
+      isSystemTemplate: item.isSystemTemplate === 'YES',
+    })),
+    total,
+    page: query.page,
+    pageSize: query.pageSize,
+  };
+}
+```
+
+- [ ] **Step 4: Run backend unit tests**
+
+Run:
+
+```bash
+cd /Users/yuchen/Documents/hairstylist/backend
+npm test -- src/modules/simulations/simulations.service.spec.ts --runInBand
+```
+
+Expected:
+
+```text
+PASS src/modules/simulations/simulations.service.spec.ts
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/src/modules/simulations/dto/list-simulation-templates-query.dto.ts backend/src/modules/simulations/dto/simulation-template-option.dto.ts backend/src/modules/simulations/simulations.controller.ts backend/src/modules/simulations/simulations.service.ts backend/src/modules/simulations/simulations.service.spec.ts
+git commit -m "feat: add simulation template options endpoint"
+```
+
+## Task 3: Implement Backend Simulation Creation Flow
+
+**Files:**
+- Create: `backend/src/modules/simulations/ai-simulation.client.ts`
+- Modify: `backend/src/modules/simulations/dto/create-simulation.dto.ts`
+- Modify: `backend/src/modules/simulations/simulations.module.ts`
+- Modify: `backend/src/modules/simulations/simulations.controller.ts`
+- Modify: `backend/src/modules/simulations/simulations.service.ts`
+- Modify: `backend/src/modules/simulations/simulations.service.spec.ts`
+- Create: `backend/test/simulations.e2e-spec.ts`
+
+**Interfaces:**
+- Consumes:
+  - `listTemplateOptions` task output
+  - `config.aiSimulation`
+  - existing authenticated user in controller
+- Produces:
+  - `AiSimulationClient.execute(templateUrl: string, imageUrl: string): Promise<{ taskId: string }>`
+  - `createSimulation(stylistId: bigint, dto: CreateSimulationDto): Promise<{ simulationId: string }>`
+  - `POST /api/v1/simulations`
+
+- [ ] **Step 1: Write failing tests for create validation and persistence**
+
+Extend `backend/src/modules/simulations/simulations.service.spec.ts` with:
+
+```ts
+it('creates one simulation and one task row per selected template', async () => {
+  prisma.customer.findFirst.mockResolvedValue({ id: BigInt(5) } as any);
+  prisma.hairTemplate.findMany.mockResolvedValue([
+    {
+      id: BigInt(11),
+      templateUrl: 'https://cdn.example.com/11.zip',
+      isSystemTemplate: 'NO',
+      hairstylistId: BigInt(7),
+      status: 'PUBLIC',
+      isDeleted: 0,
+    },
+    {
+      id: BigInt(12),
+      templateUrl: 'https://cdn.example.com/12.zip',
+      isSystemTemplate: 'YES',
+      hairstylistId: null,
+      status: 'PUBLIC',
+      isDeleted: 0,
+    },
+  ] as any);
+  aiClient.execute
+    .mockResolvedValueOnce({ taskId: 'task-11' })
+    .mockResolvedValueOnce({ taskId: 'task-12' });
+  prisma.$transaction.mockImplementation(async (callback: any) =>
+    callback({
+      aiHairSimulation: {
+        create: jest.fn().mockResolvedValue({ id: BigInt(91) }),
+      },
+      aiHairSimulationTask: {
+        createMany: jest.fn().mockResolvedValue({ count: 2 }),
+      },
+    }),
+  );
+
+  const result = await service.createSimulation(BigInt(7), {
+    customerId: '5',
+    originImage: 'https://cdn.example.com/origin.jpg',
+    templateIds: ['11', '12'],
+    simulateRemark: 'soft volume',
+  });
+
+  expect(aiClient.execute).toHaveBeenNthCalledWith(
+    1,
+    'https://cdn.example.com/11.zip',
+    'https://cdn.example.com/origin.jpg',
+  );
+  expect(result).toEqual({ simulationId: '91' });
+});
+
+it('rejects customer ids outside the current stylist scope', async () => {
+  prisma.customer.findFirst.mockResolvedValue(null);
+
+  await expect(
+    service.createSimulation(BigInt(7), {
+      customerId: '999',
+      originImage: 'https://cdn.example.com/origin.jpg',
+      templateIds: ['11'],
+    }),
+  ).rejects.toThrow('顾客不存在');
+});
+```
+
+- [ ] **Step 2: Run the service test to verify it fails**
+
+Run:
+
+```bash
+cd /Users/yuchen/Documents/hairstylist/backend
+npm test -- src/modules/simulations/simulations.service.spec.ts --runInBand
+```
+
+Expected: FAIL because the new create contract and AI client are not implemented
+
+- [ ] **Step 3: Implement the AI client, DTO, and create path**
+
+Rewrite `backend/src/modules/simulations/dto/create-simulation.dto.ts`:
+
+```ts
+import { ArrayMinSize, IsArray, IsOptional, IsString, IsUrl } from 'class-validator';
+
+export class CreateSimulationDto {
+  @IsOptional()
+  @IsString()
+  customerId?: string;
+
+  @IsUrl()
+  originImage!: string;
+
+  @IsArray()
+  @ArrayMinSize(1)
+  @IsString({ each: true })
+  templateIds!: string[];
+
+  @IsOptional()
+  @IsString()
+  simulateRemark?: string;
+}
+```
+
+Create `backend/src/modules/simulations/ai-simulation.client.ts`:
+
+```ts
+import { Injectable, ServiceUnavailableException } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+
+@Injectable()
+export class AiSimulationClient {
+  constructor(private readonly configService: ConfigService) {}
+
+  async execute(templateUrl: string, imageUrl: string): Promise<{ taskId: string }> {
+    const response = await fetch(
+      `${this.configService.getOrThrow<string>('aiSimulation.baseUrl')}/execute`,
+      {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          template: templateUrl,
+          inputs: {
+            images: [imageUrl],
+          },
+        }),
+      },
+    );
+
+    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 };
+  }
+}
+```
+
+Add to `backend/src/modules/simulations/simulations.service.ts`:
+
+```ts
+async createSimulation(hairstylistId: bigint, dto: CreateSimulationDto) {
+  if (dto.customerId) {
+    const customer = await this.prisma.customer.findFirst({
+      where: {
+        id: BigInt(dto.customerId),
+        hairstylistId,
+        isDeleted: 0,
+      },
+      select: { id: true },
+    });
+    if (!customer) {
+      throw new NotFoundException('顾客不存在');
+    }
+  }
+
+  const templateIds = dto.templateIds.map((value) => BigInt(value));
+  const templates = await this.prisma.hairTemplate.findMany({
+    where: {
+      id: { in: templateIds },
+      isDeleted: 0,
+      status: 'PUBLIC',
+      templateUrl: { not: '' },
+      OR: [
+        { isSystemTemplate: 'YES' },
+        { isSystemTemplate: 'NO', hairstylistId },
+      ],
+    },
+  });
+  if (templates.length !== dto.templateIds.length) {
+    throw new BadRequestException('部分模板不可用');
+  }
+
+  const taskSeeds = [];
+  for (const template of templates) {
+    const { taskId } = await this.aiSimulationClient.execute(
+      template.templateUrl,
+      dto.originImage,
+    );
+    taskSeeds.push({
+      template,
+      taskId,
+    });
+  }
+
+  return this.prisma.$transaction(async (tx) => {
+    const simulation = await tx.aiHairSimulation.create({
+      data: {
+        customerId: dto.customerId ? BigInt(dto.customerId) : null,
+        hairstylistId,
+        originImage: dto.originImage,
+        simulateRemark: dto.simulateRemark ?? null,
+      },
+    });
+
+    await tx.aiHairSimulationTask.createMany({
+      data: taskSeeds.map((seed) => ({
+        simulationId: simulation.id,
+        templateId: seed.template.id.toString(),
+        customerId: dto.customerId ? BigInt(dto.customerId) : null,
+        taskId: seed.taskId,
+        taskStatus: 'PROCESSING',
+        resultImages: null,
+        taskStartTime: new Date(),
+        taskFinishTime: null,
+        errorMsg: null,
+      })),
+    });
+
+    return { simulationId: simulation.id.toString() };
+  });
+}
+```
+
+- [ ] **Step 4: Add a focused E2E test for the create endpoint**
+
+Create `backend/test/simulations.e2e-spec.ts`:
+
+```ts
+it('/api/v1/simulations (POST) creates an anonymous simulation', async () => {
+  const response = await request(app.getHttpServer())
+    .post('/api/v1/simulations')
+    .set('Authorization', `Bearer ${token}`)
+    .send({
+      originImage: 'https://cdn.example.com/origin.jpg',
+      templateIds: ['11'],
+      simulateRemark: 'airy finish',
+    })
+    .expect(201);
+
+  expect(response.body.data).toEqual({
+    simulationId: expect.any(String),
+  });
+});
+```
+
+- [ ] **Step 5: Run unit + e2e tests**
+
+Run:
+
+```bash
+cd /Users/yuchen/Documents/hairstylist/backend
+npm test -- src/modules/simulations/simulations.service.spec.ts --runInBand
+npm run test:e2e -- test/simulations.e2e-spec.ts --runInBand
+```
+
+Expected:
+
+```text
+PASS src/modules/simulations/simulations.service.spec.ts
+PASS test/simulations.e2e-spec.ts
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add backend/src/modules/simulations/ai-simulation.client.ts backend/src/modules/simulations/dto/create-simulation.dto.ts backend/src/modules/simulations/simulations.module.ts backend/src/modules/simulations/simulations.controller.ts backend/src/modules/simulations/simulations.service.ts backend/src/modules/simulations/simulations.service.spec.ts backend/test/simulations.e2e-spec.ts
+git commit -m "feat: create ai hair simulations"
+```
+
+## Task 4: Implement Backend Detail Endpoint And Refresh Throttling
+
+**Files:**
+- Create: `backend/src/modules/simulations/dto/simulation-detail.dto.ts`
+- Modify: `backend/src/modules/simulations/ai-simulation.client.ts`
+- Modify: `backend/src/modules/simulations/simulations.controller.ts`
+- Modify: `backend/src/modules/simulations/simulations.service.ts`
+- Modify: `backend/src/modules/simulations/simulations.service.spec.ts`
+- Modify: `backend/test/simulations.e2e-spec.ts`
+
+**Interfaces:**
+- Consumes:
+  - `AiSimulationClient.execute()`
+  - `config.aiSimulation.refreshIntervalMs`
+- Produces:
+  - `AiSimulationClient.getResult(taskId: string): Promise<{ status: 'processing' | 'successes' | 'failed'; message: string; images: string[] }>`
+  - `getSimulationDetail(stylistId: bigint, simulationId: string): Promise<AiHairSimulationDetailDto>`
+  - `GET /api/v1/simulations/:simulationId`
+
+- [ ] **Step 1: Write failing tests for refresh and aggregate status**
+
+Extend `backend/src/modules/simulations/simulations.service.spec.ts` with:
+
+```ts
+it('refreshes stale processing tasks and returns PARTIAL_SUCCESS when mixed', async () => {
+  prisma.aiHairSimulation.findFirst.mockResolvedValue({
+    id: BigInt(91),
+    customerId: BigInt(5),
+    hairstylistId: BigInt(7),
+    originImage: 'https://cdn.example.com/origin.jpg',
+    simulateRemark: 'airy',
+    customer: {
+      id: BigInt(5),
+      name: 'Mika',
+      avatar: '',
+      phone: '090',
+      expectContact: 'LINE',
+    },
+  } as any);
+  prisma.aiHairSimulationTask.findMany.mockResolvedValue([
+    {
+      id: BigInt(1),
+      templateId: '11',
+      taskId: 'task-11',
+      taskStatus: 'PROCESSING',
+      resultImages: null,
+      errorMsg: null,
+      taskStartTime: new Date('2026-06-28T10:00:00Z'),
+      taskFinishTime: null,
+      updateTime: new Date('2026-06-28T09:59:00Z'),
+    },
+    {
+      id: BigInt(2),
+      templateId: '12',
+      taskId: 'task-12',
+      taskStatus: 'FAILED',
+      resultImages: null,
+      errorMsg: 'template invalid',
+      taskStartTime: new Date('2026-06-28T10:00:00Z'),
+      taskFinishTime: new Date('2026-06-28T10:02:00Z'),
+      updateTime: new Date('2026-06-28T10:02:00Z'),
+    },
+  ] as any);
+  prisma.hairTemplate.findMany.mockResolvedValue([
+    {
+      id: BigInt(11),
+      templateName: 'Layer Bob',
+      coverUrl: '',
+      category: 'SHORT',
+      isSystemTemplate: 'NO',
+    },
+    {
+      id: BigInt(12),
+      templateName: 'Classic Medium',
+      coverUrl: '',
+      category: 'MEDIUM',
+      isSystemTemplate: 'YES',
+    },
+  ] as any);
+  aiClient.getResult.mockResolvedValue({
+    status: 'successes',
+    message: '',
+    images: ['https://cdn.example.com/result.png'],
+  });
+
+  const result = await service.getSimulationDetail(BigInt(7), '91');
+
+  expect(aiClient.getResult).toHaveBeenCalledWith('task-11');
+  expect(prisma.aiHairSimulationTask.update).toHaveBeenCalledWith(
+    expect.objectContaining({
+      data: expect.objectContaining({
+        taskStatus: 'SUCCESSES',
+        resultImages: JSON.stringify(['https://cdn.example.com/result.png']),
+      }),
+    }),
+  );
+  expect(result.overallStatus).toBe('PARTIAL_SUCCESS');
+});
+```
+
+- [ ] **Step 2: Run the backend service spec to verify it fails**
+
+Run:
+
+```bash
+cd /Users/yuchen/Documents/hairstylist/backend
+npm test -- src/modules/simulations/simulations.service.spec.ts --runInBand
+```
+
+Expected: FAIL because `getSimulationDetail()` and `getResult()` do not exist
+
+- [ ] **Step 3: Implement detail DTO, AI result fetch, and refresh logic**
+
+Create `backend/src/modules/simulations/dto/simulation-detail.dto.ts`:
+
+```ts
+export type AiHairSimulationTaskDetailDto = {
+  id: string;
+  templateId: string;
+  templateName: string;
+  templateCoverUrl: string;
+  templateCategory: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE';
+  templateSource: 'STYLIST' | 'SYSTEM';
+  taskId: string;
+  taskStatus: 'PROCESSING' | 'SUCCESSES' | 'FAILED';
+  resultImages: string[];
+  errorMsg: string | null;
+  taskStartTime: string;
+  taskFinishTime: string | null;
+};
+
+export type AiHairSimulationDetailDto = {
+  id: string;
+  customer: {
+    id: string;
+    name: string;
+    avatar: string;
+    phone: string;
+    expectContact: 'LINE' | 'PHONE' | 'EMAIL' | 'NONE';
+  } | null;
+  originImage: string;
+  simulateRemark: string | null;
+  totalTaskCount: number;
+  processingTaskCount: number;
+  successTaskCount: number;
+  failedTaskCount: number;
+  overallStatus: 'PROCESSING' | 'PARTIAL_SUCCESS' | 'SUCCESSES' | 'FAILED';
+  tasks: AiHairSimulationTaskDetailDto[];
+};
+```
+
+Extend `backend/src/modules/simulations/ai-simulation.client.ts`:
+
+```ts
+async getResult(taskId: string): Promise<{
+  status: 'processing' | 'successes' | 'failed';
+  message: string;
+  images: string[];
+}> {
+  const response = await fetch(
+    `${this.configService.getOrThrow<string>('aiSimulation.baseUrl')}/result?taskId=${encodeURIComponent(taskId)}`,
+  );
+
+  if (!response.ok) {
+    throw new ServiceUnavailableException('AI 模拟结果查询失败');
+  }
+
+  const data = (await response.json()) as {
+    status: 'processing' | 'successes' | 'failed';
+    message?: string;
+    data?: { images?: string[] | null } | null;
+  };
+
+  return {
+    status: data.status,
+    message: data.message ?? '',
+    images: data.data?.images?.filter(Boolean) ?? [],
+  };
+}
+```
+
+Add to `backend/src/modules/simulations/simulations.service.ts`:
+
+```ts
+private shouldRefreshTask(updateTime: Date): boolean {
+  const refreshIntervalMs =
+    this.configService.getOrThrow<number>('aiSimulation.refreshIntervalMs');
+  return Date.now() - updateTime.getTime() >= refreshIntervalMs;
+}
+
+async getSimulationDetail(hairstylistId: bigint, simulationId: string) {
+  const simulation = await this.prisma.aiHairSimulation.findFirst({
+    where: {
+      id: BigInt(simulationId),
+      hairstylistId,
+      isDeleted: 0,
+    },
+    include: {
+      customer: {
+        select: {
+          id: true,
+          name: true,
+          avatar: true,
+          phone: true,
+          expectContact: true,
+        },
+      },
+    },
+  });
+  if (!simulation) {
+    throw new NotFoundException('模拟任务不存在');
+  }
+
+  const tasks = await this.prisma.aiHairSimulationTask.findMany({
+    where: {
+      simulationId: simulation.id,
+      isDeleted: 0,
+    },
+    orderBy: { id: 'asc' },
+  });
+
+  for (const task of tasks) {
+    if (task.taskStatus !== 'PROCESSING') continue;
+    if (!this.shouldRefreshTask(task.updateTime)) continue;
+
+    try {
+      const result = await this.aiSimulationClient.getResult(task.taskId);
+      if (result.status === 'processing') {
+        continue;
+      }
+
+      await this.prisma.aiHairSimulationTask.update({
+        where: { id: task.id },
+        data:
+          result.status === 'successes'
+            ? {
+                taskStatus: 'SUCCESSES',
+                resultImages: JSON.stringify(result.images),
+                taskFinishTime: new Date(),
+                errorMsg: null,
+              }
+            : {
+                taskStatus: 'FAILED',
+                errorMsg: result.message || 'AI 模拟失败',
+                taskFinishTime: new Date(),
+              },
+      });
+    } catch (error) {
+      this.logger.warn(`Failed to refresh simulation task ${task.taskId}`);
+    }
+  }
+
+  const freshTasks = await this.prisma.aiHairSimulationTask.findMany({
+    where: { simulationId: simulation.id, isDeleted: 0 },
+    orderBy: { id: 'asc' },
+  });
+  const templateIds = freshTasks.map((task) => BigInt(task.templateId));
+  const templates = await this.prisma.hairTemplate.findMany({
+    where: { id: { in: templateIds } },
+  });
+  const templateMap = new Map(templates.map((item) => [item.id.toString(), item]));
+
+  const successTaskCount = freshTasks.filter((task) => task.taskStatus === 'SUCCESSES').length;
+  const failedTaskCount = freshTasks.filter((task) => task.taskStatus === 'FAILED').length;
+  const processingTaskCount = freshTasks.filter((task) => task.taskStatus === 'PROCESSING').length;
+
+  return {
+    id: simulation.id.toString(),
+    customer: simulation.customer
+      ? {
+          id: simulation.customer.id.toString(),
+          name: simulation.customer.name,
+          avatar: simulation.customer.avatar,
+          phone: simulation.customer.phone,
+          expectContact: simulation.customer.expectContact,
+        }
+      : null,
+    originImage: simulation.originImage,
+    simulateRemark: simulation.simulateRemark,
+    totalTaskCount: freshTasks.length,
+    processingTaskCount,
+    successTaskCount,
+    failedTaskCount,
+    overallStatus:
+      processingTaskCount > 0
+        ? 'PROCESSING'
+        : successTaskCount > 0 && failedTaskCount > 0
+          ? 'PARTIAL_SUCCESS'
+          : successTaskCount === freshTasks.length
+            ? 'SUCCESSES'
+            : 'FAILED',
+    tasks: freshTasks.map((task) => {
+      const template = templateMap.get(task.templateId);
+      return {
+        id: task.id.toString(),
+        templateId: task.templateId,
+        templateName: template?.templateName ?? 'Unknown Template',
+        templateCoverUrl: template?.coverUrl ?? '',
+        templateCategory: (template?.category ?? 'LONG') as 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE',
+        templateSource: template?.isSystemTemplate === 'YES' ? 'SYSTEM' : 'STYLIST',
+        taskId: task.taskId,
+        taskStatus: task.taskStatus,
+        resultImages: task.resultImages ? JSON.parse(task.resultImages) : [],
+        errorMsg: task.errorMsg,
+        taskStartTime: task.taskStartTime.toISOString(),
+        taskFinishTime: task.taskFinishTime?.toISOString() ?? null,
+      };
+    }),
+  };
+}
+```
+
+- [ ] **Step 4: Add E2E coverage for authenticated detail**
+
+Extend `backend/test/simulations.e2e-spec.ts`:
+
+```ts
+it('/api/v1/simulations/:id (GET) returns task detail for the owner', async () => {
+  const response = await request(app.getHttpServer())
+    .get(`/api/v1/simulations/${simulationId}`)
+    .set('Authorization', `Bearer ${token}`)
+    .expect(200);
+
+  expect(response.body.data).toMatchObject({
+    id: simulationId,
+    tasks: expect.any(Array),
+    overallStatus: expect.any(String),
+  });
+});
+```
+
+- [ ] **Step 5: Run unit + e2e tests**
+
+Run:
+
+```bash
+cd /Users/yuchen/Documents/hairstylist/backend
+npm test -- src/modules/simulations/simulations.service.spec.ts --runInBand
+npm run test:e2e -- test/simulations.e2e-spec.ts --runInBand
+```
+
+Expected:
+
+```text
+PASS src/modules/simulations/simulations.service.spec.ts
+PASS test/simulations.e2e-spec.ts
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add backend/src/modules/simulations/dto/simulation-detail.dto.ts backend/src/modules/simulations/ai-simulation.client.ts backend/src/modules/simulations/simulations.controller.ts backend/src/modules/simulations/simulations.service.ts backend/src/modules/simulations/simulations.service.spec.ts backend/test/simulations.e2e-spec.ts
+git commit -m "feat: add simulation detail refresh flow"
+```
+
+## Task 5: Replace Frontend Simulation API Contracts And Routes
+
+**Files:**
+- Modify: `frontend/src/config/routes.ts`
+- Modify: `frontend/src/App.tsx`
+- Modify: `frontend/src/services/api/contracts.ts`
+- Modify: `frontend/src/services/api/simulations.ts`
+- Modify: `frontend/src/__tests__/App.test.tsx`
+
+**Interfaces:**
+- Consumes:
+  - backend `/simulations/template-options`
+  - backend `/simulations`
+  - backend `/simulations/:id`
+- Produces:
+  - `routes.simulationResultDetail = '/:locale/simulation/result/:simulationId'`
+  - `listSimulationTemplates(params)`
+  - `createSimulation(input)`
+  - `getSimulationById(simulationId)`
+
+- [ ] **Step 1: Write failing frontend API and route tests**
+
+Add to `frontend/src/__tests__/App.test.tsx`:
+
+```tsx
+it('renders simulation result detail route with simulationId param', async () => {
+  window.history.pushState({}, '', '/zh/simulation/result/91');
+  render(<App />);
+  expect(await screen.findByText(/Aitori Stylist/i)).toBeInTheDocument();
+});
+```
+
+Create or extend `frontend/src/services/api/__tests__/integration-adapters.test.ts` with:
+
+```ts
+it('builds simulation detail requests with path params', async () => {
+  vi.spyOn(http, 'get').mockResolvedValue({ id: '91' } as never);
+
+  await getSimulationById('91');
+
+  expect(http.get).toHaveBeenCalledWith('/simulations/91');
+});
+```
+
+- [ ] **Step 2: Run the frontend tests to verify they fail**
+
+Run:
+
+```bash
+cd /Users/yuchen/Documents/hairstylist/frontend
+npm test -- src/__tests__/App.test.tsx src/services/api/__tests__/integration-adapters.test.ts
+```
+
+Expected: FAIL because the detail route and API helper do not exist
+
+- [ ] **Step 3: Implement the new route and API surface**
+
+Update `frontend/src/config/routes.ts`:
+
+```ts
+simulationResultDetail: '/:locale/simulation/result/:simulationId',
+```
+
+Update `frontend/src/App.tsx`:
+
+```tsx
+<Route
+  path={routes.simulationResultDetail}
+  element={
+    <RequireAuth>
+      <SimulationResultPage />
+    </RequireAuth>
+  }
+/>
+```
+
+Update `frontend/src/services/api/contracts.ts`:
+
+```ts
+export type SimulationTemplateOption = {
+  id: string;
+  templateName: string;
+  coverUrl: string;
+  category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE';
+  tags: string[];
+  description: string | null;
+  templateUrl: string;
+  isSystemTemplate: boolean;
+};
+
+export type AiHairSimulationTaskDetail = {
+  id: string;
+  templateId: string;
+  templateName: string;
+  templateCoverUrl: string;
+  templateCategory: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE';
+  templateSource: 'STYLIST' | 'SYSTEM';
+  taskId: string;
+  taskStatus: 'PROCESSING' | 'SUCCESSES' | 'FAILED';
+  resultImages: string[];
+  errorMsg: string | null;
+  taskStartTime: string;
+  taskFinishTime: string | null;
+};
+
+export type AiHairSimulationDetail = {
+  id: string;
+  customer: {
+    id: string;
+    name: string;
+    avatar: string;
+    phone: string;
+    expectContact: 'LINE' | 'PHONE' | 'EMAIL' | 'NONE';
+  } | null;
+  originImage: string;
+  simulateRemark: string | null;
+  totalTaskCount: number;
+  processingTaskCount: number;
+  successTaskCount: number;
+  failedTaskCount: number;
+  overallStatus: 'PROCESSING' | 'PARTIAL_SUCCESS' | 'SUCCESSES' | 'FAILED';
+  tasks: AiHairSimulationTaskDetail[];
+};
+```
+
+Rewrite `frontend/src/services/api/simulations.ts` to:
+
+```ts
+import { http } from '../http/client';
+import type { AiHairSimulationDetail, ListResponse, SimulationTemplateOption } from './contracts';
+
+export async function listSimulationTemplates(params: {
+  source: 'STYLIST' | 'SYSTEM';
+  category?: string;
+  q?: string;
+  page?: number;
+  pageSize?: number;
+}): Promise<ListResponse<SimulationTemplateOption> & { page: number; pageSize: number }> {
+  const query = new URLSearchParams();
+  query.set('source', params.source);
+  if (params.category) query.set('category', params.category);
+  if (params.q) query.set('q', params.q);
+  if (params.page) query.set('page', String(params.page));
+  if (params.pageSize) query.set('pageSize', String(params.pageSize));
+  return http.get(`/simulations/template-options?${query.toString()}`);
+}
+
+export async function createSimulation(input: {
+  customerId?: string;
+  originImage: string;
+  templateIds: string[];
+  simulateRemark?: string;
+}): Promise<{ simulationId: string }> {
+  return http.post('/simulations', input);
+}
+
+export async function getSimulationById(simulationId: string): Promise<AiHairSimulationDetail> {
+  return http.get(`/simulations/${simulationId}`);
+}
+```
+
+Remove obsolete exports in that file:
+
+```ts
+// delete:
+// getSimulationJob
+// getSimulationResults
+// waitForSimulationJob
+// saveSimulationResult
+// adjustSimulationResult
+// saveAftercareFollow
+```
+
+- [ ] **Step 4: Run the frontend tests**
+
+Run:
+
+```bash
+cd /Users/yuchen/Documents/hairstylist/frontend
+npm test -- src/__tests__/App.test.tsx src/services/api/__tests__/integration-adapters.test.ts
+npm run typecheck
+```
+
+Expected:
+
+```text
+PASS src/__tests__/App.test.tsx
+PASS src/services/api/__tests__/integration-adapters.test.ts
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add frontend/src/config/routes.ts frontend/src/App.tsx frontend/src/services/api/contracts.ts frontend/src/services/api/simulations.ts frontend/src/__tests__/App.test.tsx frontend/src/services/api/__tests__/integration-adapters.test.ts
+git commit -m "refactor: replace simulation api contracts"
+```
+
+## Task 6: Rewrite The Frontend Simulation Create Page
+
+**Files:**
+- Modify: `frontend/src/pages/dashboard/simulation/StylistSimulationPage.tsx`
+- Modify: `frontend/src/i18n/locales/zh/translation.json`
+- Modify: `frontend/src/i18n/locales/ja/translation.json`
+- Modify: `frontend/public/css/aitori.css`
+- Create: `frontend/src/pages/dashboard/simulation/__tests__/StylistSimulationPage.test.tsx`
+
+**Interfaces:**
+- Consumes:
+  - `listCustomers()`
+  - `uploadMedia(file, 'ai-user-origin-image')`
+  - `listSimulationTemplates()`
+  - `createSimulation()`
+- Produces:
+  - Create page with optional customer binding
+  - Template source tabs (`STYLIST` / `SYSTEM`)
+  - Navigation to `/simulation/result/:simulationId`
+
+- [ ] **Step 1: Write failing create-page tests**
+
+Create `frontend/src/pages/dashboard/simulation/__tests__/StylistSimulationPage.test.tsx`:
+
+```tsx
+it('defaults to no customer when opened from dashboard', async () => {
+  vi.mocked(listCustomers).mockResolvedValue({ items: [], total: 0, page: 1, pageSize: 10 });
+  vi.mocked(listSimulationTemplates).mockResolvedValue({
+    items: [],
+    total: 0,
+    page: 1,
+    pageSize: 12,
+  });
+
+  renderWithRouter('/zh/simulation');
+
+  expect(await screen.findByLabelText(/不绑定顾客/i)).toBeChecked();
+});
+
+it('preselects the customer from query string and uploads origin image with ai-user-origin-image', async () => {
+  vi.mocked(listCustomers).mockResolvedValue({
+    items: [{ id: '5', name: 'Mika', phone: '090' }],
+    total: 1,
+    page: 1,
+    pageSize: 10,
+  } as never);
+  vi.mocked(listSimulationTemplates).mockResolvedValue({
+    items: [{ id: '11', templateName: 'Layer Bob', coverUrl: '', category: 'SHORT', tags: [], description: null, templateUrl: 'x', isSystemTemplate: false }],
+    total: 1,
+    page: 1,
+    pageSize: 12,
+  });
+  vi.mocked(uploadMedia).mockResolvedValue({ url: 'https://cdn.example.com/origin.jpg' } as never);
+  vi.mocked(createSimulation).mockResolvedValue({ simulationId: '91' });
+
+  renderWithRouter('/zh/simulation?customerId=5');
+
+  expect(await screen.findByDisplayValue('5')).toBeInTheDocument();
+  await userEvent.upload(screen.getByLabelText(/上传顾客照片/i), file);
+  await userEvent.click(screen.getByRole('button', { name: /发起模拟/i }));
+
+  expect(uploadMedia).toHaveBeenCalledWith(expect.any(File), 'ai-user-origin-image');
+  expect(createSimulation).toHaveBeenCalledWith(
+    expect.objectContaining({
+      customerId: '5',
+      originImage: 'https://cdn.example.com/origin.jpg',
+      templateIds: ['11'],
+    }),
+  );
+});
+```
+
+- [ ] **Step 2: Run the create-page tests to verify they fail**
+
+Run:
+
+```bash
+cd /Users/yuchen/Documents/hairstylist/frontend
+npm test -- src/pages/dashboard/simulation/__tests__/StylistSimulationPage.test.tsx
+```
+
+Expected: FAIL because the page still uses `selected/guest` and the old API surface
+
+- [ ] **Step 3: Rewrite the create page**
+
+Update `frontend/src/pages/dashboard/simulation/StylistSimulationPage.tsx` around the form state:
+
+```tsx
+const [bindCustomer, setBindCustomer] = useState(Boolean(searchParams.get('customerId')));
+const [selectedCustomerId, setSelectedCustomerId] = useState<string>(searchParams.get('customerId') ?? '');
+const [templateSource, setTemplateSource] = useState<'STYLIST' | 'SYSTEM'>('STYLIST');
+const [selectedTemplates, setSelectedTemplates] = useState<string[]>([]);
+const [originImageUrl, setOriginImageUrl] = useState('');
+
+const handleUpload = async (file: File) => {
+  const uploaded = await uploadMedia(file, 'ai-user-origin-image');
+  setOriginImageUrl(uploaded.url);
+};
+
+const handleSubmit = async (event: React.FormEvent) => {
+  event.preventDefault();
+  if (!originImageUrl) {
+    setError(copy.simulationCreatePage.errors.originRequired);
+    return;
+  }
+  if (selectedTemplates.length === 0) {
+    setError(copy.simulationCreatePage.errors.templateRequired);
+    return;
+  }
+
+  const response = await createSimulation({
+    customerId: bindCustomer && selectedCustomerId ? selectedCustomerId : undefined,
+    originImage: originImageUrl,
+    templateIds: selectedTemplates,
+    simulateRemark,
+  });
+
+  navigate(path(`/simulation/result/${response.simulationId}`));
+};
+```
+
+Use source-tab fetching:
+
+```tsx
+useEffect(() => {
+  void listSimulationTemplates({
+    source: templateSource,
+    page: templatePage,
+    pageSize: 12,
+    category: selectedCategory || undefined,
+    q: keyword || undefined,
+  }).then((result) => {
+    setTemplates(result.items);
+    setTemplateTotal(result.total);
+  });
+}, [templateSource, templatePage, selectedCategory, keyword]);
+```
+
+Required translation keys in `frontend/src/i18n/locales/zh/translation.json`:
+
+```json
+"simulationCreatePage": {
+  "title": "AI发型模拟",
+  "bindCustomer": "绑定顾客",
+  "noCustomer": "不绑定顾客",
+  "myTemplates": "我的模板",
+  "systemTemplates": "系统模板",
+  "upload": "上传顾客照片",
+  "submit": "发起模拟",
+  "errors": {
+    "originRequired": "请先上传顾客照片",
+    "templateRequired": "请至少选择一个模板",
+    "customerRequired": "请选择顾客"
+  }
+}
+```
+
+Add the corresponding Japanese keys in `frontend/src/i18n/locales/ja/translation.json`.
+
+Add CSS in `frontend/public/css/aitori.css`:
+
+```css
+.simulation-source-tabs {
+  display: flex;
+  gap: 12px;
+  margin-bottom: 24px;
+}
+
+.simulation-source-tab.is-active {
+  background: #232323;
+  color: #fff;
+}
+
+.simulation-task-card {
+  border: 1px solid #e5e5e5;
+  border-radius: 12px;
+  padding: 20px;
+}
+```
+
+- [ ] **Step 4: Run page tests and typecheck**
+
+Run:
+
+```bash
+cd /Users/yuchen/Documents/hairstylist/frontend
+npm test -- src/pages/dashboard/simulation/__tests__/StylistSimulationPage.test.tsx
+npm run typecheck
+```
+
+Expected:
+
+```text
+PASS src/pages/dashboard/simulation/__tests__/StylistSimulationPage.test.tsx
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add frontend/src/pages/dashboard/simulation/StylistSimulationPage.tsx frontend/src/i18n/locales/zh/translation.json frontend/src/i18n/locales/ja/translation.json frontend/public/css/aitori.css frontend/src/pages/dashboard/simulation/__tests__/StylistSimulationPage.test.tsx
+git commit -m "feat: rewrite ai simulation create page"
+```
+
+## Task 7: Rewrite The Task Detail Page And Activate Customer Entry
+
+**Files:**
+- Modify: `frontend/src/pages/dashboard/simulation/SimulationResultPage.tsx`
+- Modify: `frontend/src/pages/dashboard/customers/CustomerDetailPage.tsx`
+- Create: `frontend/src/pages/dashboard/simulation/__tests__/SimulationResultPage.test.tsx`
+- Modify: `frontend/src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.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:
+  - `getSimulationById(simulationId)`
+  - `routes.simulationResultDetail`
+- Produces:
+  - Polling result page bound to `simulationId`
+  - Live CTA from customer detail into the create page
+
+- [ ] **Step 1: Write failing detail and CTA tests**
+
+Create `frontend/src/pages/dashboard/simulation/__tests__/SimulationResultPage.test.tsx`:
+
+```tsx
+it('polls while overallStatus is PROCESSING and renders child task cards', async () => {
+  vi.useFakeTimers();
+  vi.mocked(getSimulationById)
+    .mockResolvedValueOnce({
+      id: '91',
+      customer: null,
+      originImage: 'https://cdn.example.com/origin.jpg',
+      simulateRemark: 'airy',
+      totalTaskCount: 1,
+      processingTaskCount: 1,
+      successTaskCount: 0,
+      failedTaskCount: 0,
+      overallStatus: 'PROCESSING',
+      tasks: [
+        {
+          id: '1',
+          templateId: '11',
+          templateName: 'Layer Bob',
+          templateCoverUrl: '',
+          templateCategory: 'SHORT',
+          templateSource: 'STYLIST',
+          taskId: 'task-11',
+          taskStatus: 'PROCESSING',
+          resultImages: [],
+          errorMsg: null,
+          taskStartTime: '2026-06-28T10:00:00.000Z',
+          taskFinishTime: null,
+        },
+      ],
+    })
+    .mockResolvedValueOnce({
+      id: '91',
+      customer: null,
+      originImage: 'https://cdn.example.com/origin.jpg',
+      simulateRemark: 'airy',
+      totalTaskCount: 1,
+      processingTaskCount: 0,
+      successTaskCount: 1,
+      failedTaskCount: 0,
+      overallStatus: 'SUCCESSES',
+      tasks: [
+        {
+          id: '1',
+          templateId: '11',
+          templateName: 'Layer Bob',
+          templateCoverUrl: '',
+          templateCategory: 'SHORT',
+          templateSource: 'STYLIST',
+          taskId: 'task-11',
+          taskStatus: 'SUCCESSES',
+          resultImages: ['https://cdn.example.com/result.png'],
+          errorMsg: null,
+          taskStartTime: '2026-06-28T10:00:00.000Z',
+          taskFinishTime: '2026-06-28T10:01:00.000Z',
+        },
+      ],
+    });
+
+  renderWithRouter('/zh/simulation/result/91');
+
+  expect(await screen.findByText('Layer Bob')).toBeInTheDocument();
+  await vi.advanceTimersByTimeAsync(5000);
+  expect(getSimulationById).toHaveBeenCalledTimes(2);
+});
+```
+
+Extend `frontend/src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx`:
+
+```tsx
+it('navigates to simulation create page with customerId', async () => {
+  renderWithRouter('/zh/customers/5');
+  expect(await screen.findByRole('link', { name: /开始模拟|シミュレーション/i }))
+    .toHaveAttribute('href', '/zh/simulation?customerId=5');
+});
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run:
+
+```bash
+cd /Users/yuchen/Documents/hairstylist/frontend
+npm test -- src/pages/dashboard/simulation/__tests__/SimulationResultPage.test.tsx src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx
+```
+
+Expected: FAIL because the detail page still expects the old session/job model and the customer CTA is disabled
+
+- [ ] **Step 3: Rewrite the result page and customer CTA**
+
+Rewrite `frontend/src/pages/dashboard/simulation/SimulationResultPage.tsx`:
+
+```tsx
+const { simulationId = '' } = useParams();
+const [detail, setDetail] = useState<AiHairSimulationDetail | null>(null);
+
+useEffect(() => {
+  let cancelled = false;
+  let timer: number | undefined;
+
+  const load = async () => {
+    const data = await getSimulationById(simulationId);
+    if (cancelled) return;
+    setDetail(data);
+
+    if (data.overallStatus === 'PROCESSING') {
+      timer = window.setTimeout(load, 5000);
+    }
+  };
+
+  void load();
+
+  return () => {
+    cancelled = true;
+    if (timer) window.clearTimeout(timer);
+  };
+}, [simulationId]);
+```
+
+Render task cards:
+
+```tsx
+{detail?.tasks.map((task) => (
+  <article key={task.id} className="simulation-task-card">
+    <div className="simulation-task-header">
+      <img src={task.templateCoverUrl || '/images/aitori-simulation-result.png'} alt={task.templateName} />
+      <div>
+        <strong>{task.templateName}</strong>
+        <span>{task.templateSource === 'SYSTEM' ? copy.simulationResultPage.systemSource : copy.simulationResultPage.stylistSource}</span>
+      </div>
+      <span className={`template-status-badge is-${task.taskStatus.toLowerCase()}`}>
+        {task.taskStatus}
+      </span>
+    </div>
+    {task.errorMsg ? <p role="alert">{task.errorMsg}</p> : null}
+    <div className="simulation-task-images">
+      {task.resultImages.map((url) => (
+        <img key={url} src={url} alt={task.templateName} />
+      ))}
+    </div>
+  </article>
+))}
+```
+
+Update `frontend/src/pages/dashboard/customers/CustomerDetailPage.tsx`:
+
+```tsx
+<Link
+  to={path(`/simulation?customerId=${customer.id}`)}
+  className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700 customer-detail-start-btn"
+>
+  <i className="feather icon-feather-camera"></i>
+  {pageCopy.simulate}
+</Link>
+```
+
+Add copy keys for task counters / source labels in both locale files:
+
+```json
+"simulationResultPage": {
+  "systemSource": "系统模板",
+  "stylistSource": "我的模板",
+  "processing": "处理中",
+  "success": "成功",
+  "failed": "失败"
+}
+```
+
+- [ ] **Step 4: Run tests, typecheck, and lint**
+
+Run:
+
+```bash
+cd /Users/yuchen/Documents/hairstylist/frontend
+npm test -- src/pages/dashboard/simulation/__tests__/SimulationResultPage.test.tsx src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx
+npm run typecheck
+npm run lint
+```
+
+Expected:
+
+```text
+PASS src/pages/dashboard/simulation/__tests__/SimulationResultPage.test.tsx
+PASS src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add frontend/src/pages/dashboard/simulation/SimulationResultPage.tsx frontend/src/pages/dashboard/customers/CustomerDetailPage.tsx frontend/src/pages/dashboard/simulation/__tests__/SimulationResultPage.test.tsx frontend/src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx frontend/src/i18n/locales/zh/translation.json frontend/src/i18n/locales/ja/translation.json frontend/public/css/aitori.css
+git commit -m "feat: add simulation detail page"
+```
+
+## Task 8: Convert Edit And Aftercare Pages Into Undeveloped Shells
+
+**Files:**
+- Modify: `frontend/src/pages/dashboard/simulation/SimulationEditPage.tsx`
+- Modify: `frontend/src/pages/dashboard/simulation/AftercareResultPage.tsx`
+- Create: `frontend/src/pages/dashboard/simulation/__tests__/SimulationPlaceholderPages.test.tsx`
+
+**Interfaces:**
+- Consumes:
+  - Existing route registration in `App.tsx`
+- Produces:
+  - Stable undeveloped shell pages for edit and aftercare
+
+- [ ] **Step 1: Write failing tests for undeveloped shells**
+
+Create `frontend/src/pages/dashboard/simulation/__tests__/SimulationPlaceholderPages.test.tsx`:
+
+```tsx
+it('renders simulation edit page as undeveloped', async () => {
+  renderWithRouter('/zh/simulation/edit');
+  expect(await screen.findByText(/未开发|未実装/i)).toBeInTheDocument();
+});
+
+it('renders aftercare page as undeveloped', async () => {
+  renderWithRouter('/zh/simulation/aftercare');
+  expect(await screen.findByText(/未开发|未実装/i)).toBeInTheDocument();
+});
+```
+
+- [ ] **Step 2: Run the placeholder-page tests to verify they fail**
+
+Run:
+
+```bash
+cd /Users/yuchen/Documents/hairstylist/frontend
+npm test -- src/pages/dashboard/simulation/__tests__/SimulationPlaceholderPages.test.tsx
+```
+
+Expected: FAIL because both pages still render old flow-specific UI
+
+- [ ] **Step 3: Replace both pages with simple undeveloped shells**
+
+Update `frontend/src/pages/dashboard/simulation/SimulationEditPage.tsx`:
+
+```tsx
+export function SimulationEditPage() {
+  const { locale, path, copy } = usePageContext();
+  return (
+    <DashboardLayout>
+      <Helmet>
+        <html lang={locale === 'ja' ? 'ja' : 'zh-CN'} />
+        <title>{`Aitori Stylist | ${copy.simulationEditPage.title}`}</title>
+      </Helmet>
+      <section className="pt-0">
+        <div className="container">
+          <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
+            <h1>{copy.simulationEditPage.title}</h1>
+            <p>{copy.common.undeveloped}</p>
+            <Link to={path('/simulation')} className="btn btn-base-color fw-700">
+              {copy.common.back}
+            </Link>
+          </div>
+        </div>
+      </section>
+    </DashboardLayout>
+  );
+}
+```
+
+Update `frontend/src/pages/dashboard/simulation/AftercareResultPage.tsx`:
+
+```tsx
+export function AftercareResultPage() {
+  const { locale, path, copy } = usePageContext();
+  return (
+    <DashboardLayout>
+      <Helmet>
+        <html lang={locale === 'ja' ? 'ja' : 'zh-CN'} />
+        <title>{`Aitori Stylist | ${copy.aftercareResultPage.title}`}</title>
+      </Helmet>
+      <section className="pt-0">
+        <div className="container">
+          <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
+            <h1>{copy.aftercareResultPage.title}</h1>
+            <p>{copy.common.undeveloped}</p>
+            <Link to={path('/simulation')} className="btn btn-base-color fw-700">
+              {copy.common.back}
+            </Link>
+          </div>
+        </div>
+      </section>
+    </DashboardLayout>
+  );
+}
+```
+
+- [ ] **Step 4: Run placeholder tests and full frontend checks**
+
+Run:
+
+```bash
+cd /Users/yuchen/Documents/hairstylist/frontend
+npm test -- src/pages/dashboard/simulation/__tests__/SimulationPlaceholderPages.test.tsx
+npm run typecheck
+npm run lint
+```
+
+Expected:
+
+```text
+PASS src/pages/dashboard/simulation/__tests__/SimulationPlaceholderPages.test.tsx
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add frontend/src/pages/dashboard/simulation/SimulationEditPage.tsx frontend/src/pages/dashboard/simulation/AftercareResultPage.tsx frontend/src/pages/dashboard/simulation/__tests__/SimulationPlaceholderPages.test.tsx
+git commit -m "refactor: mark simulation side pages undeveloped"
+```
+
+## Final Verification
+
+- [ ] **Step 1: Run backend targeted tests**
+
+```bash
+cd /Users/yuchen/Documents/hairstylist/backend
+npm test -- src/config/app.config.spec.ts src/modules/simulations/simulations.service.spec.ts --runInBand
+npm run test:e2e -- test/simulations.e2e-spec.ts --runInBand
+```
+
+- [ ] **Step 2: Run frontend targeted tests**
+
+```bash
+cd /Users/yuchen/Documents/hairstylist/frontend
+npm test -- src/__tests__/App.test.tsx src/pages/dashboard/simulation/__tests__/StylistSimulationPage.test.tsx src/pages/dashboard/simulation/__tests__/SimulationResultPage.test.tsx src/pages/dashboard/simulation/__tests__/SimulationPlaceholderPages.test.tsx src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx
+npm run typecheck
+npm run lint
+```
+
+- [ ] **Step 3: Run backend lint and Prisma validation**
+
+```bash
+cd /Users/yuchen/Documents/hairstylist/backend
+npm run lint
+npm run prisma:validate
+```
+
+- [ ] **Step 4: Manual smoke check**
+
+Run the backend and frontend locally, then verify:
+
+```text
+1. Open /zh/simulation from dashboard -> no customer selected by default
+2. Open /zh/customers/:id -> click simulate -> /zh/simulation?customerId=:id preselects the customer
+3. Upload an image -> upload request sends type=ai-user-origin-image
+4. Switch between 我的模板 / 系统模板 -> list requests send source=STYLIST / SYSTEM
+5. Submit a simulation -> navigate to /zh/simulation/result/:simulationId
+6. While tasks are processing -> detail page polls backend and eventually updates counts/results
+7. Open /zh/simulation/edit and /zh/simulation/aftercare -> both show undeveloped state
+```
+
+## Self-Review
+
+- **Spec coverage:** Task 1 covers config + Prisma alignment. Tasks 2-4 cover backend template list, create flow, AI client, detail refresh, and aggregate status. Tasks 5-8 cover routes, contracts, create page, detail page, customer entry, undeveloped side pages, and cleanup of old flow-specific logic. No spec requirement is left without a task.
+- **Placeholder scan:** The plan does not contain `TODO`, `TBD`, “implement later”, or vague “add tests” instructions without concrete test code. Every code-changing task includes concrete file paths, snippets, and commands.
+- **Type consistency:** The plan uses the same DTO names and route shape throughout:
+  - `CreateSimulationDto`
+  - `SimulationTemplateOption`
+  - `AiHairSimulationDetail`
+  - `AiHairSimulationTaskDetail`
+  - `/simulations/template-options`
+  - `/simulations/:simulationId`
+  - `/:locale/simulation/result/:simulationId`

+ 520 - 0
docs/superpowers/plans/2026-06-28-customer-detail-simulation-gallery.md

@@ -0,0 +1,520 @@
+# Customer Detail Simulation Gallery 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:** 在顾客详情页“过往照片和AI发型模拟”区块展示该顾客所有成功生成的 AI 模拟结果卡片,并支持点击卡片进入对应任务详情页。
+
+**Architecture:** 后端在 `simulations` 模块新增一个按顾客查询 gallery 的只读接口,负责把 `ai_hair_simulation` + `ai_hair_simulation_task.result_images` 扁平化成图片卡片流。前端在现有顾客详情页并行拉取顾客详情和 gallery 数据,用真实卡片网格替换当前占位块,保持现有页面骨架不变。
+
+**Tech Stack:** NestJS 11, Prisma, Jest, React 19, React Router 7, Vitest, TypeScript
+
+## Global Constraints
+
+- 顾客详情页开发必须遵循原有的 UI 骨架(顶部操作栏、Hero 区、资料布局等),不得随意更改已有的 UI 设计。
+- 前端顾客详情页中的“过往照片和AI发型模拟”区块继续使用当前区块位置和标题,不改整体页面骨架。
+- 一个生成图片就是一个卡片,按模拟任务创建时间倒序输出,最新在前。
+- 点击卡片统一跳转到 `/:locale/simulation/result/:simulationId`,不新增子任务详情页。
+- 只展示成功生成且有真实图片的结果卡片;失败任务、处理中任务、空图片任务不进入图片区。
+- 本次不做分页、筛选、顾客详情页轮询,也不修改任务详情页结构。
+
+---
+
+### Task 1: 后端顾客 Gallery DTO 与 Service 查询
+
+**Files:**
+- Create: `/Users/yuchen/Documents/hairstylist/backend/src/modules/simulations/dto/customer-simulation-gallery.dto.ts`
+- Modify: `/Users/yuchen/Documents/hairstylist/backend/src/modules/simulations/simulations.service.ts`
+- Test: `/Users/yuchen/Documents/hairstylist/backend/src/modules/simulations/simulations.service.spec.ts`
+
+**Interfaces:**
+- Consumes: `PrismaService.customer.findFirst()`, `PrismaService.aiHairSimulation.findMany()`, `PrismaService.aiHairSimulationTask.findMany()`, `PrismaService.hairTemplate.findMany()`
+- Produces: `SimulationsService.listCustomerSimulationGallery(hairstylistId: bigint, customerId: string): Promise<{ items: CustomerSimulationGalleryItemDto[] }>`
+
+- [ ] **Step 1: 写失败测试,定义 gallery 只返回成功图片并按最新在前展开**
+
+```ts
+it('lists flattened customer gallery cards in descending simulation time order', async () => {
+  prisma.customer.findFirst.mockResolvedValue({ id: BigInt(5) });
+  prisma.aiHairSimulation.findMany.mockResolvedValue([
+    {
+      id: BigInt(91),
+      customerId: BigInt(5),
+      hairstylistId: BigInt(7),
+      createTime: new Date('2026-06-28T09:00:00Z'),
+      isDeleted: 0,
+    },
+    {
+      id: BigInt(88),
+      customerId: BigInt(5),
+      hairstylistId: BigInt(7),
+      createTime: new Date('2026-06-27T09:00:00Z'),
+      isDeleted: 0,
+    },
+  ]);
+  prisma.aiHairSimulationTask.findMany.mockResolvedValue([
+    {
+      id: BigInt(1),
+      simulationId: BigInt(91),
+      templateId: '11',
+      taskId: 'task-11',
+      taskStatus: 'SUCCESSES',
+      resultImages: JSON.stringify([
+        'https://cdn.example.com/result-2.png',
+        'https://cdn.example.com/result-1.png',
+      ]),
+      isDeleted: 0,
+    },
+    {
+      id: BigInt(2),
+      simulationId: BigInt(88),
+      templateId: '12',
+      taskId: 'task-12',
+      taskStatus: 'FAILED',
+      resultImages: null,
+      isDeleted: 0,
+    },
+  ]);
+  prisma.hairTemplate.findMany.mockResolvedValue([
+    { id: BigInt(11), templateName: 'Layer Bob' },
+  ]);
+
+  const result = await service.listCustomerSimulationGallery(BigInt(7), '5');
+
+  expect(result.items).toEqual([
+    {
+      simulationId: '91',
+      taskId: 'task-11',
+      imageUrl: 'https://cdn.example.com/result-2.png',
+      templateName: 'Layer Bob',
+      createdAt: '2026-06-28T09:00:00.000Z',
+      taskStatus: 'SUCCESSES',
+    },
+    {
+      simulationId: '91',
+      taskId: 'task-11',
+      imageUrl: 'https://cdn.example.com/result-1.png',
+      templateName: 'Layer Bob',
+      createdAt: '2026-06-28T09:00:00.000Z',
+      taskStatus: 'SUCCESSES',
+    },
+  ]);
+});
+```
+
+- [ ] **Step 2: 跑单测确认失败**
+
+Run: `npm test -- src/modules/simulations/simulations.service.spec.ts --runInBand`
+
+Expected: FAIL,提示 `listCustomerSimulationGallery` 或 DTO 尚不存在
+
+- [ ] **Step 3: 新增 DTO 并实现最小查询逻辑**
+
+```ts
+export type CustomerSimulationGalleryItemDto = {
+  simulationId: string;
+  taskId: string;
+  imageUrl: string;
+  templateName: string;
+  createdAt: string;
+  taskStatus: 'SUCCESSES';
+};
+```
+
+```ts
+async listCustomerSimulationGallery(
+  hairstylistId: bigint,
+  customerId: string,
+): Promise<{ items: CustomerSimulationGalleryItemDto[] }> {
+  const customer = await this.prisma.customer.findFirst({
+    where: {
+      id: BigInt(customerId),
+      hairstylistId,
+      isDeleted: 0,
+    },
+    select: { id: true },
+  });
+
+  if (!customer) {
+    throw new NotFoundException('顾客不存在');
+  }
+
+  const simulations = await this.prisma.aiHairSimulation.findMany({
+    where: {
+      customerId: BigInt(customerId),
+      hairstylistId,
+      isDeleted: 0,
+    },
+    orderBy: { createTime: 'desc' },
+    select: {
+      id: true,
+      createTime: true,
+    },
+  });
+
+  if (!simulations.length) {
+    return { items: [] };
+  }
+
+  const tasks = await this.prisma.aiHairSimulationTask.findMany({
+    where: {
+      simulationId: { in: simulations.map((item) => item.id) },
+      taskStatus: 'SUCCESSES',
+      isDeleted: 0,
+    },
+    select: {
+      simulationId: true,
+      templateId: true,
+      taskId: true,
+      taskStatus: true,
+      resultImages: true,
+    },
+  });
+
+  const templateIds = tasks
+    .map((task) => {
+      try {
+        return BigInt(task.templateId);
+      } catch {
+        return null;
+      }
+    })
+    .filter((value): value is bigint => value !== null);
+
+  const templates = templateIds.length
+    ? await this.prisma.hairTemplate.findMany({
+        where: { id: { in: templateIds }, isDeleted: 0 },
+        select: { id: true, templateName: true },
+      })
+    : [];
+
+  const templateMap = new Map(templates.map((item) => [item.id.toString(), item.templateName]));
+  const simulationMap = new Map(simulations.map((item) => [item.id.toString(), item.createTime]));
+
+  return {
+    items: tasks.flatMap((task) => {
+      const createdAt = simulationMap.get(task.simulationId.toString());
+      if (!createdAt) return [];
+
+      return this.parseResultImages(task.resultImages).map((imageUrl) => ({
+        simulationId: task.simulationId.toString(),
+        taskId: task.taskId,
+        imageUrl,
+        templateName: templateMap.get(task.templateId) ?? 'Unknown Template',
+        createdAt: createdAt.toISOString(),
+        taskStatus: 'SUCCESSES' as const,
+      }));
+    }),
+  };
+}
+```
+
+- [ ] **Step 4: 跑单测确认通过**
+
+Run: `npm test -- src/modules/simulations/simulations.service.spec.ts --runInBand`
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/src/modules/simulations/dto/customer-simulation-gallery.dto.ts \
+  backend/src/modules/simulations/simulations.service.ts \
+  backend/src/modules/simulations/simulations.service.spec.ts
+git commit -m "feat: add customer simulation gallery service"
+```
+
+### Task 2: 后端 Controller 与 E2E 接口验证
+
+**Files:**
+- Modify: `/Users/yuchen/Documents/hairstylist/backend/src/modules/simulations/simulations.controller.ts`
+- Modify: `/Users/yuchen/Documents/hairstylist/backend/test/simulations.e2e-spec.ts`
+
+**Interfaces:**
+- Consumes: `SimulationsService.listCustomerSimulationGallery()`
+- Produces: `GET /api/v1/simulations/customer/:customerId/gallery`
+
+- [ ] **Step 1: 写失败的 e2e 用例**
+
+```ts
+it('/api/v1/simulations/customer/:customerId/gallery (GET) returns flattened result cards', async () => {
+  const token = await signToken();
+  const response = await request(app.getHttpServer())
+    .get('/api/v1/simulations/customer/1/gallery')
+    .set('Authorization', `Bearer ${token}`);
+
+  expect(response.status).toBe(200);
+  expect(response.body.data.items).toEqual(
+    expect.arrayContaining([
+      expect.objectContaining({
+        simulationId: expect.any(String),
+        imageUrl: expect.any(String),
+      }),
+    ]),
+  );
+});
+```
+
+- [ ] **Step 2: 跑 e2e 确认失败**
+
+Run: `npm run test:e2e -- simulations.e2e-spec.ts`
+
+Expected: FAIL,提示路由不存在或返回结构不符
+
+- [ ] **Step 3: 接出 controller 路由**
+
+```ts
+@Get('customer/:customerId/gallery')
+gallery(
+  @Req() request: AuthenticatedRequest,
+  @Param('customerId') customerId: string,
+) {
+  return this.simulationsService.listCustomerSimulationGallery(
+    BigInt(request.user.stylistId),
+    customerId,
+  );
+}
+```
+
+- [ ] **Step 4: 跑 e2e 确认通过**
+
+Run: `npm run test:e2e -- simulations.e2e-spec.ts`
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/src/modules/simulations/simulations.controller.ts \
+  backend/test/simulations.e2e-spec.ts
+git commit -m "feat: expose customer simulation gallery endpoint"
+```
+
+### Task 3: 前端 API 契约与请求封装
+
+**Files:**
+- Modify: `/Users/yuchen/Documents/hairstylist/frontend/src/services/api/contracts.ts`
+- Modify: `/Users/yuchen/Documents/hairstylist/frontend/src/services/api/simulations.ts`
+- Test: `/Users/yuchen/Documents/hairstylist/frontend/src/services/api/__tests__/simulations.test.ts`
+
+**Interfaces:**
+- Consumes: `GET /simulations/customer/:customerId/gallery`
+- Produces: `listCustomerSimulationGallery(customerId: string): Promise<{ items: CustomerSimulationGalleryItem[] }>`
+
+- [ ] **Step 1: 写失败测试,约束 gallery 请求路径**
+
+```ts
+it('builds customer simulation gallery requests with customerId', async () => {
+  vi.mocked(http.get).mockResolvedValue({ items: [] });
+
+  await listCustomerSimulationGallery('5');
+
+  expect(http.get).toHaveBeenCalledWith('/simulations/customer/5/gallery');
+});
+```
+
+- [ ] **Step 2: 跑前端 API 测试确认失败**
+
+Run: `npm test -- src/services/api/__tests__/simulations.test.ts`
+
+Expected: FAIL,提示 `listCustomerSimulationGallery` 未导出
+
+- [ ] **Step 3: 补齐契约类型和 API 方法**
+
+```ts
+export type CustomerSimulationGalleryItem = {
+  simulationId: string
+  taskId: string
+  imageUrl: string
+  templateName: string
+  createdAt: string
+  taskStatus: 'SUCCESSES'
+}
+```
+
+```ts
+export async function listCustomerSimulationGallery(
+  customerId: string,
+): Promise<{ items: CustomerSimulationGalleryItem[] }> {
+  return http.get(`/simulations/customer/${customerId}/gallery`)
+}
+```
+
+- [ ] **Step 4: 跑测试确认通过**
+
+Run: `npm test -- src/services/api/__tests__/simulations.test.ts`
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add frontend/src/services/api/contracts.ts \
+  frontend/src/services/api/simulations.ts \
+  frontend/src/services/api/__tests__/simulations.test.ts
+git commit -m "feat: add customer simulation gallery api"
+```
+
+### Task 4: 顾客详情页真实卡片流替换
+
+**Files:**
+- Modify: `/Users/yuchen/Documents/hairstylist/frontend/src/pages/dashboard/customers/CustomerDetailPage.tsx`
+- Modify: `/Users/yuchen/Documents/hairstylist/frontend/public/css/aitori.css`
+- Test: `/Users/yuchen/Documents/hairstylist/frontend/src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx`
+
+**Interfaces:**
+- Consumes: `getCustomerById(customerId: string)`, `listCustomerSimulationGallery(customerId: string)`
+- Produces: 顾客详情页中真实 gallery 卡片、空状态、卡片跳转
+
+- [ ] **Step 1: 写失败页面测试,约束真实卡片渲染和跳转**
+
+```tsx
+it('renders gallery cards and links each image card to simulation detail', async () => {
+  getCustomerById.mockResolvedValue({
+    id: '5',
+    name: 'Mika',
+    phone: '090-1111-2222',
+    nextFollowDate: '2026-06-30',
+    recommendVisitDate: '2026-07-10',
+    followContent: 'follow',
+    visitFrequencyLabel: '未记录',
+  });
+  listCustomerSimulationGallery.mockResolvedValue({
+    items: [
+      {
+        simulationId: '91',
+        taskId: 'task-11',
+        imageUrl: 'https://cdn.example.com/result-1.png',
+        templateName: 'Layer Bob',
+        createdAt: '2026-06-28T09:00:00.000Z',
+        taskStatus: 'SUCCESSES',
+      },
+    ],
+  });
+
+  renderCustomerDetail();
+
+  const card = await screen.findByRole('link', { name: /Layer Bob/i });
+  expect(card).toHaveAttribute('href', '/zh/simulation/result/91');
+});
+```
+
+- [ ] **Step 2: 跑页面测试确认失败**
+
+Run: `npm test -- src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx`
+
+Expected: FAIL,提示 gallery API 未调用或真实卡片未出现
+
+- [ ] **Step 3: 最小实现并行请求、真实卡片、空状态**
+
+```tsx
+const [customer, setCustomer] = useState<CustomerDetailEntity | null>(null)
+const [galleryItems, setGalleryItems] = useState<CustomerSimulationGalleryItem[]>([])
+
+useEffect(() => {
+  if (!customerId) return
+
+  Promise.all([
+    getCustomerById(customerId),
+    listCustomerSimulationGallery(customerId),
+  ])
+    .then(([customerData, galleryData]) => {
+      setCustomer(customerData)
+      setGalleryItems(galleryData.items)
+    })
+    .catch(() => {
+      setCustomer(null)
+      setGalleryItems([])
+    })
+}, [customerId])
+```
+
+```tsx
+<div className="customer-photo-grid">
+  {galleryItems.length ? (
+    galleryItems.map((item) => (
+      <Link
+        key={`${item.simulationId}-${item.taskId}-${item.imageUrl}`}
+        to={path(`/simulation/result/${item.simulationId}`)}
+        className="customer-photo-card customer-photo-card-link"
+      >
+        <img src={item.imageUrl} alt={item.templateName} className="customer-photo-image" />
+        <div>
+          <span>{formatDisplayDateTime(item.createdAt)}</span>
+          <strong>{item.templateName}</strong>
+        </div>
+      </Link>
+    ))
+  ) : (
+    <div className="customer-photo-empty">
+      <span>{pageCopy.states.empty || '暂无结果'}</span>
+    </div>
+  )}
+</div>
+```
+
+- [ ] **Step 4: 跑页面测试确认通过**
+
+Run: `npm test -- src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx`
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add frontend/src/pages/dashboard/customers/CustomerDetailPage.tsx \
+  frontend/src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx \
+  frontend/public/css/aitori.css
+git commit -m "feat: show simulation gallery on customer detail page"
+```
+
+### Task 5: 回归验证
+
+**Files:**
+- Modify: `/Users/yuchen/Documents/hairstylist/frontend/src/i18n/locales/zh/translation.json`
+- Modify: `/Users/yuchen/Documents/hairstylist/frontend/src/i18n/locales/ja/translation.json`
+
+**Interfaces:**
+- Consumes: 顾客详情页空状态、图片区块文案
+- Produces: 中日文一致的 gallery 文案和通过的定向验证
+
+- [ ] **Step 1: 如需空状态文案,先补翻译键**
+
+```json
+"photos": {
+  "badge": "照片与模拟",
+  "title": "过往照片和AI发型模拟",
+  "empty": "暂无 AI 发型模拟结果"
+}
+```
+
+- [ ] **Step 2: 跑前后端定向验证**
+
+Run: `npm test -- src/services/api/__tests__/simulations.test.ts src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx`
+
+Expected: PASS
+
+Run: `npm test -- src/modules/simulations/simulations.service.spec.ts --runInBand`
+
+Expected: PASS
+
+Run: `npm run test:e2e -- simulations.e2e-spec.ts`
+
+Expected: PASS
+
+- [ ] **Step 3: 跑类型检查和诊断**
+
+Run: `npm run typecheck`
+
+Expected: PASS
+
+Run: `npm run lint`
+
+Expected: PASS in `backend`; `frontend` 仅在本次触达文件范围内不新增错误
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add frontend/src/i18n/locales/zh/translation.json \
+  frontend/src/i18n/locales/ja/translation.json
+git commit -m "test: verify customer simulation gallery flow"
+```