From bca1d8afeae4f3b8be9e846cbcb83a308693ba99 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Thu, 23 Jul 2026 00:21:10 +0800 Subject: [PATCH] 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 --- .../src/pages/promo/PromoCodeDetailPage.tsx | 22 ++++-- server/dukang-api/.env.example | 4 ++ server/dukang-api/.env.production.example | 4 ++ .../wechat/wechat.api.provider.ts | 53 ++++++++++++++- .../wechat/wechat.disabled.provider.ts | 6 +- .../integrations/wechat/wechat.interface.ts | 14 ++++ .../wechat/wechat.mock.provider.ts | 13 ++++ .../wechat/wechat.router.provider.ts | 4 ++ .../modules/analytics/analytics.controller.ts | 2 +- .../src/modules/analytics/dto/promo.dto.ts | 7 +- .../src/modules/promo/promo-code.service.ts | 67 ++++++++++++------- 11 files changed, 162 insertions(+), 34 deletions(-) diff --git a/apps/admin-web/src/pages/promo/PromoCodeDetailPage.tsx b/apps/admin-web/src/pages/promo/PromoCodeDetailPage.tsx index 079330e..ca77cbd 100644 --- a/apps/admin-web/src/pages/promo/PromoCodeDetailPage.tsx +++ b/apps/admin-web/src/pages/promo/PromoCodeDetailPage.tsx @@ -74,25 +74,28 @@ export default function PromoCodeDetailPage() { <> - + {detail.qrcodeUrl ? (
推广二维码 + + 微信扫码进入小程序(scene=活动 ID {detail.id}) +
) : ( - 暂无二维码 + 暂无小程序码 )}
@@ -144,13 +147,18 @@ export default function PromoCodeDetailPage() { {PROMO_CODE_STATUS_LABELS[detail.status] || detail.status} + + + {detail.id}(小程序码 scene) + + {detail.qrcodeId} - + {detail.landingUrl} - + {detail.qrcodeUrl ? ( {detail.qrcodeUrl} diff --git a/server/dukang-api/.env.example b/server/dukang-api/.env.example index 1a166ff..ef00593 100644 --- a/server/dukang-api/.env.example +++ b/server/dukang-api/.env.example @@ -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= diff --git a/server/dukang-api/.env.production.example b/server/dukang-api/.env.production.example index ddc926e..b212bef 100644 --- a/server/dukang-api/.env.production.example +++ b/server/dukang-api/.env.production.example @@ -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= diff --git a/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts b/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts index ee3fbec..959ac77 100644 --- a/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts +++ b/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts @@ -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 { + 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 { if (platform === 'h5') { throw new InternalServerErrorException('H5 请使用短信绑定手机号'); diff --git a/server/dukang-api/src/integrations/wechat/wechat.disabled.provider.ts b/server/dukang-api/src/integrations/wechat/wechat.disabled.provider.ts index 520c498..385ece9 100644 --- a/server/dukang-api/src/integrations/wechat/wechat.disabled.provider.ts +++ b/server/dukang-api/src/integrations/wechat/wechat.disabled.provider.ts @@ -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 { + return this.disabled(); + } } diff --git a/server/dukang-api/src/integrations/wechat/wechat.interface.ts b/server/dukang-api/src/integrations/wechat/wechat.interface.ts index 61052a9..c44ee97 100644 --- a/server/dukang-api/src/integrations/wechat/wechat.interface.ts +++ b/server/dukang-api/src/integrations/wechat/wechat.interface.ts @@ -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, rawBody: string, ): Promise; + + /** 获取不限制的小程序码(PNG Buffer),须服务端调用 */ + getWxaCodeUnlimited(input: WechatWxaCodeUnlimitedInput): Promise; } diff --git a/server/dukang-api/src/integrations/wechat/wechat.mock.provider.ts b/server/dukang-api/src/integrations/wechat/wechat.mock.provider.ts index 01e2e0d..98fcb7a 100644 --- a/server/dukang-api/src/integrations/wechat/wechat.mock.provider.ts +++ b/server/dukang-api/src/integrations/wechat/wechat.mock.provider.ts @@ -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 { + 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' }, + }); + } } diff --git a/server/dukang-api/src/integrations/wechat/wechat.router.provider.ts b/server/dukang-api/src/integrations/wechat/wechat.router.provider.ts index b63c0c2..109363b 100644 --- a/server/dukang-api/src/integrations/wechat/wechat.router.provider.ts +++ b/server/dukang-api/src/integrations/wechat/wechat.router.provider.ts @@ -86,4 +86,8 @@ export class WechatRouterProvider implements IWechatProvider { ) { return this.resolve().parsePayNotification(headers, rawBody); } + + getWxaCodeUnlimited(input: Parameters[0]) { + return this.resolve().getWxaCodeUnlimited(input); + } } diff --git a/server/dukang-api/src/modules/analytics/analytics.controller.ts b/server/dukang-api/src/modules/analytics/analytics.controller.ts index ea149f0..bd1eabd 100644 --- a/server/dukang-api/src/modules/analytics/analytics.controller.ts +++ b/server/dukang-api/src/modules/analytics/analytics.controller.ts @@ -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, ); } diff --git a/server/dukang-api/src/modules/analytics/dto/promo.dto.ts b/server/dukang-api/src/modules/analytics/dto/promo.dto.ts index b6aff03..48c44f4 100644 --- a/server/dukang-api/src/modules/analytics/dto/promo.dto.ts +++ b/server/dukang-api/src/modules/analytics/dto/promo.dto.ts @@ -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; } diff --git a/server/dukang-api/src/modules/promo/promo-code.service.ts b/server/dukang-api/src/modules/promo/promo-code.service.ts index db9ceb0..8f0ef4d 100644 --- a/server/dukang-api/src/modules/promo/promo-code.service.ts +++ b/server/dukang-api/src/modules/promo/promo-code.service.ts @@ -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); - 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('推广码无效或已停用'); }