feat(ops): v4.0.7 HQ 活动图快链与勾选导出

HQ 指定一张活动图为勾选主合伙人合成 PNG/zip;城市合伙人页增加快链与单下/导出。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-01 15:12:10 +08:00
parent eeee55e215
commit cdc4b82931
21 changed files with 1041 additions and 28 deletions
+2
View File
@@ -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` };
}
}