| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570 |
- 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<string, unknown>) => Promise<
- Array<{
- id: bigint;
- configKey: string;
- configName: string;
- configGroup: string;
- configValue: string | null;
- }>
- >;
- } {
- return (
- this.prisma as PrismaService & {
- systemConfig: {
- findMany: (args: Record<string, unknown>) => 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<string, { id: bigint; taskStatus: string }>,
- ) {
- 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<TemplateAssetsDto> {
- 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<string> {
- 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<TemplateBasicInfoDto> {
- const template = await this.getOwnedTemplate(stylistId, templateId);
- return this.toBasicInfoDto(template);
- }
- async updateBasicInfo(
- stylistId: bigint,
- templateId: string,
- dto: UpdateTemplateBasicInfoDto,
- ): Promise<TemplateBasicInfoDto> {
- 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<string, { id: bigint; taskStatus: string }>();
- 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<never> {
- throw new NotImplementedException('Template creation is not implemented yet');
- }
- }
|