|
|
@@ -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`
|