推广码后端页面优化

This commit is contained in:
2026-07-23 00:43:49 +08:00
parent cd6b82abd8
commit e93e4a7721
11 changed files with 456 additions and 260 deletions
@@ -145,6 +145,7 @@ export default function PromoCodesPage() {
scene: values.scene,
ownerUserId: values.ownerUserId?.trim() || undefined,
remark: values.remark?.trim() || undefined,
page: values.page?.trim() || undefined,
}),
});
message.success('推广码已创建');
@@ -220,6 +221,13 @@ export default function PromoCodesPage() {
<Form.Item name="code" label="自定义码值(选填)">
<Input placeholder="留空自动生成,如 DKDEMO1" />
</Form.Item>
<Form.Item
name="page"
label="小程序落地页(选填)"
extra="如 pages/home/index;留空则使用服务端环境变量 WX_MINI_PROMO_PAGE"
>
<Input placeholder="pages/home/index" />
</Form.Item>
<Form.Item name="ownerUserId" label="关联用户 ID(选填)">
<Input placeholder="渠道负责人,填写用户数据库 ID" />
</Form.Item>
@@ -155,9 +155,6 @@ export default function PromoCodeDetailPage() {
<Descriptions.Item label="二维码 ID" span={2}>
<Typography.Text copyable={{ text: detail.qrcodeId }}>{detail.qrcodeId}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="H5 落地链接" span={2}>
<Typography.Text copyable={{ text: detail.landingUrl }}>{detail.landingUrl}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="小程序码 OSS" span={2}>
{detail.qrcodeUrl ? (
<Typography.Text copyable={{ text: detail.qrcodeUrl }} ellipsis>
@@ -179,7 +176,15 @@ export default function PromoCodeDetailPage() {
<Row gutter={16} style={{ marginTop: 16 }}>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="扫码次数" value={stats?.scanCount ?? detail.scanCount} />
<Statistic title="扫码进入次数" value={stats?.scanCount ?? detail.scanCount} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="扫码注册用户数"
value={stats?.registerCount ?? stats?.sourceMarkedCount ?? 0}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
@@ -195,11 +200,6 @@ export default function PromoCodeDetailPage() {
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="来源标记用户" value={stats?.sourceMarkedCount ?? '—'} />
</Card>
</Col>
</Row>
<Modal
@@ -3,6 +3,7 @@ import { captureIosJssdkEntryUrl } from '@dukang/weixin-sdk';
import { useEffect, useRef } from 'react';
import { finishLoginNavigate, forceReloadAfterAccountMerge, goLogin } from '../lib/auth-nav';
import { toast } from '../lib/api';
import { capturePromoSceneAndTouchScan } from '../lib/promo';
import { saveWechatLoginResult } from '../lib/pay-wechat';
import { applyWechatShare } from '../lib/wechat-share';
import { handleWechatAuthCallback } from '../lib/wechat-auth';
@@ -29,13 +30,16 @@ function currentPagePathWithQuery(): string {
}
/**
* H5 App 根节点专用:不可用 useDidShowApp 无页面 Context
* - 首次进页:捕获 iOS 签名 URL + 默认分享 + OAuth code 回调
* - 路由/回前台:刷新默认分享卡片
* H5 App 根节点iOS 签名 URL + 默认分享 + OAuth code 回调
* 小程序:冷启动时捕获推广码 scene 并回传扫码埋点。
*/
export default function WechatShareBootstrap() {
const handlingCode = useRef(false);
useEffect(() => {
void capturePromoSceneAndTouchScan();
}, []);
useEffect(() => {
if (process.env.TARO_ENV !== 'h5') return;
if (typeof window === 'undefined') return;
+129
View File
@@ -0,0 +1,129 @@
import Taro from '@tarojs/taro';
import { request } from './api';
const PROMO_ID_KEY = 'dukang_promo_id';
/** 同一次进入只 touch 一次扫码计数,避免首页反复 onShow 刷量 */
let lastScanTouchKey = '';
function safeDecode(raw: string): string {
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
}
function normalizePromoId(raw: unknown): string | null {
if (raw == null || raw === '') return null;
const s = safeDecode(String(raw)).trim();
// 小程序码 scene 写入的是推广活动数字 ID
if (!/^\d+$/.test(s)) return null;
return s;
}
type EnterOptionsLike = {
scene?: string | number;
query?: Record<string, string | undefined>;
path?: string;
};
/** 从启动/进入参数解析推广活动 ID(优先 query.scene,与 getwxacodeunlimit 一致) */
export function extractPromoIdFromEnterOptions(opts?: EnterOptionsLike | null): string | null {
if (!opts) return null;
const q = opts.query ?? {};
return (
normalizePromoId(q.scene) ||
normalizePromoId(q.promoId) ||
normalizePromoId(q.pid) ||
null
);
}
export function getStoredPromoId(): string | null {
try {
const v = Taro.getStorageSync(PROMO_ID_KEY);
return normalizePromoId(v);
} catch {
return null;
}
}
export function setStoredPromoId(promoId: string) {
const id = normalizePromoId(promoId);
if (!id) return;
try {
Taro.setStorageSync(PROMO_ID_KEY, id);
} catch {
/* ignore */
}
}
function readEnterOptions(): EnterOptionsLike | null {
try {
if (typeof Taro.getEnterOptionsSync === 'function') {
return Taro.getEnterOptionsSync() as EnterOptionsLike;
}
} catch {
/* ignore */
}
try {
if (typeof Taro.getLaunchOptionsSync === 'function') {
return Taro.getLaunchOptionsSync() as EnterOptionsLike;
}
} catch {
/* ignore */
}
// H5:从 URL query 读取
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') {
const params = new URLSearchParams(window.location.search);
return {
query: {
scene: params.get('scene') || undefined,
promoId: params.get('promoId') || undefined,
pid: params.get('pid') || undefined,
},
};
}
return null;
}
/**
* 主页面进入时:取出 scene(活动 ID)本地缓存,并回传 /promo/touch 累加扫码次数。
* 同一进入会话只计一次扫码。
*/
export async function capturePromoSceneAndTouchScan(): Promise<void> {
const opts = readEnterOptions();
const fromEnter = extractPromoIdFromEnterOptions(opts);
if (fromEnter) {
setStoredPromoId(fromEnter);
const touchKey = `${fromEnter}|${opts?.path || ''}|${JSON.stringify(opts?.query || {})}|${String(opts?.scene ?? '')}`;
if (touchKey === lastScanTouchKey) return;
lastScanTouchKey = touchKey;
await touchPromo({ promoId: fromEnter, countScan: true });
return;
}
// 无新 scene 时不重复扫码计数
}
/** 登录成功后:用已缓存的活动 ID 做归因(不重复加扫码次数) */
export async function touchStoredPromoAfterLogin(): Promise<void> {
const promoId = getStoredPromoId();
if (!promoId) return;
await touchPromo({ promoId, countScan: false });
}
async function touchPromo(input: { promoId: string; countScan: boolean }): Promise<void> {
try {
await request('/promo/touch', {
method: 'POST',
data: {
promoId: input.promoId,
countScan: input.countScan,
},
});
} catch {
/* 静默失败,不阻断浏览 */
}
}
+2
View File
@@ -7,6 +7,7 @@ import CouponBadge from '../../components/CouponBadge';
import ProductCarousel from '../../components/ProductCarousel';
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
import { request, toast } from '../../lib/api';
import { capturePromoSceneAndTouchScan } from '../../lib/promo';
import { getProductImages } from '../../lib/product-images';
import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location';
type Product = {
@@ -35,6 +36,7 @@ export default function HomePage() {
useDidShow(() => {
syncTabBarSelected(0);
void capturePromoSceneAndTouchScan();
void resolveUserCity().then((resolved) => {
setDisplayCity(resolved.displayCity);
setCityCode(getCityCodeForCatalog(resolved));
+2
View File
@@ -24,6 +24,7 @@ import {
type MiniWechatProfile,
} from '../../lib/mini-wechat-profile';
import { isLoggedIn, request, saveAuth, toast, type SessionPayload } from '../../lib/api';
import { touchStoredPromoAfterLogin } from '../../lib/promo';
const IS_WEAPP = process.env.TARO_ENV === 'weapp';
@@ -176,6 +177,7 @@ export default function LoginPage() {
refreshToken: data.refreshToken,
});
void syncMiniWechatProfile(wxInfo ?? getCachedWxProfile());
void touchStoredPromoAfterLogin();
if (!phoneValue) {
void fetchUserProfile()
.then((me) => resolveDefaultUserPhone(me))
+5 -1
View File
@@ -45,12 +45,16 @@ export type PromoCodeItem = {
};
export type PromoCodeStats = {
/** 扫码进入次数 */
scanCount: number;
orderCount: number;
conversionRate: number;
/** 归因用户数(user_promo_attribution */
attributionCount?: number;
/** 用户表 source_ref_id 指向本推广码的用户数 */
/** 扫码注册用户数:用户来源标记为本推广码 */
sourceMarkedCount?: number;
/** @deprecated 同 sourceMarkedCount,兼容旧字段名 */
registerCount?: number;
};
export type PromoCodeAttributedUser = {
@@ -26,7 +26,12 @@ 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, promoId: dto.promoId },
{
promoCode: dto.promoCode,
qrcodeId: dto.qrcodeId,
promoId: dto.promoId,
countScan: dto.countScan,
},
userId,
);
}
@@ -1,4 +1,5 @@
import { IsOptional, IsString } from 'class-validator';
import { IsBoolean, IsOptional, IsString } from 'class-validator';
import { Transform } from 'class-transformer';
export class PromoTouchDto {
@IsOptional()
@@ -13,4 +14,17 @@ export class PromoTouchDto {
@IsOptional()
@IsString()
promoId?: string;
/**
* 是否累加扫码次数。扫码进入为 true;登录后归因可传 false,避免重复计数。
* 默认 true。
*/
@IsOptional()
@Transform(({ value }) => {
if (value === false || value === 'false' || value === 0 || value === '0') return false;
if (value === true || value === 'true' || value === 1 || value === '1') return true;
return undefined;
})
@IsBoolean()
countScan?: boolean;
}
@@ -55,6 +55,12 @@ export class CreatePromoCodeDto {
@IsString()
@MaxLength(256)
remark?: string;
/** 小程序码落地页路径,如 pages/home/index;留空用环境变量 WX_MINI_PROMO_PAGE */
@IsOptional()
@IsString()
@MaxLength(128)
page?: string;
}
export class UpdatePromoCodeDto {
@@ -235,14 +235,19 @@ export class PromoCodeService {
/**
* 调用微信 getwxacodeunlimitscene=推广活动 IDPNG 上传 OSS uploads/qrcode/
*/
private async createQrcodeResource(promoId: bigint, code: string) {
private async createQrcodeResource(promoId: bigint, code: string, pagePath?: string) {
const scene = promoId.toString();
if (scene.length > 32) {
throw new BadRequestException('推广活动 ID 过长,无法写入小程序码 scene');
}
const page = (
pagePath?.trim() ||
process.env.WX_MINI_PROMO_PAGE ||
'pages/home/index'
).replace(/^\//, '');
const pngBuffer = await this.wechat.getWxaCodeUnlimited({
scene,
page: process.env.WX_MINI_PROMO_PAGE || 'pages/home/index',
page,
width: 430,
checkPath: false,
});
@@ -290,7 +295,7 @@ export class PromoCodeService {
});
try {
const resource = await this.createQrcodeResource(row.id, code);
const resource = await this.createQrcodeResource(row.id, code, dto.page);
const updated = await this.prisma.commonPromoCode.update({
where: { id: row.id },
data: { qrcodeResourceId: resource.id },
@@ -380,7 +385,13 @@ export class PromoCodeService {
/** C 端扫码/带参进入:累加 scan_count、归因、标记用户来源 */
async touch(
input: { promoCode?: string; qrcodeId?: string; promoId?: string },
input: {
promoCode?: string;
qrcodeId?: string;
promoId?: string;
/** 默认 true;登录后归因传 false 避免重复计扫码 */
countScan?: boolean;
},
userId?: bigint,
) {
const promoCode = input.promoCode?.trim().toUpperCase();
@@ -395,10 +406,13 @@ export class PromoCodeService {
throw new NotFoundException('推广码无效或已停用');
}
const shouldCountScan = input.countScan !== false;
if (shouldCountScan) {
await this.prisma.commonPromoCode.update({
where: { id: promo.id },
data: { scanCount: { increment: 1 } },
});
}
let attributed = false;
let sourceApplied = false;
@@ -428,6 +442,7 @@ export class PromoCodeService {
channelName: promo.name,
attributed,
sourceApplied,
scanCounted: shouldCountScan,
});
}
@@ -472,7 +487,14 @@ export class PromoCodeService {
},
}),
]);
return { scanCount, orderCount, conversionRate, attributionCount, sourceMarkedCount };
return {
scanCount,
orderCount,
conversionRate,
attributionCount,
sourceMarkedCount,
registerCount: sourceMarkedCount,
};
}
/** 推广码关联用户:归因记录或用户来源指向本码 */