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:
@@ -74,25 +74,28 @@ export default function PromoCodeDetailPage() {
|
||||
<>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} lg={8}>
|
||||
<Card title="推广二维码" size="small">
|
||||
<Card title="小程序码" size="small">
|
||||
{detail.qrcodeUrl ? (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<img
|
||||
src={detail.qrcodeUrl}
|
||||
alt="推广二维码"
|
||||
alt="推广小程序码"
|
||||
style={{ width: 200, height: 200, marginBottom: 12 }}
|
||||
/>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12, fontSize: 12 }}>
|
||||
微信扫码进入小程序(scene=活动 ID {detail.id})
|
||||
</Typography.Paragraph>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Button
|
||||
block
|
||||
onClick={() => void downloadQrcode(detail.qrcodeUrl!, `${detail.code}-qrcode.png`)}
|
||||
onClick={() => void downloadQrcode(detail.qrcodeUrl!, `${detail.code}-wxacode.png`)}
|
||||
>
|
||||
下载二维码
|
||||
下载小程序码
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
) : (
|
||||
<Typography.Text type="secondary">暂无二维码</Typography.Text>
|
||||
<Typography.Text type="secondary">暂无小程序码</Typography.Text>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
@@ -144,13 +147,18 @@ export default function PromoCodeDetailPage() {
|
||||
<Descriptions.Item label="状态">
|
||||
{PROMO_CODE_STATUS_LABELS[detail.status] || detail.status}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="活动 ID" span={2}>
|
||||
<Typography.Text copyable={{ text: String(detail.id) }}>
|
||||
{detail.id}(小程序码 scene)
|
||||
</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="二维码 ID" span={2}>
|
||||
<Typography.Text copyable={{ text: detail.qrcodeId }}>{detail.qrcodeId}</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="落地链接" span={2}>
|
||||
<Descriptions.Item label="H5 落地链接" span={2}>
|
||||
<Typography.Text copyable={{ text: detail.landingUrl }}>{detail.landingUrl}</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="OSS 地址" span={2}>
|
||||
<Descriptions.Item label="小程序码 OSS" span={2}>
|
||||
{detail.qrcodeUrl ? (
|
||||
<Typography.Text copyable={{ text: detail.qrcodeUrl }} ellipsis>
|
||||
{detail.qrcodeUrl}
|
||||
|
||||
@@ -44,6 +44,10 @@ WX_APP_SECRET=
|
||||
# 未配置时回退 WX_APP_ID,若与小程序 appid 不同会导致 invalid code
|
||||
WX_MINI_APP_ID=
|
||||
WX_MINI_APP_SECRET=
|
||||
# 推广码小程序码落地页(getwxacodeunlimit 的 page;勿前导 /)
|
||||
WX_MINI_PROMO_PAGE=pages/home/index
|
||||
# 小程序码打开版本:release | trial | develop(默认 release)
|
||||
# WX_MINI_ENV_VERSION=release
|
||||
WX_MCH_ID=
|
||||
WX_MCH_SERIAL_NO=
|
||||
WX_MCH_PRIVATE_KEY=
|
||||
|
||||
@@ -32,6 +32,10 @@ USER_H5_URL=https://user.runxian.top/user
|
||||
MOCK_WECHAT=false
|
||||
WX_APP_ID=
|
||||
WX_APP_SECRET=
|
||||
WX_MINI_APP_ID=
|
||||
WX_MINI_APP_SECRET=
|
||||
WX_MINI_PROMO_PAGE=pages/home/index
|
||||
# WX_MINI_ENV_VERSION=release
|
||||
WX_MCH_ID=
|
||||
WX_MCH_SERIAL_NO=
|
||||
WX_MCH_PRIVATE_KEY=
|
||||
|
||||
@@ -3,7 +3,12 @@ import { BadRequestException, Injectable, InternalServerErrorException, Logger }
|
||||
import { loadAppConfig } from '@dukang/shared-types';
|
||||
import { RedisService } from '../../common/redis/redis.service';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import type { IWechatProvider, WechatCodeSession, WechatOAuthSession } from './wechat.interface';
|
||||
import type {
|
||||
IWechatProvider,
|
||||
WechatCodeSession,
|
||||
WechatOAuthSession,
|
||||
WechatWxaCodeUnlimitedInput,
|
||||
} from './wechat.interface';
|
||||
import { logWechatAuth, type WechatActorRef } from './wechat-log.util';
|
||||
import {
|
||||
decryptPayResource,
|
||||
@@ -252,6 +257,52 @@ export class WechatApiProvider implements IWechatProvider {
|
||||
}
|
||||
}
|
||||
|
||||
async getWxaCodeUnlimited(input: WechatWxaCodeUnlimitedInput): Promise<Buffer> {
|
||||
const scene = (input.scene ?? '').trim();
|
||||
if (!scene || scene.length > 32) {
|
||||
throw new BadRequestException('小程序码 scene 须为 1~32 个可见字符');
|
||||
}
|
||||
const accessToken = await this.getMiniAccessToken();
|
||||
const page = (input.page ?? process.env.WX_MINI_PROMO_PAGE ?? 'pages/home/index').replace(
|
||||
/^\//,
|
||||
'',
|
||||
);
|
||||
const envFromCfg = process.env.WX_MINI_ENV_VERSION;
|
||||
const envVersion: 'release' | 'trial' | 'develop' =
|
||||
input.envVersion ??
|
||||
(envFromCfg === 'trial' || envFromCfg === 'develop' || envFromCfg === 'release'
|
||||
? envFromCfg
|
||||
: 'release');
|
||||
const body = {
|
||||
scene,
|
||||
page,
|
||||
width: input.width ?? 430,
|
||||
check_path: input.checkPath ?? false,
|
||||
env_version: envVersion,
|
||||
is_hyaline: input.isHyaline ?? false,
|
||||
};
|
||||
const apiUrl = `https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=${accessToken}`;
|
||||
const res = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
// 失败时微信返回 JSON(以 { 开头),成功为 PNG 二进制
|
||||
if (buf.length >= 1 && buf[0] === 0x7b /* '{' */) {
|
||||
let errMsg = '生成小程序码失败';
|
||||
try {
|
||||
const err = JSON.parse(buf.toString('utf8')) as { errcode?: number; errmsg?: string };
|
||||
errMsg = err.errmsg || errMsg;
|
||||
this.logger.error(`getwxacodeunlimit failed: ${err.errcode} ${err.errmsg}`);
|
||||
} catch {
|
||||
this.logger.error(`getwxacodeunlimit non-image response: ${buf.toString('utf8').slice(0, 200)}`);
|
||||
}
|
||||
throw new InternalServerErrorException(errMsg);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
async getPhoneNumberByCode(code: string, platform: 'mini' | 'h5', actorRef?: WechatActorRef): Promise<string> {
|
||||
if (platform === 'h5') {
|
||||
throw new InternalServerErrorException('H5 请使用短信绑定手机号');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable, NotImplementedException } from '@nestjs/common';
|
||||
import type { IWechatProvider } from './wechat.interface';
|
||||
import type { IWechatProvider, WechatWxaCodeUnlimitedInput } from './wechat.interface';
|
||||
|
||||
@Injectable()
|
||||
export class WechatDisabledProvider implements IWechatProvider {
|
||||
@@ -54,4 +54,8 @@ export class WechatDisabledProvider implements IWechatProvider {
|
||||
parsePayNotification() {
|
||||
return this.disabled();
|
||||
}
|
||||
|
||||
getWxaCodeUnlimited(_input: WechatWxaCodeUnlimitedInput): Promise<Buffer> {
|
||||
return this.disabled();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,17 @@ export type WechatPayNotifyResult = {
|
||||
amountFen: number;
|
||||
};
|
||||
|
||||
export type WechatWxaCodeUnlimitedInput = {
|
||||
/** 最大 32 可见字符,扫码后小程序 onLaunch.options.scene */
|
||||
scene: string;
|
||||
/** 小程序页面路径,如 pages/home/index(不要前导 /) */
|
||||
page?: string;
|
||||
width?: number;
|
||||
checkPath?: boolean;
|
||||
envVersion?: 'release' | 'trial' | 'develop';
|
||||
isHyaline?: boolean;
|
||||
};
|
||||
|
||||
export interface IWechatProvider {
|
||||
isEnabled(): boolean;
|
||||
|
||||
@@ -81,4 +92,7 @@ export interface IWechatProvider {
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
rawBody: string,
|
||||
): Promise<WechatPayNotifyResult>;
|
||||
|
||||
/** 获取不限制的小程序码(PNG Buffer),须服务端调用 */
|
||||
getWxaCodeUnlimited(input: WechatWxaCodeUnlimitedInput): Promise<Buffer>;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { Injectable, NotImplementedException } from '@nestjs/common';
|
||||
import * as QRCode from 'qrcode';
|
||||
import type {
|
||||
IWechatProvider,
|
||||
WechatCodeSession,
|
||||
WechatOAuthSession,
|
||||
WechatWxaCodeUnlimitedInput,
|
||||
} from './wechat.interface';
|
||||
|
||||
/**
|
||||
@@ -81,4 +83,15 @@ export class WechatMockProvider implements IWechatProvider {
|
||||
parsePayNotification(): never {
|
||||
throw new NotImplementedException('FEATURE_DISABLED');
|
||||
}
|
||||
|
||||
/** Mock:用普通二维码 PNG 占位,内容含 scene,便于本地联调上传 OSS */
|
||||
async getWxaCodeUnlimited(input: WechatWxaCodeUnlimitedInput): Promise<Buffer> {
|
||||
const scene = (input.scene ?? '').trim() || 'mock';
|
||||
return QRCode.toBuffer(`mock-wxa://promo?scene=${encodeURIComponent(scene)}`, {
|
||||
width: input.width ?? 430,
|
||||
margin: 1,
|
||||
type: 'png',
|
||||
color: { dark: '#1f1a17', light: '#ffffff' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,4 +86,8 @@ export class WechatRouterProvider implements IWechatProvider {
|
||||
) {
|
||||
return this.resolve().parsePayNotification(headers, rawBody);
|
||||
}
|
||||
|
||||
getWxaCodeUnlimited(input: Parameters<IWechatProvider['getWxaCodeUnlimited']>[0]) {
|
||||
return this.resolve().getWxaCodeUnlimited(input);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
/**
|
||||
* 调用微信 getwxacodeunlimit,scene=推广活动 ID,PNG 上传 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);
|
||||
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('推广码无效或已停用');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user