Selaa lähdekoodia

feat: add customer simulation gallery service

ChenYu 4 kuukautta sitten
vanhempi
sitoutus
e471e62282

+ 8 - 0
backend/src/modules/simulations/dto/customer-simulation-gallery.dto.ts

@@ -0,0 +1,8 @@
+export type CustomerSimulationGalleryItemDto = {
+  simulationId: string;
+  taskId: string;
+  imageUrl: string;
+  templateName: string;
+  createdAt: string;
+  taskStatus: 'SUCCESSES';
+};

+ 287 - 0
backend/src/modules/simulations/simulations.service.spec.ts

@@ -1,23 +1,59 @@
+import { ConfigService } from '@nestjs/config';
 import { Test } from '@nestjs/testing';
 import { PrismaService } from '../../prisma/prisma.service';
+import { AiSimulationClient } from './ai-simulation.client';
 import { SimulationsService } from './simulations.service';
 
 describe('SimulationsService', () => {
   let service: SimulationsService;
+  const aiClient = {
+    execute: jest.fn(),
+    getResult: jest.fn(),
+  };
+  const configService = {
+    getOrThrow: jest.fn((key: string) => {
+      if (key === 'aiSimulation.refreshIntervalMs') {
+        return 10_000;
+      }
+
+      throw new Error(`Unexpected config key: ${key}`);
+    }),
+  };
   const prisma = {
+    customer: {
+      findFirst: jest.fn(),
+    },
+    aiHairSimulation: {
+      findFirst: jest.fn(),
+      findMany: jest.fn(),
+    },
+    aiHairSimulationTask: {
+      findMany: jest.fn(),
+      update: jest.fn(),
+    },
     hairTemplate: {
       findMany: jest.fn(),
       count: jest.fn(),
     },
+    $transaction: jest.fn(),
   };
 
   beforeEach(async () => {
     jest.resetAllMocks();
+    configService.getOrThrow.mockImplementation((key: string) => {
+      if (key === 'aiSimulation.refreshIntervalMs') {
+        return 10_000;
+      }
+
+      throw new Error(`Unexpected config key: ${key}`);
+    });
 
     const moduleRef = await Test.createTestingModule({
       providers: [
         SimulationsService,
         { provide: PrismaService, useValue: prisma },
+        { provide: AiSimulationClient, useValue: aiClient },
+        { provide: ConfigService, useValue: configService },
       ],
     }).compile();
 
@@ -95,4 +131,255 @@ describe('SimulationsService', () => {
     );
     expect(result.items[0].id).toBe('21');
   });
+
+  it('creates one simulation and one task row per selected template', async () => {
+    prisma.customer.findFirst.mockResolvedValue({ id: BigInt(5) });
+    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,
+      },
+    ]);
+    aiClient.execute
+      .mockResolvedValueOnce({ taskId: 'task-11' })
+      .mockResolvedValueOnce({ taskId: 'task-12' });
+    const createSimulationRecord = jest.fn().mockResolvedValue({ id: BigInt(91) });
+    const createTaskRecords = jest.fn().mockResolvedValue({ count: 2 });
+    prisma.$transaction.mockImplementation(async (callback: (tx: {
+      aiHairSimulation: { create: jest.Mock };
+      aiHairSimulationTask: { createMany: jest.Mock };
+    }) => Promise<{ simulationId: string }>) =>
+      callback({
+        aiHairSimulation: {
+          create: createSimulationRecord,
+        },
+        aiHairSimulationTask: {
+          createMany: createTaskRecords,
+        },
+      }),
+    );
+
+    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(createSimulationRecord).toHaveBeenCalledWith({
+      data: expect.objectContaining({
+        templateIds: JSON.stringify(['11', '12']),
+      }),
+    });
+    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('顾客不存在');
+  });
+
+  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',
+      createTime: new Date('2026-06-28T09:00:00Z'),
+    });
+    prisma.customer.findFirst.mockResolvedValue({
+      id: BigInt(5),
+      name: 'Mika',
+      avatar: 'https://cdn.example.com/mika.png',
+      phone: '090',
+      expectContact: 'LINE',
+    });
+    prisma.aiHairSimulationTask.findMany
+      .mockResolvedValueOnce([
+        {
+          id: BigInt(1),
+          templateId: '11',
+          customerId: BigInt(5),
+          taskId: 'task-11',
+          taskStatus: 'PROCESSING',
+          resultImages: null,
+          errorMsg: null,
+          taskStartTime: new Date('2026-06-28T10:00:00Z'),
+          taskFinishTime: null,
+          updateTime: new Date(Date.now() - 20_000),
+          isDeleted: 0,
+        },
+        {
+          id: BigInt(2),
+          templateId: '12',
+          customerId: BigInt(5),
+          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'),
+          isDeleted: 0,
+        },
+      ])
+      .mockResolvedValueOnce([
+        {
+          id: BigInt(1),
+          templateId: '11',
+          customerId: BigInt(5),
+          taskId: 'task-11',
+          taskStatus: 'SUCCESSES',
+          resultImages: JSON.stringify(['https://cdn.example.com/result.png']),
+          errorMsg: null,
+          taskStartTime: new Date('2026-06-28T10:00:00Z'),
+          taskFinishTime: new Date('2026-06-28T10:01:00Z'),
+          updateTime: new Date('2026-06-28T10:01:00Z'),
+          isDeleted: 0,
+        },
+        {
+          id: BigInt(2),
+          templateId: '12',
+          customerId: BigInt(5),
+          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'),
+          isDeleted: 0,
+        },
+      ]);
+    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',
+      },
+    ]);
+    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.createdAt).toBe('2026-06-28T09:00:00.000Z');
+    expect(result.overallStatus).toBe('PARTIAL_SUCCESS');
+  });
+
+  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',
+      },
+    ]);
+  });
 });

