feat(ops): v4.0.7 HQ 活动图快链与勾选导出
HQ 指定一张活动图为勾选主合伙人合成 PNG/zip;城市合伙人页增加快链与单下/导出。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -42,6 +42,7 @@
|
||||
"@sentry/node": "^10.69.0",
|
||||
"@wecom/aibot-node-sdk": "^1.0.7",
|
||||
"ali-oss": "^6.23.0",
|
||||
"archiver": "^7.0.1",
|
||||
"bullmq": "^5.12.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
@@ -61,6 +62,7 @@
|
||||
"@nestjs/cli": "^10.4.0",
|
||||
"@nestjs/schematics": "^10.1.0",
|
||||
"@types/ali-oss": "^6.23.3",
|
||||
"@types/archiver": "^6.0.4",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^20.14.0",
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Sequential Sharp compose bench (1 / 10 / 50) on a 1080×1920 template.
|
||||
* Usage: node scripts/with-api-env.cjs npx ts-node --transpile-only scripts/bench-activity-poster-pack.ts
|
||||
* or from dukang-api: npx ts-node --transpile-only scripts/bench-activity-poster-pack.ts
|
||||
*/
|
||||
import sharp from 'sharp';
|
||||
import {
|
||||
DEFAULT_ACTIVITY_POSTER_QR_SLOT,
|
||||
activityPosterQrSlotPx,
|
||||
activityPosterTemplateTooLarge,
|
||||
} from '@dukang/shared-types';
|
||||
|
||||
async function png(width: number, height: number, r: number, g: number, b: number) {
|
||||
return sharp({
|
||||
create: { width, height, channels: 3, background: { r, g, b } },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
async function compose(template: Buffer, qrPng: Buffer) {
|
||||
const meta = await sharp(template).metadata();
|
||||
if (!meta.width || !meta.height) throw new Error('no size');
|
||||
if (activityPosterTemplateTooLarge(meta.width, meta.height)) throw new Error('too large');
|
||||
const { left, top, size } = activityPosterQrSlotPx(
|
||||
meta.width,
|
||||
meta.height,
|
||||
DEFAULT_ACTIVITY_POSTER_QR_SLOT.qrXPct,
|
||||
DEFAULT_ACTIVITY_POSTER_QR_SLOT.qrYPct,
|
||||
DEFAULT_ACTIVITY_POSTER_QR_SLOT.qrSizePct,
|
||||
);
|
||||
const qr = await sharp(qrPng).resize(size, size, { fit: 'fill' }).png().toBuffer();
|
||||
return sharp(template).composite([{ input: qr, left, top }]).png().toBuffer();
|
||||
}
|
||||
|
||||
function rssMb() {
|
||||
return Math.round(process.memoryUsage().rss / 1024 / 1024);
|
||||
}
|
||||
|
||||
async function bench(n: number, template: Buffer, qr: Buffer) {
|
||||
const t0 = Date.now();
|
||||
const rss0 = rssMb();
|
||||
let last = 0;
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const out = await compose(template, qr);
|
||||
last = out.length;
|
||||
}
|
||||
const ms = Date.now() - t0;
|
||||
return { n, ms, avgMs: Math.round(ms / n), rss0, rss1: rssMb(), lastKb: Math.round(last / 1024) };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const template = await png(1080, 1920, 160, 32, 32);
|
||||
const qr = await png(430, 430, 255, 255, 255);
|
||||
console.log(`template=${Math.round(template.length / 1024)}KB qr=${Math.round(qr.length / 1024)}KB rss=${rssMb()}MB`);
|
||||
for (const n of [1, 10, 50]) {
|
||||
const row = await bench(n, template, qr);
|
||||
console.log(
|
||||
`n=${row.n} total=${row.ms}ms avg=${row.avgMs}ms rss ${row.rss0}->${row.rss1}MB out≈${row.lastKb}KB`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void main();
|
||||
@@ -129,6 +129,7 @@ export const HqOperationAction = {
|
||||
ACTIVITY_POSTER_UPDATE: 'ACTIVITY_POSTER_UPDATE',
|
||||
ACTIVITY_POSTER_UPDATE_STATUS: 'ACTIVITY_POSTER_UPDATE_STATUS',
|
||||
ACTIVITY_POSTER_DELETE: 'ACTIVITY_POSTER_DELETE',
|
||||
ACTIVITY_POSTER_PACK: 'ACTIVITY_POSTER_PACK',
|
||||
} as const;
|
||||
|
||||
export type HqOperationActionCode = (typeof HqOperationAction)[keyof typeof HqOperationAction];
|
||||
@@ -263,6 +264,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
|
||||
[HqOperationAction.ACTIVITY_POSTER_UPDATE]: '编辑活动图',
|
||||
[HqOperationAction.ACTIVITY_POSTER_UPDATE_STATUS]: '活动图上下架',
|
||||
[HqOperationAction.ACTIVITY_POSTER_DELETE]: '删除活动图',
|
||||
[HqOperationAction.ACTIVITY_POSTER_PACK]: '导出合伙人活动图',
|
||||
STORE_PAYOUT: '门店打款确认',
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { activityPosterQrSlotPx } from '@dukang/shared-types';
|
||||
import {
|
||||
activityPosterPackFileName,
|
||||
activityPosterQrSlotPx,
|
||||
activityPosterTemplateTooLarge,
|
||||
} from '@dukang/shared-types';
|
||||
import archiver from 'archiver';
|
||||
import type { Response } from 'express';
|
||||
import sharp from 'sharp';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
@@ -108,11 +114,7 @@ export class ActivityPosterService {
|
||||
}
|
||||
|
||||
async composeForPartner(partnerAccountId: bigint, posterId: bigint) {
|
||||
const poster = await this.require(posterId);
|
||||
if (poster.status !== 'ACTIVE') {
|
||||
throw new NotFoundException('活动图不存在或已下架');
|
||||
}
|
||||
|
||||
const poster = await this.requireActive(posterId);
|
||||
let qr: { buffer: Buffer; fileName: string };
|
||||
try {
|
||||
qr = await this.partnerAssoc.getQrcodeBuffer(partnerAccountId);
|
||||
@@ -123,17 +125,139 @@ export class ActivityPosterService {
|
||||
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();
|
||||
async composeForHqPartner(partnerAccountId: bigint, posterId: bigint) {
|
||||
const poster = await this.requireActive(posterId);
|
||||
const qr = await this.partnerAssoc.getExistingQrcodeBuffer(partnerAccountId);
|
||||
if (!qr) {
|
||||
throw new BadRequestException('关联码尚未生成,无法合成活动图');
|
||||
}
|
||||
const template = await this.fetchPngLike(poster.imageUrl, '活动图底图下载失败');
|
||||
const buffer = await this.compose(template, qr.buffer, poster);
|
||||
return { buffer, fileName: await this.fileNameForPartner(partnerAccountId) };
|
||||
}
|
||||
|
||||
async packForPartners(posterId: bigint, partnerIds: string[], res: Response) {
|
||||
const ids = uniqueNumericIds(partnerIds);
|
||||
if (!ids.length) throw new BadRequestException('请选择有效的合伙人');
|
||||
|
||||
const poster = await this.requireActive(posterId);
|
||||
const template = await this.fetchPngLike(poster.imageUrl, '活动图底图下载失败');
|
||||
await this.assertTemplateSize(template);
|
||||
|
||||
const rows = await this.prisma.partnerAccount.findMany({
|
||||
where: { id: { in: ids.map((id) => BigInt(id)) }, isPrimary: 1 },
|
||||
select: {
|
||||
id: true,
|
||||
isTest: true,
|
||||
companyName: true,
|
||||
name: true,
|
||||
assocQrcodeResource: { select: { url: true } },
|
||||
city: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
const byId = new Map(rows.map((row) => [row.id.toString(), row]));
|
||||
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="activity-poster-${poster.id.toString()}-pack.zip"`,
|
||||
);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
|
||||
const archive = archiver('zip', { zlib: { level: 6 } });
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
archive.on('end', () => resolve());
|
||||
archive.on('error', reject);
|
||||
res.on('error', reject);
|
||||
});
|
||||
archive.pipe(res);
|
||||
|
||||
const skipped: string[] = [];
|
||||
for (const id of ids) {
|
||||
const row = byId.get(id);
|
||||
if (!row) {
|
||||
skipped.push(`${id}\t不是有效主合伙人`);
|
||||
continue;
|
||||
}
|
||||
if (row.isTest) {
|
||||
skipped.push(`${id}\t测试账号已跳过`);
|
||||
continue;
|
||||
}
|
||||
const qrUrl = row.assocQrcodeResource?.url;
|
||||
if (!qrUrl) {
|
||||
skipped.push(`${id}\t关联码尚未生成`);
|
||||
continue;
|
||||
}
|
||||
const qrRes = await fetch(qrUrl);
|
||||
if (!qrRes.ok) {
|
||||
skipped.push(`${id}\t关联码下载失败`);
|
||||
continue;
|
||||
}
|
||||
const qrBuffer = Buffer.from(await qrRes.arrayBuffer());
|
||||
try {
|
||||
const buffer = await this.compose(template, qrBuffer, poster);
|
||||
archive.append(buffer, {
|
||||
name: activityPosterPackFileName({
|
||||
cityName: row.city?.name,
|
||||
companyName: row.companyName,
|
||||
partnerName: row.name,
|
||||
partnerId: id,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
skipped.push(`${id}\t合成失败`);
|
||||
}
|
||||
}
|
||||
if (skipped.length) {
|
||||
archive.append(`${skipped.join('\n')}\n`, { name: '_skipped.txt' });
|
||||
}
|
||||
await archive.finalize();
|
||||
await done;
|
||||
}
|
||||
|
||||
private async fileNameForPartner(partnerAccountId: bigint) {
|
||||
const row = await this.prisma.partnerAccount.findUnique({
|
||||
where: { id: partnerAccountId },
|
||||
select: { id: true, companyName: true, name: true, city: { select: { name: true } } },
|
||||
});
|
||||
return activityPosterPackFileName({
|
||||
cityName: row?.city?.name,
|
||||
companyName: row?.companyName,
|
||||
partnerName: row?.name,
|
||||
partnerId: partnerAccountId.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
private async requireActive(id: bigint) {
|
||||
const poster = await this.require(id);
|
||||
if (poster.status !== 'ACTIVE') {
|
||||
throw new NotFoundException('活动图不存在或已下架');
|
||||
}
|
||||
return poster;
|
||||
}
|
||||
|
||||
private async assertTemplateSize(template: Buffer) {
|
||||
const meta = await sharp(template).metadata();
|
||||
if (!meta.width || !meta.height) {
|
||||
throw new BadRequestException('活动图底图无法读取尺寸');
|
||||
}
|
||||
if (activityPosterTemplateTooLarge(meta.width, meta.height)) {
|
||||
throw new BadRequestException('活动图底图过大,请压缩后再上传(最长边不超过 2500px)');
|
||||
}
|
||||
}
|
||||
|
||||
private async compose(template: Buffer, qrPng: Buffer, poster: PosterRow) {
|
||||
const meta = await sharp(template).metadata();
|
||||
if (!meta.width || !meta.height) {
|
||||
throw new BadRequestException('活动图底图无法读取尺寸');
|
||||
}
|
||||
if (activityPosterTemplateTooLarge(meta.width, meta.height)) {
|
||||
throw new BadRequestException('活动图底图过大,请压缩后再上传(最长边不超过 2500px)');
|
||||
}
|
||||
const { left, top, size } = activityPosterQrSlotPx(
|
||||
meta.width,
|
||||
meta.height,
|
||||
@@ -142,7 +266,7 @@ export class ActivityPosterService {
|
||||
Number(poster.qrSizePct),
|
||||
);
|
||||
const qr = await sharp(qrPng).resize(size, size, { fit: 'fill' }).png().toBuffer();
|
||||
return base.composite([{ input: qr, left, top }]).png().toBuffer();
|
||||
return sharp(template).composite([{ input: qr, left, top }]).png().toBuffer();
|
||||
}
|
||||
|
||||
private async fetchPngLike(url: string, failMessage: string) {
|
||||
@@ -186,3 +310,15 @@ export class ActivityPosterService {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueNumericIds(raw: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const ids: string[] = [];
|
||||
for (const item of raw) {
|
||||
const id = String(item ?? '').trim();
|
||||
if (!/^\d+$/.test(id) || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Post, Put, Query, Res, UseGuards, BadRequestException } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { HqPermissionGuard, RequireHqPermissions } from '../../common/guards/hq-permission.guard';
|
||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||
import { ActivityPosterService } from './activity-poster.service';
|
||||
import {
|
||||
ActivityPosterPackDto,
|
||||
ActivityPosterQueryDto,
|
||||
UpdateActivityPosterStatusDto,
|
||||
UpsertActivityPosterDto,
|
||||
@@ -21,6 +23,36 @@ export class AdminActivityPostersController {
|
||||
return this.service.adminList(query);
|
||||
}
|
||||
|
||||
@Get(':id/image')
|
||||
async image(
|
||||
@Param('id') id: string,
|
||||
@Query('partnerId') partnerId: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
if (!partnerId || !/^\d+$/.test(partnerId.trim())) {
|
||||
throw new BadRequestException('请指定合伙人');
|
||||
}
|
||||
const { buffer, fileName } = await this.service.composeForHqPartner(BigInt(partnerId.trim()), BigInt(id));
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="activity-poster.png"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
|
||||
);
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
@Post(':id/partner-pack')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.ACTIVITY_POSTER_PACK,
|
||||
refType: 'ACTIVITY_POSTER',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
includeResponse: false,
|
||||
})
|
||||
async pack(@Param('id') id: string, @Body() dto: ActivityPosterPackDto, @Res() res: Response) {
|
||||
await this.service.packForPartners(BigInt(id), dto.partnerIds, res);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.service.adminDetail(BigInt(id));
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
@@ -11,7 +14,7 @@ import {
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import { ACTIVITY_POSTER_STATUSES } from '@dukang/shared-types';
|
||||
import { ACTIVITY_POSTER_PACK_MAX_PARTNERS, ACTIVITY_POSTER_STATUSES } from '@dukang/shared-types';
|
||||
import { PaginationQueryDto } from './admin-query.dto';
|
||||
|
||||
export class ActivityPosterQueryDto extends PaginationQueryDto {
|
||||
@@ -76,3 +79,11 @@ export class ActivityPosterSelectionDto {
|
||||
@IsString()
|
||||
posterId?: string | null;
|
||||
}
|
||||
|
||||
export class ActivityPosterPackDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(ACTIVITY_POSTER_PACK_MAX_PARTNERS)
|
||||
@IsString({ each: true })
|
||||
partnerIds!: string[];
|
||||
}
|
||||
|
||||
@@ -452,4 +452,19 @@ export class PartnerAssocService {
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
return { buffer, fileName: `partner-assoc-${summary.partnerId}.png` };
|
||||
}
|
||||
|
||||
/** 只读已有 OSS 关联码,不调微信补码。无码返回 null。 */
|
||||
async getExistingQrcodeBuffer(partnerAccountId: bigint): Promise<{ buffer: Buffer; fileName: string } | null> {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
if (!primary.assocQrcodeResourceId) return null;
|
||||
const resource = await this.prisma.commonResource.findUnique({
|
||||
where: { id: primary.assocQrcodeResourceId },
|
||||
select: { url: true },
|
||||
});
|
||||
if (!resource?.url) return null;
|
||||
const res = await fetch(resource.url);
|
||||
if (!res.ok) return null;
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
return { buffer, fileName: `partner-assoc-${primary.id.toString()}.png` };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user