import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { activityPosterQrSlotPx } from '@dukang/shared-types'; import sharp from 'sharp'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { PartnerAssocService } from '../store/partner-assoc.service'; import type { ActivityPosterQueryDto, UpdateActivityPosterStatusDto, UpsertActivityPosterDto, } from './dto/activity-poster.dto'; type PosterRow = { id: bigint; title: string; copyText: string; imageUrl: string; qrXPct: Prisma.Decimal; qrYPct: Prisma.Decimal; qrSizePct: Prisma.Decimal; sortOrder: number; status: string; createdAt: Date; updatedAt: Date; }; @Injectable() export class ActivityPosterService { constructor( private readonly prisma: PrismaService, private readonly partnerAssoc: PartnerAssocService, ) {} async adminList(query: ActivityPosterQueryDto) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const where: Prisma.ActivityPosterWhereInput = {}; if (query.status) where.status = query.status; const [items, total] = await Promise.all([ this.prisma.activityPoster.findMany({ where, orderBy: [{ sortOrder: 'asc' }, { id: 'desc' }], skip: (page - 1) * pageSize, take: pageSize, }), this.prisma.activityPoster.count({ where }), ]); return serializeBigInt({ items: items.map((row) => this.format(row)), total, page, pageSize, }); } async adminDetail(id: bigint) { return serializeBigInt(this.format(await this.require(id))); } async create(dto: UpsertActivityPosterDto) { const row = await this.prisma.activityPoster.create({ data: this.toCreateData(dto), }); return serializeBigInt(this.format(row)); } async update(id: bigint, dto: UpsertActivityPosterDto) { await this.require(id); const row = await this.prisma.activityPoster.update({ where: { id }, data: this.toCreateData(dto), }); return serializeBigInt(this.format(row)); } async updateStatus(id: bigint, dto: UpdateActivityPosterStatusDto) { await this.require(id); const row = await this.prisma.activityPoster.update({ where: { id }, data: { status: dto.status }, }); return serializeBigInt(this.format(row)); } async remove(id: bigint) { await this.require(id); await this.prisma.activityPoster.delete({ where: { id } }); return { ok: true }; } async listForPartner() { const items = await this.prisma.activityPoster.findMany({ where: { status: 'ACTIVE' }, orderBy: [{ sortOrder: 'asc' }, { id: 'desc' }], }); return serializeBigInt(items.map((row) => this.format(row))); } getSelection(partnerAccountId: bigint) { return this.partnerAssoc.getSelectedActivityPosterId(partnerAccountId).then((posterId) => ({ posterId })); } setSelection(partnerAccountId: bigint, posterId: bigint | null) { return this.partnerAssoc.setSelectedActivityPoster(partnerAccountId, posterId); } async composeForPartner(partnerAccountId: bigint, posterId: bigint) { const poster = await this.require(posterId); if (poster.status !== 'ACTIVE') { throw new NotFoundException('活动图不存在或已下架'); } let qr: { buffer: Buffer; fileName: string }; try { qr = await this.partnerAssoc.getQrcodeBuffer(partnerAccountId); } catch (e) { if (e instanceof NotFoundException) { throw new BadRequestException('关联码尚未生成,无法合成活动图'); } throw e; } const template = await this.fetchPngLike(poster.imageUrl, '活动图底图下载失败'); const buffer = await this.compose(template, qr.buffer, poster); return { buffer, fileName: `activity-poster-${poster.id}.png` }; } private async compose(template: Buffer, qrPng: Buffer, poster: PosterRow) { const base = sharp(template); const meta = await base.metadata(); if (!meta.width || !meta.height) { throw new BadRequestException('活动图底图无法读取尺寸'); } const { left, top, size } = activityPosterQrSlotPx( meta.width, meta.height, Number(poster.qrXPct), Number(poster.qrYPct), Number(poster.qrSizePct), ); const qr = await sharp(qrPng).resize(size, size, { fit: 'fill' }).png().toBuffer(); return base.composite([{ input: qr, left, top }]).png().toBuffer(); } private async fetchPngLike(url: string, failMessage: string) { const res = await fetch(url); if (!res.ok) throw new BadRequestException(failMessage); return Buffer.from(await res.arrayBuffer()); } private async require(id: bigint) { const row = await this.prisma.activityPoster.findUnique({ where: { id } }); if (!row) throw new NotFoundException('活动图不存在'); return row; } private toCreateData(dto: UpsertActivityPosterDto): Prisma.ActivityPosterCreateInput { return { title: dto.title.trim(), copyText: (dto.copyText ?? '').trim(), imageUrl: dto.imageUrl.trim(), qrXPct: new Prisma.Decimal(dto.qrXPct.toFixed(2)), qrYPct: new Prisma.Decimal(dto.qrYPct.toFixed(2)), qrSizePct: new Prisma.Decimal(dto.qrSizePct.toFixed(2)), sortOrder: dto.sortOrder ?? 0, status: dto.status ?? 'ACTIVE', }; } private format(row: PosterRow) { return { id: row.id.toString(), title: row.title, copyText: row.copyText, imageUrl: row.imageUrl, qrXPct: Number(row.qrXPct), qrYPct: Number(row.qrYPct), qrSizePct: Number(row.qrSizePct), sortOrder: row.sortOrder, status: row.status, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; } }