import { Injectable, NotFoundException, NotImplementedException } from '@nestjs/common'; import { randomUUID } from 'crypto'; import { PrismaService } from '../../prisma/prisma.service'; import { CreateTemplateDto } from './dto/create-template.dto'; import { CreateTemplateMakeTaskDto } from './dto/create-template-make-task.dto'; import { TemplateAssetsDto } from './dto/get-template-assets.dto'; import { ListTemplatesQueryDto } from './dto/list-templates-query.dto'; import { TemplateBasicInfoDto } from './dto/template-basic-info.dto'; import { UpdateTemplateBasicInfoDto } from './dto/update-template-basic-info.dto'; import { TemplateAiClient } from './template-ai.client'; import { TemplateTaskRunner } from './template-task-runner'; type TemplateListQuery = ListTemplatesQueryDto & { stylistId: bigint; }; const TEMPLATE_CATEGORIES = ['LONG', 'MEDIUM', 'SHORT', 'DYE'] as const; const TEMPLATE_STATUSES = ['DRAFT', 'PUBLIC'] as const; @Injectable() export class TemplatesService { private get systemConfigDelegate(): { findMany: (args: Record) => Promise< Array<{ id: bigint; configKey: string; configName: string; configGroup: string; configValue: string | null; }> >; } { return ( this.prisma as PrismaService & { systemConfig: { findMany: (args: Record) => Promise< Array<{ id: bigint; configKey: string; configName: string; configGroup: string; configValue: string | null; }> >; }; } ).systemConfig; } constructor( private readonly prisma: PrismaService, private readonly templateAiClient: TemplateAiClient, private readonly templateTaskRunner: TemplateTaskRunner, ) {} private async getOwnedTemplate(stylistId: bigint, templateId: string) { const template = await this.prisma.hairTemplate.findFirst({ where: { id: BigInt(templateId), hairstylistId: stylistId, isDeleted: 0, }, }); if (!template) { throw new NotFoundException('模板不存在'); } return template; } private toBasicInfoDto(template: { id: bigint; templateName: string; status: 'DRAFT' | 'PUBLIC'; category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE'; tags: string; description: string | null; coverUrl: string; templateUrl: string; }): TemplateBasicInfoDto { return { id: template.id.toString(), templateName: template.templateName, status: template.status, category: template.category, tags: template.tags ? template.tags .split(',') .map((tag) => tag.trim()) .filter(Boolean) : [], description: template.description ?? '', coverUrl: template.coverUrl, templateUrl: template.templateUrl, }; } private toTemplateListItem( template: { id: bigint; templateName: string; category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE'; status: 'DRAFT' | 'PUBLIC'; description: string | null; tags: string; coverUrl: string; usedCount: number; }, taskMap: Map, ) { const task = taskMap.get(template.id.toString()); return { id: template.id.toString(), name: template.templateName, category: template.category, status: template.status, description: template.description ?? '', tags: template.tags ? template.tags .split(',') .map((tag) => tag.trim()) .filter(Boolean) : [], image: template.coverUrl, makeTaskId: task?.id.toString(), makeTaskStatus: task?.taskStatus, usedCount: template.usedCount ?? 0, }; } async getTemplateAssets(): Promise { type AssetConfigRow = { id: bigint; configKey: string; configName: string; configGroup: string; configValue: string | null; }; const rows = await this.systemConfigDelegate.findMany({ where: { isDeleted: 0, configGroup: { in: ['ai-model', 'clothing', 'background'], }, }, orderBy: [{ sort: 'asc' }, { id: 'asc' }], }); const mapRow = (row: AssetConfigRow) => ({ id: row.id.toString(), name: row.configName, url: row.configValue ?? '', configKey: row.configKey, }); return { models: rows.filter((row: AssetConfigRow) => row.configGroup === 'ai-model').map(mapRow), clothes: rows.filter((row: AssetConfigRow) => row.configGroup === 'clothing').map(mapRow), backgrounds: rows .filter((row: AssetConfigRow) => row.configGroup === 'background') .map(mapRow), }; } private async getRequiredStep1TemplateUrl(): Promise { const rows = await this.systemConfigDelegate.findMany({ where: { isDeleted: 0, configGroup: 'template-step1', }, orderBy: [{ sort: 'asc' }, { id: 'asc' }], take: 1, }); const templateUrl = rows[0]?.configValue?.trim() ?? ''; if (!templateUrl) { throw new NotFoundException('缺少 template-step1 配置'); } return templateUrl; } async createTemplateMakeTask( stylistId: bigint, dto: CreateTemplateMakeTaskDto, ): Promise<{ templateId: string; taskId: string; totalTaskId: string; taskStatus: 'PROCESSING'; }> { const step1TemplateUrl = await this.getRequiredStep1TemplateUrl(); const totalTaskId = `tmpl_${Date.now()}_${randomUUID()}`; const created = await this.prisma.$transaction(async (tx) => { const template = await tx.hairTemplate.create({ data: { templateName: dto.templateName, coverUrl: '', status: dto.status, category: dto.category, tags: (dto.tags ?? []).join(','), description: dto.description ?? null, modelUrl: dto.modelUrl, clothesUrl: dto.clothesUrl, backgroundUrl: dto.backgroundUrl, refHairUrl: dto.refHairUrl, hairstylistId: stylistId, templateUrl: '', isSystemTemplate: 'NO', }, }); const task = await tx.hairTemplateMakeTask.create({ data: { totalTaskId, step1TaskId: '', step2TaskId: '', taskStatus: 'WAIT', templateId: template.id, step1Resource: JSON.stringify({ templateUrl: step1TemplateUrl, lastError: null, }), step2Resource: JSON.stringify({ uploadedZipUrl: null, lastError: null, }), refHairUrl: dto.refHairUrl, modelUrl: dto.modelUrl, clothesUrl: dto.clothesUrl, backgroundUrl: dto.backgroundUrl, }, }); return { template, task }; }); const step1 = await this.templateAiClient.executeTemplate(step1TemplateUrl, { images: [dto.refHairUrl], }); await this.prisma.hairTemplateMakeTask.update({ where: { id: created.task.id }, data: { step1TaskId: step1.taskId, taskStatus: 'PROCESSING', step1Resource: JSON.stringify({ templateUrl: step1TemplateUrl, lastError: null, }), }, }); return { templateId: created.template.id.toString(), taskId: created.task.id.toString(), totalTaskId, taskStatus: 'PROCESSING', }; } async getTemplateMakeTaskDetail( hairstylistId: bigint, taskId: string, ) { return this.templateTaskRunner.refreshTask(hairstylistId, taskId); } async getBasicInfo( stylistId: bigint, templateId: string, ): Promise { const template = await this.getOwnedTemplate(stylistId, templateId); return this.toBasicInfoDto(template); } async updateBasicInfo( stylistId: bigint, templateId: string, dto: UpdateTemplateBasicInfoDto, ): Promise { const template = await this.getOwnedTemplate(stylistId, templateId); const updated = await this.prisma.hairTemplate.update({ where: { id: template.id }, data: { templateName: dto.templateName, status: dto.status, category: dto.category, tags: (dto.tags ?? []).join(','), description: dto.description ?? '', }, }); return this.toBasicInfoDto(updated); } async deleteTemplate( stylistId: bigint, templateId: string, ): Promise<{ success: true }> { const template = await this.getOwnedTemplate(stylistId, templateId); await this.prisma.hairTemplate.updateMany({ where: { id: template.id, hairstylistId: stylistId, isDeleted: 0, }, data: { isDeleted: 1, }, }); await this.prisma.hairTemplateMakeTask.updateMany({ where: { templateId: template.id, isDeleted: 0, }, data: { isDeleted: 1, }, }); return { success: true }; } async deleteTemplates( stylistId: bigint, templateIds: string[], ): Promise<{ deletedCount: number }> { const parsedIds = Array.from( new Set( templateIds .map((id) => id.trim()) .filter(Boolean) .map((id) => BigInt(id)), ), ); if (parsedIds.length === 0) { return { deletedCount: 0 }; } const ownedTemplates = await this.prisma.hairTemplate.findMany({ where: { id: { in: parsedIds, }, hairstylistId: stylistId, isDeleted: 0, }, select: { id: true, }, }); const ownedIds = ownedTemplates.map((item) => item.id); if (ownedIds.length === 0) { return { deletedCount: 0 }; } const { count } = await this.prisma.hairTemplate.updateMany({ where: { id: { in: ownedIds, }, hairstylistId: stylistId, isDeleted: 0, }, data: { isDeleted: 1, }, }); await this.prisma.hairTemplateMakeTask.updateMany({ where: { templateId: { in: ownedIds, }, isDeleted: 0, }, data: { isDeleted: 1, }, }); return { deletedCount: count, }; } async getSummary(stylistId: bigint): Promise<{ total: number; published: number; weeklyUsed: number; draft: number; }> { const now = new Date(); const utcDay = now.getUTCDay(); const diffToMonday = utcDay === 0 ? 6 : utcDay - 1; const startOfWeek = new Date( Date.UTC( now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - diffToMonday, ), ); const endOfWeek = new Date( Date.UTC( now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - diffToMonday + 7, ), ); const [total, published, draft, templates] = await Promise.all([ this.prisma.hairTemplate.count({ where: { hairstylistId: stylistId, isDeleted: 0, }, }), this.prisma.hairTemplate.count({ where: { hairstylistId: stylistId, isDeleted: 0, status: 'PUBLIC', }, }), this.prisma.hairTemplate.count({ where: { hairstylistId: stylistId, isDeleted: 0, status: 'DRAFT', }, }), this.prisma.hairTemplate.findMany({ where: { hairstylistId: stylistId, isDeleted: 0, }, select: { id: true, }, }), ]); const templateIds = templates.map((item) => item.id.toString()); const weeklyUsed = templateIds.length ? await this.prisma.aiHairSimulationTask.count({ where: { taskStatus: 'SUCCESSES', isDeleted: 0, templateId: { in: templateIds, }, taskFinishTime: { gte: startOfWeek, lt: endOfWeek, }, }, }) : 0; return { total, published, weeklyUsed, draft, }; } async list(query: TemplateListQuery): Promise<{ items: Array<{ id: string; name: string; category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE'; status: 'DRAFT' | 'PUBLIC'; description: string; tags: string[]; image: string; makeTaskId?: string; makeTaskStatus?: string; usedCount: number; }>; total: number; page: number; pageSize: number; }> { const page = query.page && query.page > 0 ? query.page : 1; const pageSize = query.pageSize && query.pageSize > 0 ? query.pageSize : 8; const keyword = query.q?.trim(); const category = TEMPLATE_CATEGORIES.find((item) => item === query.category); const status = TEMPLATE_STATUSES.find((item) => item === query.status); const where = { hairstylistId: query.stylistId, isDeleted: 0, ...(category ? { category } : {}), ...(status ? { status } : {}), ...(keyword ? { OR: [ { templateName: { contains: keyword } }, { tags: { contains: keyword } }, { description: { contains: keyword } }, ], } : {}), }; const [total, templates] = await Promise.all([ this.prisma.hairTemplate.count({ where }), this.prisma.hairTemplate.findMany({ where, orderBy: [{ id: 'desc' }], skip: (page - 1) * pageSize, take: pageSize, select: { id: true, templateName: true, category: true, status: true, description: true, tags: true, coverUrl: true, usedCount: true, }, }), ]); const templateIds = templates.map((item) => item.id); const tasks = templateIds.length ? await this.prisma.hairTemplateMakeTask.findMany({ where: { isDeleted: 0, templateId: { in: templateIds }, }, orderBy: [{ id: 'desc' }], }) : []; const taskMap = new Map(); for (const task of tasks) { if (!task.templateId) { continue; } const key = task.templateId.toString(); if (!taskMap.has(key)) { taskMap.set(key, { id: task.id, taskStatus: task.taskStatus }); } } return { items: templates.map((template) => this.toTemplateListItem(template, taskMap)), total, page, pageSize, }; } async create(_body: CreateTemplateDto): Promise { throw new NotImplementedException('Template creation is not implemented yet'); } }