feat(promo): generate WeChat mini-program codes on create

Use getwxacodeunlimit with activity id as scene, upload PNG to OSS qrcode/, and show in HQ admin.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-23 00:21:10 +08:00
parent 0418370c31
commit bca1d8afea
11 changed files with 162 additions and 34 deletions
@@ -26,7 +26,7 @@ export class PromoController {
touch(@CurrentUser() user: AuthUser | undefined, @Body() dto: PromoTouchDto) {
const userId = user?.actorType === ActorType.USER ? user.actorId : undefined;
return this.promoCodeService.touch(
{ promoCode: dto.promoCode, qrcodeId: dto.qrcodeId },
{ promoCode: dto.promoCode, qrcodeId: dto.qrcodeId, promoId: dto.promoId },
userId,
);
}
@@ -1,4 +1,4 @@
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { IsOptional, IsString } from 'class-validator';
export class PromoTouchDto {
@IsOptional()
@@ -8,4 +8,9 @@ export class PromoTouchDto {
@IsOptional()
@IsString()
qrcodeId?: string;
/** 小程序码 scene 中的推广活动 ID */
@IsOptional()
@IsString()
promoId?: string;
}
@@ -5,7 +5,6 @@ import {
NotFoundException,
} from '@nestjs/common';
import { randomBytes } from 'crypto';
import * as QRCode from 'qrcode';
import {
PROMO_CODE_SCENE_LABELS,
PromoCodeScene,
@@ -14,8 +13,9 @@ import {
} from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { OSS_PROVIDER } from '../../integrations/integrations.constants';
import { OSS_PROVIDER, WECHAT_PROVIDER } from '../../integrations/integrations.constants';
import type { IOssProvider } from '../../integrations/oss/oss.interface';
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
import type {
CreatePromoCodeDto,
PromoCodeListQueryDto,
@@ -70,6 +70,7 @@ export class PromoCodeService {
constructor(
private readonly prisma: PrismaService,
@Inject(OSS_PROVIDER) private readonly oss: IOssProvider,
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
) {}
listScenes() {
@@ -231,18 +232,25 @@ export class PromoCodeService {
throw new BadRequestException('生成二维码 ID 失败,请重试');
}
private async createQrcodeResource(promoId: bigint, code: string, qrcodeId: string) {
const landingUrl = buildLandingUrl(code, qrcodeId);
const pngBuffer = await QRCode.toBuffer(landingUrl, {
width: 512,
margin: 1,
type: 'png',
color: { dark: '#1f1a17', light: '#ffffff' },
/**
* 调用微信 getwxacodeunlimitscene=推广活动 IDPNG 上传 OSS uploads/qrcode/
*/
private async createQrcodeResource(promoId: bigint, code: string) {
const scene = promoId.toString();
if (scene.length > 32) {
throw new BadRequestException('推广活动 ID 过长,无法写入小程序码 scene');
}
const pngBuffer = await this.wechat.getWxaCodeUnlimited({
scene,
page: process.env.WX_MINI_PROMO_PAGE || 'pages/home/index',
width: 430,
checkPath: false,
});
const fileName = `promo-${code}-${scene}.png`;
const uploaded = await this.oss.putObject({
bizType: 'QRCODE',
mediaType: 'IMAGE',
fileName: `promo-${code}.png`,
fileName,
buffer: pngBuffer,
mimeType: 'image/png',
});
@@ -255,7 +263,7 @@ export class PromoCodeService {
ossBucket: uploaded.bucket,
ossKey: uploaded.ossKey,
url: uploaded.url,
fileName: `promo-${code}.png`,
fileName,
fileSize: BigInt(pngBuffer.length),
mimeType: 'image/png',
},
@@ -281,13 +289,18 @@ export class PromoCodeService {
},
});
const resource = await this.createQrcodeResource(row.id, code, qrcodeId);
const updated = await this.prisma.commonPromoCode.update({
where: { id: row.id },
data: { qrcodeResourceId: resource.id },
include: this.includeRelations,
});
return this.mapRow(updated);
try {
const resource = await this.createQrcodeResource(row.id, code);
const updated = await this.prisma.commonPromoCode.update({
where: { id: row.id },
data: { qrcodeResourceId: resource.id },
include: this.includeRelations,
});
return this.mapRow(updated);
} catch (err) {
await this.prisma.commonPromoCode.delete({ where: { id: row.id } }).catch(() => undefined);
throw err;
}
}
async update(id: bigint, dto: UpdatePromoCodeDto) {
@@ -349,9 +362,13 @@ export class PromoCodeService {
};
}
async findByCodeOrQrcodeId(input: { code?: string; qrcodeId?: string }) {
async findByCodeOrQrcodeId(input: { code?: string; qrcodeId?: string; promoId?: string }) {
const code = input.code?.trim().toUpperCase();
const qrcodeId = input.qrcodeId?.trim();
const promoId = input.promoId?.trim();
if (promoId && /^\d+$/.test(promoId)) {
return this.prisma.commonPromoCode.findUnique({ where: { id: BigInt(promoId) } });
}
if (code) {
return this.prisma.commonPromoCode.findUnique({ where: { code } });
}
@@ -362,14 +379,18 @@ export class PromoCodeService {
}
/** C 端扫码/带参进入:累加 scan_count、归因、标记用户来源 */
async touch(input: { promoCode?: string; qrcodeId?: string }, userId?: bigint) {
async touch(
input: { promoCode?: string; qrcodeId?: string; promoId?: string },
userId?: bigint,
) {
const promoCode = input.promoCode?.trim().toUpperCase();
const qrcodeId = input.qrcodeId?.trim();
if (!promoCode && !qrcodeId) {
throw new BadRequestException('请提供 promoCode 或 qrcodeId');
const promoId = input.promoId?.trim();
if (!promoCode && !qrcodeId && !promoId) {
throw new BadRequestException('请提供 promoId、promoCode 或 qrcodeId');
}
const promo = await this.findByCodeOrQrcodeId({ code: promoCode, qrcodeId });
const promo = await this.findByCodeOrQrcodeId({ code: promoCode, qrcodeId, promoId });
if (!promo || promo.status !== 'ACTIVE') {
throw new NotFoundException('推广码无效或已停用');
}