diff --git a/apps/admin-web/src/pages/PromoCodesPage.tsx b/apps/admin-web/src/pages/PromoCodesPage.tsx index ccf1703..094a008 100644 --- a/apps/admin-web/src/pages/PromoCodesPage.tsx +++ b/apps/admin-web/src/pages/PromoCodesPage.tsx @@ -1,236 +1,244 @@ -import { useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { - Button, Form, Input, Modal, Select, Space, Table, Tag, Typography, message, -} from 'antd'; -import type { ColumnsType } from 'antd/es/table'; -import { - PROMO_CODE_SCENE_LABELS, - PROMO_CODE_STATUS_LABELS, - promoConversion, - type PromoCodeItem, - type PromoCodeScene, -} from '@dukang/shared-types'; -import { request } from '../lib/api'; -import { fmtTime } from '../lib/constants'; -import { useAdminList } from '../lib/useAdminList'; - -type Row = PromoCodeItem; - -type SceneOption = { value: PromoCodeScene; label: string }; - -async function downloadQrcode(url: string, filename: string) { - try { - const res = await fetch(url); - const blob = await res.blob(); - const objectUrl = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = objectUrl; - a.download = filename; - a.click(); - URL.revokeObjectURL(objectUrl); - } catch { - window.open(url, '_blank'); - } -} - -export default function PromoCodesPage() { - const navigate = useNavigate(); - const [filterForm] = Form.useForm(); - const [createForm] = Form.useForm(); - const [filters, setFilters] = useState>({}); - const [scenes, setScenes] = useState( - Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({ - value: value as PromoCodeScene, - label, - })), - ); - const [createOpen, setCreateOpen] = useState(false); - const [creating, setCreating] = useState(false); - - const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( - '/admin/promo-codes', - () => { - const qs = new URLSearchParams(); - if (filters.name) qs.set('name', filters.name); - if (filters.code) qs.set('code', filters.code); - if (filters.status) qs.set('status', filters.status); - if (filters.scene) qs.set('scene', filters.scene); - return qs; - }, - [filters], - ); - - async function loadScenes() { - try { - const list = await request('/admin/promo-codes/scenes'); - if (list.length) setScenes(list); - } catch { - /* 使用本地默认场景 */ - } - } - - useEffect(() => { - void loadScenes(); - }, []); - - const columns: ColumnsType = [ - { title: '名称', dataIndex: 'name', width: 160, ellipsis: true }, - { title: '码值', dataIndex: 'code', width: 110 }, - { - title: '场景', - dataIndex: 'scene', - width: 110, - render: (s: PromoCodeScene) => PROMO_CODE_SCENE_LABELS[s] || s, - }, - { - title: '状态', - dataIndex: 'status', - width: 90, - render: (s) => ( - - {PROMO_CODE_STATUS_LABELS[s as keyof typeof PROMO_CODE_STATUS_LABELS] || s} - - ), - }, - { title: '扫码', dataIndex: 'scanCount', width: 70 }, - { title: '订单', dataIndex: 'orderCount', width: 70 }, - { - title: '转化率', - width: 90, - render: (_, row) => promoConversion(row.scanCount, row.orderCount), - }, - { - title: '渠道负责人', - width: 120, - render: (_, row) => row.ownerUser?.userNo || row.ownerUser?.phone || '—', - }, - { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, - { - title: '操作', - width: 220, - fixed: 'right', - render: (_, row) => ( - - - - - - ), - }, - ]; - - async function handleCreate(values: Record) { - setCreating(true); - try { - const created = await request('/admin/promo-codes', { - method: 'POST', - body: JSON.stringify({ - name: values.name, - code: values.code?.trim() || undefined, - scene: values.scene, - ownerUserId: values.ownerUserId?.trim() || undefined, - remark: values.remark?.trim() || undefined, - }), - }); - message.success('推广码已创建'); - setCreateOpen(false); - createForm.resetFields(); - void reload(); - navigate(`/promo-codes/${created.id}`); - } catch (e) { - message.error(e instanceof Error ? e.message : '创建失败'); - } finally { - setCreating(false); - } - } - - return ( -
-
- 推广码管理 - -
- -
{ setFilters(v); setPage(1); }} - > - - - - ({ value, label }))} - /> - - -
- - { setPage(p); setPageSize(ps); }, - }} - /> - - setCreateOpen(false)} - footer={null} - destroyOnClose - > -
- - - - - - - - - - - - - - -
- - ); -} +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Button, Form, Input, Modal, Select, Space, Table, Tag, Typography, message, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { + PROMO_CODE_SCENE_LABELS, + PROMO_CODE_STATUS_LABELS, + promoConversion, + type PromoCodeItem, + type PromoCodeScene, +} from '@dukang/shared-types'; +import { request } from '../lib/api'; +import { fmtTime } from '../lib/constants'; +import { useAdminList } from '../lib/useAdminList'; + +type Row = PromoCodeItem; + +type SceneOption = { value: PromoCodeScene; label: string }; + +async function downloadQrcode(url: string, filename: string) { + try { + const res = await fetch(url); + const blob = await res.blob(); + const objectUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = objectUrl; + a.download = filename; + a.click(); + URL.revokeObjectURL(objectUrl); + } catch { + window.open(url, '_blank'); + } +} + +export default function PromoCodesPage() { + const navigate = useNavigate(); + const [filterForm] = Form.useForm(); + const [createForm] = Form.useForm(); + const [filters, setFilters] = useState>({}); + const [scenes, setScenes] = useState( + Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({ + value: value as PromoCodeScene, + label, + })), + ); + const [createOpen, setCreateOpen] = useState(false); + const [creating, setCreating] = useState(false); + + const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( + '/admin/promo-codes', + () => { + const qs = new URLSearchParams(); + if (filters.name) qs.set('name', filters.name); + if (filters.code) qs.set('code', filters.code); + if (filters.status) qs.set('status', filters.status); + if (filters.scene) qs.set('scene', filters.scene); + return qs; + }, + [filters], + ); + + async function loadScenes() { + try { + const list = await request('/admin/promo-codes/scenes'); + if (list.length) setScenes(list); + } catch { + /* 使用本地默认场景 */ + } + } + + useEffect(() => { + void loadScenes(); + }, []); + + const columns: ColumnsType = [ + { title: '名称', dataIndex: 'name', width: 160, ellipsis: true }, + { title: '码值', dataIndex: 'code', width: 110 }, + { + title: '场景', + dataIndex: 'scene', + width: 110, + render: (s: PromoCodeScene) => PROMO_CODE_SCENE_LABELS[s] || s, + }, + { + title: '状态', + dataIndex: 'status', + width: 90, + render: (s) => ( + + {PROMO_CODE_STATUS_LABELS[s as keyof typeof PROMO_CODE_STATUS_LABELS] || s} + + ), + }, + { title: '扫码', dataIndex: 'scanCount', width: 70 }, + { title: '订单', dataIndex: 'orderCount', width: 70 }, + { + title: '转化率', + width: 90, + render: (_, row) => promoConversion(row.scanCount, row.orderCount), + }, + { + title: '渠道负责人', + width: 120, + render: (_, row) => row.ownerUser?.userNo || row.ownerUser?.phone || '—', + }, + { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, + { + title: '操作', + width: 220, + fixed: 'right', + render: (_, row) => ( + + + + + + ), + }, + ]; + + async function handleCreate(values: Record) { + setCreating(true); + try { + const created = await request('/admin/promo-codes', { + method: 'POST', + body: JSON.stringify({ + name: values.name, + code: values.code?.trim() || undefined, + scene: values.scene, + ownerUserId: values.ownerUserId?.trim() || undefined, + remark: values.remark?.trim() || undefined, + page: values.page?.trim() || undefined, + }), + }); + message.success('推广码已创建'); + setCreateOpen(false); + createForm.resetFields(); + void reload(); + navigate(`/promo-codes/${created.id}`); + } catch (e) { + message.error(e instanceof Error ? e.message : '创建失败'); + } finally { + setCreating(false); + } + } + + return ( +
+
+ 推广码管理 + +
+ +
{ setFilters(v); setPage(1); }} + > + + + + ({ value, label }))} + /> + + + + +
{ setPage(p); setPageSize(ps); }, + }} + /> + + setCreateOpen(false)} + footer={null} + destroyOnClose + > +
+ + + + + + + + + + + + + + + + + +
+ + ); +} diff --git a/apps/admin-web/src/pages/promo/PromoCodeDetailPage.tsx b/apps/admin-web/src/pages/promo/PromoCodeDetailPage.tsx index ca77cbd..6608beb 100644 --- a/apps/admin-web/src/pages/promo/PromoCodeDetailPage.tsx +++ b/apps/admin-web/src/pages/promo/PromoCodeDetailPage.tsx @@ -155,9 +155,6 @@ export default function PromoCodeDetailPage() { {detail.qrcodeId} - - {detail.landingUrl} - {detail.qrcodeUrl ? ( @@ -179,7 +176,15 @@ export default function PromoCodeDetailPage() { - + + + + + + @@ -195,11 +200,6 @@ export default function PromoCodeDetailPage() { /> - - - - - { + void capturePromoSceneAndTouchScan(); + }, []); + useEffect(() => { if (process.env.TARO_ENV !== 'h5') return; if (typeof window === 'undefined') return; diff --git a/apps/mini-user/src/lib/promo.ts b/apps/mini-user/src/lib/promo.ts new file mode 100644 index 0000000..5e7dce0 --- /dev/null +++ b/apps/mini-user/src/lib/promo.ts @@ -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; + 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 { + 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 { + const promoId = getStoredPromoId(); + if (!promoId) return; + await touchPromo({ promoId, countScan: false }); +} + +async function touchPromo(input: { promoId: string; countScan: boolean }): Promise { + try { + await request('/promo/touch', { + method: 'POST', + data: { + promoId: input.promoId, + countScan: input.countScan, + }, + }); + } catch { + /* 静默失败,不阻断浏览 */ + } +} diff --git a/apps/mini-user/src/pages/home/index.tsx b/apps/mini-user/src/pages/home/index.tsx index 6f72c9f..6474764 100644 --- a/apps/mini-user/src/pages/home/index.tsx +++ b/apps/mini-user/src/pages/home/index.tsx @@ -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)); diff --git a/apps/mini-user/src/pages/login/index.tsx b/apps/mini-user/src/pages/login/index.tsx index ca9a2ab..2eb53f8 100644 --- a/apps/mini-user/src/pages/login/index.tsx +++ b/apps/mini-user/src/pages/login/index.tsx @@ -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)) diff --git a/packages/shared-types/src/promo.ts b/packages/shared-types/src/promo.ts index ab7c317..59d5081 100644 --- a/packages/shared-types/src/promo.ts +++ b/packages/shared-types/src/promo.ts @@ -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 = { diff --git a/server/dukang-api/src/modules/analytics/analytics.controller.ts b/server/dukang-api/src/modules/analytics/analytics.controller.ts index bd1eabd..82491c7 100644 --- a/server/dukang-api/src/modules/analytics/analytics.controller.ts +++ b/server/dukang-api/src/modules/analytics/analytics.controller.ts @@ -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, ); } 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 48c44f4..7550d15 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,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; } diff --git a/server/dukang-api/src/modules/promo/dto/promo-code.dto.ts b/server/dukang-api/src/modules/promo/dto/promo-code.dto.ts index 86fd453..5f9e131 100644 --- a/server/dukang-api/src/modules/promo/dto/promo-code.dto.ts +++ b/server/dukang-api/src/modules/promo/dto/promo-code.dto.ts @@ -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 { 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 8f0ef4d..834b6d2 100644 --- a/server/dukang-api/src/modules/promo/promo-code.service.ts +++ b/server/dukang-api/src/modules/promo/promo-code.service.ts @@ -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, + }; } /** 推广码关联用户:归因记录或用户来源指向本码 */