|
|
@@ -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,
|
|
|
+ }));
|
|
|
+ }),
|
|
|
+ };
|
|
|
}
|
|
|
}
|