+ 398 - 5
backend/src/modules/simulations/simulations.service.ts

@@ -1,12 +1,27 @@
-import { Injectable, NotImplementedException } from '@nestjs/common';
+import {
+  BadRequestException,
+  Injectable,
+  Logger,
+  NotFoundException,
+} from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
 import { PrismaService } from '../../prisma/prisma.service';
+import { AiSimulationClient } from './ai-simulation.client';
 import { CreateSimulationDto } from './dto/create-simulation.dto';
+import { CustomerSimulationGalleryItemDto } from './dto/customer-simulation-gallery.dto';
 import { ListSimulationTemplateOptionsQueryDto } from './dto/list-simulation-templates-query.dto';
+import { AiHairSimulationDetailDto } from './dto/simulation-detail.dto';
 import { SimulationTemplateOptionDto } from './dto/simulation-template-option.dto';
 
 @Injectable()
 export class SimulationsService {
-  constructor(private readonly prisma: PrismaService) {}
+  private readonly logger = new Logger(SimulationsService.name);
+
+  constructor(
+    private readonly prisma: PrismaService,
+    private readonly aiSimulationClient: AiSimulationClient,
+    private readonly configService: ConfigService,
+  ) {}
 
   async listTemplateOptions(
     hairstylistId: bigint,
@@ -57,9 +72,387 @@ export class SimulationsService {
     };
   }
 
-  async create(_input: CreateSimulationDto): Promise<never> {
-    throw new NotImplementedException(
-      'Simulation creation is not implemented yet',
+  async createSimulation(
+    hairstylistId: bigint,
+    dto: CreateSimulationDto,
+  ): Promise<{ simulationId: string }> {
+    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: Array<{
+      template: {
+        id: bigint;
+        templateUrl: string;
+      };
+      taskId: string;
+    }> = [];
+    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,
+          templateIds: JSON.stringify(dto.templateIds),
+          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() };
+    });
+  }
+
+  private shouldRefreshTask(updateTime: Date): boolean {
+    const refreshIntervalMs = this.configService.getOrThrow<number>(
+      'aiSimulation.refreshIntervalMs',
+    );
+
+    return Date.now() - updateTime.getTime() >= refreshIntervalMs;
+  }
+
+  private parseResultImages(resultImages: string | null): string[] {
+    if (!resultImages) {
+      return [];
+    }
+
+    try {
+      const parsed = JSON.parse(resultImages) as unknown;
+      return Array.isArray(parsed)
+        ? parsed.filter((item): item is string => typeof item === 'string')
+        : [];
+    } catch {
+      return [];
+    }
+  }
+
+  private getOverallStatus(counts: {
+    totalTaskCount: number;
+    processingTaskCount: number;
+    successTaskCount: number;
+    failedTaskCount: number;
+  }): 'PROCESSING' | 'PARTIAL_SUCCESS' | 'SUCCESSES' | 'FAILED' {
+    if (counts.processingTaskCount > 0) {
+      return 'PROCESSING';
+    }
+
+    if (counts.totalTaskCount > 0 && counts.successTaskCount === counts.totalTaskCount) {
+      return 'SUCCESSES';
+    }
+
+    if (counts.successTaskCount > 0 && counts.failedTaskCount > 0) {
+      return 'PARTIAL_SUCCESS';
+    }
+
+    return 'FAILED';
+  }
+
+  async getSimulationDetail(
+    hairstylistId: bigint,
+    simulationId: string,
+  ): Promise<AiHairSimulationDetailDto> {
+    const simulation = await this.prisma.aiHairSimulation.findFirst({
+      where: {
+        id: BigInt(simulationId),
+        hairstylistId,
+        isDeleted: 0,
+      },
+    });
+
+    if (!simulation) {
+      throw new NotFoundException('模拟任务不存在');
+    }
+
+    const customer = simulation.customerId
+      ? await this.prisma.customer.findFirst({
+          where: {
+            id: simulation.customerId,
+            hairstylistId,
+            isDeleted: 0,
+          },
+          select: {
+            id: true,
+            name: true,
+            avatar: true,
+            phone: true,
+            expectContact: true,
+          },
+        })
+      : null;
+
+    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) {
+        const reason = error instanceof Error ? error.message : 'unknown error';
+        this.logger.warn(`Failed to refresh simulation task ${task.taskId}: ${reason}`);
+      }
+    }
+
+    const freshTasks = await this.prisma.aiHairSimulationTask.findMany({
+      where: {
+        simulationId: simulation.id,
+        isDeleted: 0,
+      },
+      orderBy: { id: 'asc' },
+    });
+
+    const numericTemplateIds = freshTasks
+      .map((task) => {
+        try {
+          return BigInt(task.templateId);
+        } catch {
+          return null;
+        }
+      })
+      .filter((value): value is bigint => value !== null);
+    const templates = numericTemplateIds.length
+      ? await this.prisma.hairTemplate.findMany({
+          where: {
+            id: { in: numericTemplateIds },
+            isDeleted: 0,
+          },
+        })
+      : [];
+    const templateMap = new Map(templates.map((item) => [item.id.toString(), item]));
+
+    const counts = {
+      totalTaskCount: freshTasks.length,
+      processingTaskCount: freshTasks.filter((task) => task.taskStatus === 'PROCESSING').length,
+      successTaskCount: freshTasks.filter((task) => task.taskStatus === 'SUCCESSES').length,
+      failedTaskCount: freshTasks.filter((task) => task.taskStatus === 'FAILED').length,
+    };
+
+    return {
+      id: simulation.id.toString(),
+      createdAt: simulation.createTime.toISOString(),
+      customer: customer
+        ? {
+            id: customer.id.toString(),
+            name: customer.name,
+            avatar: customer.avatar,
+            phone: customer.phone,
+            expectContact: customer.expectContact,
+          }
+        : null,
+      originImage: simulation.originImage,
+      simulateRemark: simulation.simulateRemark,
+      totalTaskCount: counts.totalTaskCount,
+      processingTaskCount: counts.processingTaskCount,
+      successTaskCount: counts.successTaskCount,
+      failedTaskCount: counts.failedTaskCount,
+      overallStatus: this.getOverallStatus(counts),
+      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',
+          templateSource: template?.isSystemTemplate === 'YES' ? 'SYSTEM' : 'STYLIST',
+          taskId: task.taskId,
+          taskStatus: task.taskStatus,
+          resultImages: this.parseResultImages(task.resultImages),
+          errorMsg: task.errorMsg,
+          taskStartTime: task.taskStartTime.toISOString(),
+          taskFinishTime: task.taskFinishTime?.toISOString() ?? null,
+        };
+      }),
+    };
+  }
+
+  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,
+        }));
+      }),
+    };
   }
 }