推广码后端页面优化
This commit is contained in:
@@ -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 根节点专用:不可用 useDidShow(App 无页面 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;
|
||||
|
||||
@@ -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 {
|
||||
/* 静默失败,不阻断浏览 */
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
/**
|
||||
* 调用微信 getwxacodeunlimit,scene=推广活动 ID,PNG 上传 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('推广码无效或已停用');
|
||||
}
|
||||
|
||||
await this.prisma.commonPromoCode.update({
|
||||
where: { id: promo.id },
|
||||
data: { scanCount: { increment: 1 } },
|
||||
});
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/** 推广码关联用户:归因记录或用户来源指向本码 */
|
||||
|
||||
Reference in New Issue
Block a user