templates.service.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570
  1. import { Injectable, NotFoundException, NotImplementedException } from '@nestjs/common';
  2. import { randomUUID } from 'crypto';
  3. import { PrismaService } from '../../prisma/prisma.service';
  4. import { CreateTemplateDto } from './dto/create-template.dto';
  5. import { CreateTemplateMakeTaskDto } from './dto/create-template-make-task.dto';
  6. import { TemplateAssetsDto } from './dto/get-template-assets.dto';
  7. import { ListTemplatesQueryDto } from './dto/list-templates-query.dto';
  8. import { TemplateBasicInfoDto } from './dto/template-basic-info.dto';
  9. import { UpdateTemplateBasicInfoDto } from './dto/update-template-basic-info.dto';
  10. import { TemplateAiClient } from './template-ai.client';
  11. import { TemplateTaskRunner } from './template-task-runner';
  12. type TemplateListQuery = ListTemplatesQueryDto & {
  13. stylistId: bigint;
  14. };
  15. const TEMPLATE_CATEGORIES = ['LONG', 'MEDIUM', 'SHORT', 'DYE'] as const;
  16. const TEMPLATE_STATUSES = ['DRAFT', 'PUBLIC'] as const;
  17. @Injectable()
  18. export class TemplatesService {
  19. private get systemConfigDelegate(): {
  20. findMany: (args: Record<string, unknown>) => Promise<
  21. Array<{
  22. id: bigint;
  23. configKey: string;
  24. configName: string;
  25. configGroup: string;
  26. configValue: string | null;
  27. }>
  28. >;
  29. } {
  30. return (
  31. this.prisma as PrismaService & {
  32. systemConfig: {
  33. findMany: (args: Record<string, unknown>) => Promise<
  34. Array<{
  35. id: bigint;
  36. configKey: string;
  37. configName: string;
  38. configGroup: string;
  39. configValue: string | null;
  40. }>
  41. >;
  42. };
  43. }
  44. ).systemConfig;
  45. }
  46. constructor(
  47. private readonly prisma: PrismaService,
  48. private readonly templateAiClient: TemplateAiClient,
  49. private readonly templateTaskRunner: TemplateTaskRunner,
  50. ) {}
  51. private async getOwnedTemplate(stylistId: bigint, templateId: string) {
  52. const template = await this.prisma.hairTemplate.findFirst({
  53. where: {
  54. id: BigInt(templateId),
  55. hairstylistId: stylistId,
  56. isDeleted: 0,
  57. },
  58. });
  59. if (!template) {
  60. throw new NotFoundException('模板不存在');
  61. }
  62. return template;
  63. }
  64. private toBasicInfoDto(template: {
  65. id: bigint;
  66. templateName: string;
  67. status: 'DRAFT' | 'PUBLIC';
  68. category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE';
  69. tags: string;
  70. description: string | null;
  71. coverUrl: string;
  72. templateUrl: string;
  73. }): TemplateBasicInfoDto {
  74. return {
  75. id: template.id.toString(),
  76. templateName: template.templateName,
  77. status: template.status,
  78. category: template.category,
  79. tags: template.tags
  80. ? template.tags
  81. .split(',')
  82. .map((tag) => tag.trim())
  83. .filter(Boolean)
  84. : [],
  85. description: template.description ?? '',
  86. coverUrl: template.coverUrl,
  87. templateUrl: template.templateUrl,
  88. };
  89. }
  90. private toTemplateListItem(
  91. template: {
  92. id: bigint;
  93. templateName: string;
  94. category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE';
  95. status: 'DRAFT' | 'PUBLIC';
  96. description: string | null;
  97. tags: string;
  98. coverUrl: string;
  99. usedCount: number;
  100. },
  101. taskMap: Map<string, { id: bigint; taskStatus: string }>,
  102. ) {
  103. const task = taskMap.get(template.id.toString());
  104. return {
  105. id: template.id.toString(),
  106. name: template.templateName,
  107. category: template.category,
  108. status: template.status,
  109. description: template.description ?? '',
  110. tags: template.tags
  111. ? template.tags
  112. .split(',')
  113. .map((tag) => tag.trim())
  114. .filter(Boolean)
  115. : [],
  116. image: template.coverUrl,
  117. makeTaskId: task?.id.toString(),
  118. makeTaskStatus: task?.taskStatus,
  119. usedCount: template.usedCount ?? 0,
  120. };
  121. }
  122. async getTemplateAssets(): Promise<TemplateAssetsDto> {
  123. type AssetConfigRow = {
  124. id: bigint;
  125. configKey: string;
  126. configName: string;
  127. configGroup: string;
  128. configValue: string | null;
  129. };
  130. const rows = await this.systemConfigDelegate.findMany({
  131. where: {
  132. isDeleted: 0,
  133. configGroup: {
  134. in: ['ai-model', 'clothing', 'background'],
  135. },
  136. },
  137. orderBy: [{ sort: 'asc' }, { id: 'asc' }],
  138. });
  139. const mapRow = (row: AssetConfigRow) => ({
  140. id: row.id.toString(),
  141. name: row.configName,
  142. url: row.configValue ?? '',
  143. configKey: row.configKey,
  144. });
  145. return {
  146. models: rows.filter((row: AssetConfigRow) => row.configGroup === 'ai-model').map(mapRow),
  147. clothes: rows.filter((row: AssetConfigRow) => row.configGroup === 'clothing').map(mapRow),
  148. backgrounds: rows
  149. .filter((row: AssetConfigRow) => row.configGroup === 'background')
  150. .map(mapRow),
  151. };
  152. }
  153. private async getRequiredStep1TemplateUrl(): Promise<string> {
  154. const rows = await this.systemConfigDelegate.findMany({
  155. where: {
  156. isDeleted: 0,
  157. configGroup: 'template-step1',
  158. },
  159. orderBy: [{ sort: 'asc' }, { id: 'asc' }],
  160. take: 1,
  161. });
  162. const templateUrl = rows[0]?.configValue?.trim() ?? '';
  163. if (!templateUrl) {
  164. throw new NotFoundException('缺少 template-step1 配置');
  165. }
  166. return templateUrl;
  167. }
  168. async createTemplateMakeTask(
  169. stylistId: bigint,
  170. dto: CreateTemplateMakeTaskDto,
  171. ): Promise<{
  172. templateId: string;
  173. taskId: string;
  174. totalTaskId: string;
  175. taskStatus: 'PROCESSING';
  176. }> {
  177. const step1TemplateUrl = await this.getRequiredStep1TemplateUrl();
  178. const totalTaskId = `tmpl_${Date.now()}_${randomUUID()}`;
  179. const created = await this.prisma.$transaction(async (tx) => {
  180. const template = await tx.hairTemplate.create({
  181. data: {
  182. templateName: dto.templateName,
  183. coverUrl: '',
  184. status: dto.status,
  185. category: dto.category,
  186. tags: (dto.tags ?? []).join(','),
  187. description: dto.description ?? null,
  188. modelUrl: dto.modelUrl,
  189. clothesUrl: dto.clothesUrl,
  190. backgroundUrl: dto.backgroundUrl,
  191. refHairUrl: dto.refHairUrl,
  192. hairstylistId: stylistId,
  193. templateUrl: '',
  194. isSystemTemplate: 'NO',
  195. },
  196. });
  197. const task = await tx.hairTemplateMakeTask.create({
  198. data: {
  199. totalTaskId,
  200. step1TaskId: '',
  201. step2TaskId: '',
  202. taskStatus: 'WAIT',
  203. templateId: template.id,
  204. step1Resource: JSON.stringify({
  205. templateUrl: step1TemplateUrl,
  206. lastError: null,
  207. }),
  208. step2Resource: JSON.stringify({
  209. uploadedZipUrl: null,
  210. lastError: null,
  211. }),
  212. refHairUrl: dto.refHairUrl,
  213. modelUrl: dto.modelUrl,
  214. clothesUrl: dto.clothesUrl,
  215. backgroundUrl: dto.backgroundUrl,
  216. },
  217. });
  218. return { template, task };
  219. });
  220. const step1 = await this.templateAiClient.executeTemplate(step1TemplateUrl, {
  221. images: [dto.refHairUrl],
  222. });
  223. await this.prisma.hairTemplateMakeTask.update({
  224. where: { id: created.task.id },
  225. data: {
  226. step1TaskId: step1.taskId,
  227. taskStatus: 'PROCESSING',
  228. step1Resource: JSON.stringify({
  229. templateUrl: step1TemplateUrl,
  230. lastError: null,
  231. }),
  232. },
  233. });
  234. return {
  235. templateId: created.template.id.toString(),
  236. taskId: created.task.id.toString(),
  237. totalTaskId,
  238. taskStatus: 'PROCESSING',
  239. };
  240. }
  241. async getTemplateMakeTaskDetail(
  242. hairstylistId: bigint,
  243. taskId: string,
  244. ) {
  245. return this.templateTaskRunner.refreshTask(hairstylistId, taskId);
  246. }
  247. async getBasicInfo(
  248. stylistId: bigint,
  249. templateId: string,
  250. ): Promise<TemplateBasicInfoDto> {
  251. const template = await this.getOwnedTemplate(stylistId, templateId);
  252. return this.toBasicInfoDto(template);
  253. }
  254. async updateBasicInfo(
  255. stylistId: bigint,
  256. templateId: string,
  257. dto: UpdateTemplateBasicInfoDto,
  258. ): Promise<TemplateBasicInfoDto> {
  259. const template = await this.getOwnedTemplate(stylistId, templateId);
  260. const updated = await this.prisma.hairTemplate.update({
  261. where: { id: template.id },
  262. data: {
  263. templateName: dto.templateName,
  264. status: dto.status,
  265. category: dto.category,
  266. tags: (dto.tags ?? []).join(','),
  267. description: dto.description ?? '',
  268. },
  269. });
  270. return this.toBasicInfoDto(updated);
  271. }
  272. async deleteTemplate(
  273. stylistId: bigint,
  274. templateId: string,
  275. ): Promise<{ success: true }> {
  276. const template = await this.getOwnedTemplate(stylistId, templateId);
  277. await this.prisma.hairTemplate.updateMany({
  278. where: {
  279. id: template.id,
  280. hairstylistId: stylistId,
  281. isDeleted: 0,
  282. },
  283. data: {
  284. isDeleted: 1,
  285. },
  286. });
  287. await this.prisma.hairTemplateMakeTask.updateMany({
  288. where: {
  289. templateId: template.id,
  290. isDeleted: 0,
  291. },
  292. data: {
  293. isDeleted: 1,
  294. },
  295. });
  296. return { success: true };
  297. }
  298. async deleteTemplates(
  299. stylistId: bigint,
  300. templateIds: string[],
  301. ): Promise<{ deletedCount: number }> {
  302. const parsedIds = Array.from(
  303. new Set(
  304. templateIds
  305. .map((id) => id.trim())
  306. .filter(Boolean)
  307. .map((id) => BigInt(id)),
  308. ),
  309. );
  310. if (parsedIds.length === 0) {
  311. return { deletedCount: 0 };
  312. }
  313. const ownedTemplates = await this.prisma.hairTemplate.findMany({
  314. where: {
  315. id: {
  316. in: parsedIds,
  317. },
  318. hairstylistId: stylistId,
  319. isDeleted: 0,
  320. },
  321. select: {
  322. id: true,
  323. },
  324. });
  325. const ownedIds = ownedTemplates.map((item) => item.id);
  326. if (ownedIds.length === 0) {
  327. return { deletedCount: 0 };
  328. }
  329. const { count } = await this.prisma.hairTemplate.updateMany({
  330. where: {
  331. id: {
  332. in: ownedIds,
  333. },
  334. hairstylistId: stylistId,
  335. isDeleted: 0,
  336. },
  337. data: {
  338. isDeleted: 1,
  339. },
  340. });
  341. await this.prisma.hairTemplateMakeTask.updateMany({
  342. where: {
  343. templateId: {
  344. in: ownedIds,
  345. },
  346. isDeleted: 0,
  347. },
  348. data: {
  349. isDeleted: 1,
  350. },
  351. });
  352. return {
  353. deletedCount: count,
  354. };
  355. }
  356. async getSummary(stylistId: bigint): Promise<{
  357. total: number;
  358. published: number;
  359. weeklyUsed: number;
  360. draft: number;
  361. }> {
  362. const now = new Date();
  363. const utcDay = now.getUTCDay();
  364. const diffToMonday = utcDay === 0 ? 6 : utcDay - 1;
  365. const startOfWeek = new Date(
  366. Date.UTC(
  367. now.getUTCFullYear(),
  368. now.getUTCMonth(),
  369. now.getUTCDate() - diffToMonday,
  370. ),
  371. );
  372. const endOfWeek = new Date(
  373. Date.UTC(
  374. now.getUTCFullYear(),
  375. now.getUTCMonth(),
  376. now.getUTCDate() - diffToMonday + 7,
  377. ),
  378. );
  379. const [total, published, draft, templates] = await Promise.all([
  380. this.prisma.hairTemplate.count({
  381. where: {
  382. hairstylistId: stylistId,
  383. isDeleted: 0,
  384. },
  385. }),
  386. this.prisma.hairTemplate.count({
  387. where: {
  388. hairstylistId: stylistId,
  389. isDeleted: 0,
  390. status: 'PUBLIC',
  391. },
  392. }),
  393. this.prisma.hairTemplate.count({
  394. where: {
  395. hairstylistId: stylistId,
  396. isDeleted: 0,
  397. status: 'DRAFT',
  398. },
  399. }),
  400. this.prisma.hairTemplate.findMany({
  401. where: {
  402. hairstylistId: stylistId,
  403. isDeleted: 0,
  404. },
  405. select: {
  406. id: true,
  407. },
  408. }),
  409. ]);
  410. const templateIds = templates.map((item) => item.id.toString());
  411. const weeklyUsed = templateIds.length
  412. ? await this.prisma.aiHairSimulationTask.count({
  413. where: {
  414. taskStatus: 'SUCCESSES',
  415. isDeleted: 0,
  416. templateId: {
  417. in: templateIds,
  418. },
  419. taskFinishTime: {
  420. gte: startOfWeek,
  421. lt: endOfWeek,
  422. },
  423. },
  424. })
  425. : 0;
  426. return {
  427. total,
  428. published,
  429. weeklyUsed,
  430. draft,
  431. };
  432. }
  433. async list(query: TemplateListQuery): Promise<{
  434. items: Array<{
  435. id: string;
  436. name: string;
  437. category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE';
  438. status: 'DRAFT' | 'PUBLIC';
  439. description: string;
  440. tags: string[];
  441. image: string;
  442. makeTaskId?: string;
  443. makeTaskStatus?: string;
  444. usedCount: number;
  445. }>;
  446. total: number;
  447. page: number;
  448. pageSize: number;
  449. }> {
  450. const page = query.page && query.page > 0 ? query.page : 1;
  451. const pageSize = query.pageSize && query.pageSize > 0 ? query.pageSize : 8;
  452. const keyword = query.q?.trim();
  453. const category = TEMPLATE_CATEGORIES.find((item) => item === query.category);
  454. const status = TEMPLATE_STATUSES.find((item) => item === query.status);
  455. const where = {
  456. hairstylistId: query.stylistId,
  457. isDeleted: 0,
  458. ...(category ? { category } : {}),
  459. ...(status ? { status } : {}),
  460. ...(keyword
  461. ? {
  462. OR: [
  463. { templateName: { contains: keyword } },
  464. { tags: { contains: keyword } },
  465. { description: { contains: keyword } },
  466. ],
  467. }
  468. : {}),
  469. };
  470. const [total, templates] = await Promise.all([
  471. this.prisma.hairTemplate.count({ where }),
  472. this.prisma.hairTemplate.findMany({
  473. where,
  474. orderBy: [{ id: 'desc' }],
  475. skip: (page - 1) * pageSize,
  476. take: pageSize,
  477. select: {
  478. id: true,
  479. templateName: true,
  480. category: true,
  481. status: true,
  482. description: true,
  483. tags: true,
  484. coverUrl: true,
  485. usedCount: true,
  486. },
  487. }),
  488. ]);
  489. const templateIds = templates.map((item) => item.id);
  490. const tasks = templateIds.length
  491. ? await this.prisma.hairTemplateMakeTask.findMany({
  492. where: {
  493. isDeleted: 0,
  494. templateId: { in: templateIds },
  495. },
  496. orderBy: [{ id: 'desc' }],
  497. })
  498. : [];
  499. const taskMap = new Map<string, { id: bigint; taskStatus: string }>();
  500. for (const task of tasks) {
  501. if (!task.templateId) {
  502. continue;
  503. }
  504. const key = task.templateId.toString();
  505. if (!taskMap.has(key)) {
  506. taskMap.set(key, { id: task.id, taskStatus: task.taskStatus });
  507. }
  508. }
  509. return {
  510. items: templates.map((template) => this.toTemplateListItem(template, taskMap)),
  511. total,
  512. page,
  513. pageSize,
  514. };
  515. }
  516. async create(_body: CreateTemplateDto): Promise<never> {
  517. throw new NotImplementedException('Template creation is not implemented yet');
  518. }
  519. }