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
@@ -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);
}
}