feat(promo): promo metric event logs and HQ timeline charts (v3.4.13)
Add log_promo_event for scan/attribution/register/order with IP; admin metrics APIs and ECharts on promo detail page. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
|||||||
import { request } from '../../lib/api';
|
import { request } from '../../lib/api';
|
||||||
import { fmtTime } from '../../lib/constants';
|
import { fmtTime } from '../../lib/constants';
|
||||||
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
|
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
|
||||||
|
import PromoCodeMetricsPanel from './PromoCodeMetricsPanel';
|
||||||
|
|
||||||
const descLabelStyle: CSSProperties = {
|
const descLabelStyle: CSSProperties = {
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
@@ -243,6 +244,8 @@ export default function PromoCodeDetailPage() {
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
<PromoCodeMetricsPanel promoId={detail.id} />
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title="编辑推广码"
|
title="编辑推广码"
|
||||||
open={editOpen}
|
open={editOpen}
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
DatePicker,
|
||||||
|
Radio,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import dayjs, { type Dayjs } from 'dayjs';
|
||||||
|
import ReactECharts from 'echarts-for-react';
|
||||||
|
import type { EChartsOption } from 'echarts';
|
||||||
|
import {
|
||||||
|
PROMO_METRIC_EVENT_LABELS,
|
||||||
|
type PromoMetricEventItem,
|
||||||
|
type PromoMetricEventType,
|
||||||
|
type PromoMetricTimelineDto,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import { request, type Paginated } from '../../lib/api';
|
||||||
|
import { fmtTime } from '../../lib/constants';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
promoId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SERIES = [
|
||||||
|
{ key: 'scan', name: '扫码进入', color: '#1677ff' },
|
||||||
|
{ key: 'attribution', name: '归因用户', color: '#52c41a' },
|
||||||
|
{ key: 'register', name: '扫码注册', color: '#faad14' },
|
||||||
|
{ key: 'order', name: '订单', color: '#eb2f96' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function buildTimelineQs(promoId: string, range: [Dayjs, Dayjs], granularity: 'day' | 'hour') {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
qs.set('dateFrom', range[0].format('YYYY-MM-DD'));
|
||||||
|
qs.set('dateTo', range[1].format('YYYY-MM-DD'));
|
||||||
|
qs.set('granularity', granularity);
|
||||||
|
return `/admin/promo-codes/${promoId}/metrics/timeline?${qs.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEventsQs(
|
||||||
|
promoId: string,
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
range: [Dayjs, Dayjs],
|
||||||
|
eventType?: PromoMetricEventType,
|
||||||
|
) {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
qs.set('page', String(page));
|
||||||
|
qs.set('pageSize', String(pageSize));
|
||||||
|
qs.set('dateFrom', range[0].format('YYYY-MM-DD'));
|
||||||
|
qs.set('dateTo', range[1].format('YYYY-MM-DD'));
|
||||||
|
if (eventType) qs.set('eventType', eventType);
|
||||||
|
return `/admin/promo-codes/${promoId}/metrics/events?${qs.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRefId(row: PromoMetricEventItem): string {
|
||||||
|
if (row.orderId) return `订单 ${row.orderId}`;
|
||||||
|
if (row.userId) return `用户 ${row.userId}`;
|
||||||
|
if (row.sessionId) return `会话 ${row.sessionId}`;
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLocation(row: PromoMetricEventItem): string {
|
||||||
|
const parts = [row.ipProvince, row.ipCity].filter(Boolean);
|
||||||
|
if (parts.length) return parts.join(' ');
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PromoCodeMetricsPanel({ promoId }: Props) {
|
||||||
|
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs().subtract(6, 'day'), dayjs()]);
|
||||||
|
const [granularity, setGranularity] = useState<'day' | 'hour'>('day');
|
||||||
|
const [timeline, setTimeline] = useState<PromoMetricTimelineDto | null>(null);
|
||||||
|
const [timelineLoading, setTimelineLoading] = useState(false);
|
||||||
|
const [eventType, setEventType] = useState<PromoMetricEventType | undefined>();
|
||||||
|
const [eventsPage, setEventsPage] = useState(1);
|
||||||
|
const [events, setEvents] = useState<Paginated<PromoMetricEventItem> | null>(null);
|
||||||
|
const [eventsLoading, setEventsLoading] = useState(false);
|
||||||
|
|
||||||
|
const loadTimeline = useCallback(() => {
|
||||||
|
setTimelineLoading(true);
|
||||||
|
return request<PromoMetricTimelineDto>(buildTimelineQs(promoId, range, granularity))
|
||||||
|
.then(setTimeline)
|
||||||
|
.catch((e) => {
|
||||||
|
message.error(e instanceof Error ? e.message : '加载趋势失败');
|
||||||
|
setTimeline(null);
|
||||||
|
})
|
||||||
|
.finally(() => setTimelineLoading(false));
|
||||||
|
}, [promoId, range, granularity]);
|
||||||
|
|
||||||
|
const loadEvents = useCallback(() => {
|
||||||
|
setEventsLoading(true);
|
||||||
|
return request<Paginated<PromoMetricEventItem>>(
|
||||||
|
buildEventsQs(promoId, eventsPage, 20, range, eventType),
|
||||||
|
)
|
||||||
|
.then(setEvents)
|
||||||
|
.catch((e) => {
|
||||||
|
message.error(e instanceof Error ? e.message : '加载事件失败');
|
||||||
|
setEvents(null);
|
||||||
|
})
|
||||||
|
.finally(() => setEventsLoading(false));
|
||||||
|
}, [promoId, eventsPage, range, eventType]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadTimeline();
|
||||||
|
}, [loadTimeline]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadEvents();
|
||||||
|
}, [loadEvents]);
|
||||||
|
|
||||||
|
const chartOption = useMemo<EChartsOption>(() => {
|
||||||
|
const buckets = timeline?.buckets ?? [];
|
||||||
|
const xData = buckets.map((b) => b.key);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
legend: { data: SERIES.map((s) => s.name), bottom: 0 },
|
||||||
|
grid: { left: 48, right: 24, top: 24, bottom: 48 },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: xData,
|
||||||
|
axisLabel: {
|
||||||
|
rotate: granularity === 'hour' ? 45 : 0,
|
||||||
|
formatter: (v: string) => (granularity === 'hour' ? v.slice(5, 16) : v.slice(5)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
yAxis: { type: 'value', minInterval: 1 },
|
||||||
|
series: SERIES.map((s) => ({
|
||||||
|
name: s.name,
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
data: buckets.map((b) => b[s.key]),
|
||||||
|
itemStyle: { color: s.color },
|
||||||
|
markPoint: {
|
||||||
|
symbol: 'pin',
|
||||||
|
symbolSize: 42,
|
||||||
|
data: (() => {
|
||||||
|
const peak = timeline?.peak[s.key];
|
||||||
|
if (!peak || peak.count <= 0) return [];
|
||||||
|
return [{ name: '高峰', coord: [peak.key, peak.count], value: peak.count }];
|
||||||
|
})(),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}, [timeline, granularity]);
|
||||||
|
|
||||||
|
const columns: ColumnsType<PromoMetricEventItem> = [
|
||||||
|
{
|
||||||
|
title: '时间',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
width: 168,
|
||||||
|
render: (v: string) => fmtTime(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '类型',
|
||||||
|
dataIndex: 'eventType',
|
||||||
|
width: 108,
|
||||||
|
render: (v: PromoMetricEventType) => PROMO_METRIC_EVENT_LABELS[v],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'ID',
|
||||||
|
key: 'ref',
|
||||||
|
render: (_, row) => formatRefId(row),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'IP',
|
||||||
|
dataIndex: 'clientIp',
|
||||||
|
width: 128,
|
||||||
|
render: (v: string | null) => v ?? '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '地点',
|
||||||
|
key: 'location',
|
||||||
|
width: 120,
|
||||||
|
render: (_, row) => formatLocation(row),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title="数据趋势"
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
extra={
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
仅统计功能上线后的新事件
|
||||||
|
</Typography.Text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Space wrap style={{ marginBottom: 16 }}>
|
||||||
|
<DatePicker.RangePicker
|
||||||
|
value={range}
|
||||||
|
onChange={(v) => {
|
||||||
|
if (v?.[0] && v[1]) {
|
||||||
|
setRange([v[0], v[1]]);
|
||||||
|
setEventsPage(1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
allowClear={false}
|
||||||
|
/>
|
||||||
|
<Radio.Group
|
||||||
|
value={granularity}
|
||||||
|
onChange={(e) => setGranularity(e.target.value as 'day' | 'hour')}
|
||||||
|
optionType="button"
|
||||||
|
buttonStyle="solid"
|
||||||
|
options={[
|
||||||
|
{ label: '按日', value: 'day' },
|
||||||
|
{ label: '按时', value: 'hour' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<ReactECharts
|
||||||
|
option={chartOption}
|
||||||
|
style={{ height: 360 }}
|
||||||
|
showLoading={timelineLoading}
|
||||||
|
notMerge
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 24, marginBottom: 12 }}>
|
||||||
|
事件日志
|
||||||
|
</Typography.Title>
|
||||||
|
<Space wrap style={{ marginBottom: 12 }}>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="全部类型"
|
||||||
|
style={{ width: 160 }}
|
||||||
|
value={eventType}
|
||||||
|
onChange={(v) => {
|
||||||
|
setEventType(v);
|
||||||
|
setEventsPage(1);
|
||||||
|
}}
|
||||||
|
options={Object.entries(PROMO_METRIC_EVENT_LABELS).map(([value, label]) => ({
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={events?.items ?? []}
|
||||||
|
loading={eventsLoading}
|
||||||
|
pagination={{
|
||||||
|
current: eventsPage,
|
||||||
|
pageSize: events?.pageSize ?? 20,
|
||||||
|
total: events?.total ?? 0,
|
||||||
|
showSizeChanger: false,
|
||||||
|
onChange: (p) => setEventsPage(p),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -85,6 +85,50 @@ export function promoConversion(scan: number, orders: number): string {
|
|||||||
return `${Math.round((orders / scan) * 1000) / 10}%`;
|
return `${Math.round((orders / scan) * 1000) / 10}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PromoMetricEventType = 'SCAN' | 'ATTRIBUTION' | 'REGISTER' | 'ORDER';
|
||||||
|
|
||||||
|
export const PROMO_METRIC_EVENT_LABELS: Record<PromoMetricEventType, string> = {
|
||||||
|
SCAN: '扫码进入',
|
||||||
|
ATTRIBUTION: '归因用户',
|
||||||
|
REGISTER: '扫码注册',
|
||||||
|
ORDER: '订单',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PromoMetricEventItem = {
|
||||||
|
id: string;
|
||||||
|
eventType: PromoMetricEventType;
|
||||||
|
userId: string | null;
|
||||||
|
orderId: string | null;
|
||||||
|
sessionId: string | null;
|
||||||
|
clientIp: string | null;
|
||||||
|
ipProvince: string | null;
|
||||||
|
ipCity: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PromoMetricTimelineBucket = {
|
||||||
|
key: string;
|
||||||
|
scan: number;
|
||||||
|
attribution: number;
|
||||||
|
register: number;
|
||||||
|
order: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PromoMetricTimelinePeak = {
|
||||||
|
scan: { key: string; count: number } | null;
|
||||||
|
attribution: { key: string; count: number } | null;
|
||||||
|
register: { key: string; count: number } | null;
|
||||||
|
order: { key: string; count: number } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PromoMetricTimelineDto = {
|
||||||
|
granularity: 'day' | 'hour';
|
||||||
|
dateFrom: string;
|
||||||
|
dateTo: string;
|
||||||
|
buckets: PromoMetricTimelineBucket[];
|
||||||
|
peak: PromoMetricTimelinePeak;
|
||||||
|
};
|
||||||
|
|
||||||
export function buildPromoLandingUrl(baseUrl: string, code: string, qrcodeId: string): string {
|
export function buildPromoLandingUrl(baseUrl: string, code: string, qrcodeId: string): string {
|
||||||
const base = baseUrl.replace(/\/$/, '');
|
const base = baseUrl.replace(/\/$/, '');
|
||||||
return `${base}/?promo=${encodeURIComponent(code)}&pid=${encodeURIComponent(qrcodeId)}`;
|
return `${base}/?promo=${encodeURIComponent(code)}&pid=${encodeURIComponent(qrcodeId)}`;
|
||||||
|
|||||||
@@ -181,6 +181,13 @@ enum PromoCodeScene {
|
|||||||
OTHER
|
OTHER
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum PromoMetricEventType {
|
||||||
|
SCAN
|
||||||
|
ATTRIBUTION
|
||||||
|
REGISTER
|
||||||
|
ORDER
|
||||||
|
}
|
||||||
|
|
||||||
enum CityStatus {
|
enum CityStatus {
|
||||||
PENDING
|
PENDING
|
||||||
ACTIVE
|
ACTIVE
|
||||||
@@ -856,6 +863,7 @@ model CommonPromoCode {
|
|||||||
qrcodeResource CommonResource? @relation("PromoQrcode", fields: [qrcodeResourceId], references: [id], onDelete: SetNull)
|
qrcodeResource CommonResource? @relation("PromoQrcode", fields: [qrcodeResourceId], references: [id], onDelete: SetNull)
|
||||||
attributions UserPromoAttribution[]
|
attributions UserPromoAttribution[]
|
||||||
orders Order[]
|
orders Order[]
|
||||||
|
metricEvents LogPromoEvent[]
|
||||||
|
|
||||||
@@index([ownerUserId])
|
@@index([ownerUserId])
|
||||||
@@index([scene, status])
|
@@index([scene, status])
|
||||||
@@ -1759,6 +1767,25 @@ model LogThirdParty {
|
|||||||
@@map("log_third_party")
|
@@map("log_third_party")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model LogPromoEvent {
|
||||||
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
|
promoCodeId BigInt @map("promo_code_id") @db.UnsignedBigInt
|
||||||
|
eventType PromoMetricEventType @map("event_type")
|
||||||
|
userId BigInt? @map("user_id") @db.UnsignedBigInt
|
||||||
|
orderId BigInt? @map("order_id") @db.UnsignedBigInt
|
||||||
|
sessionId String? @map("session_id") @db.VarChar(64)
|
||||||
|
clientIp String? @map("client_ip") @db.VarChar(45)
|
||||||
|
ipProvince String? @map("ip_province") @db.VarChar(32)
|
||||||
|
ipCity String? @map("ip_city") @db.VarChar(32)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
|
|
||||||
|
promoCode CommonPromoCode @relation(fields: [promoCodeId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([promoCodeId, eventType, createdAt])
|
||||||
|
@@index([createdAt])
|
||||||
|
@@map("log_promo_event")
|
||||||
|
}
|
||||||
|
|
||||||
model LogUserAnalytics {
|
model LogUserAnalytics {
|
||||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
userId BigInt? @map("user_id") @db.UnsignedBigInt
|
userId BigInt? @map("user_id") @db.UnsignedBigInt
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { BadRequestException, Body, Controller, Post, UseGuards } from '@nestjs/common';
|
import { BadRequestException, Body, Controller, Post, Req, UseGuards } from '@nestjs/common';
|
||||||
|
import type { Request } from 'express';
|
||||||
import { AnalyticsService } from './analytics.service';
|
import { AnalyticsService } from './analytics.service';
|
||||||
import { PromoCodeService } from '../promo/promo-code.service';
|
import { PromoCodeService } from '../promo/promo-code.service';
|
||||||
|
import { extractClientIp } from '../../common/geo/client-ip.util';
|
||||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||||
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
|
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
@@ -66,8 +68,13 @@ export class PromoController {
|
|||||||
|
|
||||||
@Post('touch')
|
@Post('touch')
|
||||||
@UseGuards(OptionalJwtAuthGuard)
|
@UseGuards(OptionalJwtAuthGuard)
|
||||||
async touch(@CurrentUser() user: AuthUser | undefined, @Body() dto: PromoTouchDto) {
|
async touch(
|
||||||
|
@CurrentUser() user: AuthUser | undefined,
|
||||||
|
@Body() dto: PromoTouchDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
const userId = user?.actorType === ActorType.USER ? user.actorId : undefined;
|
const userId = user?.actorType === ActorType.USER ? user.actorId : undefined;
|
||||||
|
const clientIp = extractClientIp(req) ?? undefined;
|
||||||
const result = await this.promoCodeService.touch(
|
const result = await this.promoCodeService.touch(
|
||||||
{
|
{
|
||||||
promoCode: dto.promoCode,
|
promoCode: dto.promoCode,
|
||||||
@@ -76,6 +83,7 @@ export class PromoController {
|
|||||||
countScan: dto.countScan,
|
countScan: dto.countScan,
|
||||||
},
|
},
|
||||||
userId,
|
userId,
|
||||||
|
{ clientIp, sessionId: dto.sessionId },
|
||||||
);
|
);
|
||||||
|
|
||||||
void this.analyticsService.trackBatchOptional(
|
void this.analyticsService.trackBatchOptional(
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { PromoCodeService } from './promo-code.service';
|
|||||||
import {
|
import {
|
||||||
CreatePromoCodeDto,
|
CreatePromoCodeDto,
|
||||||
PromoCodeListQueryDto,
|
PromoCodeListQueryDto,
|
||||||
|
PromoMetricEventsQueryDto,
|
||||||
|
PromoMetricTimelineQueryDto,
|
||||||
UpdatePromoCodeDto,
|
UpdatePromoCodeDto,
|
||||||
UpdatePromoCodeStatusDto,
|
UpdatePromoCodeStatusDto,
|
||||||
} from './dto/promo-code.dto';
|
} from './dto/promo-code.dto';
|
||||||
@@ -39,6 +41,16 @@ export class AdminPromoCodeController {
|
|||||||
return this.service.stats(BigInt(id));
|
return this.service.stats(BigInt(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/metrics/timeline')
|
||||||
|
metricsTimeline(@Param('id') id: string, @Query() query: PromoMetricTimelineQueryDto) {
|
||||||
|
return this.service.getMetricsTimeline(BigInt(id), query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/metrics/events')
|
||||||
|
metricsEvents(@Param('id') id: string, @Query() query: PromoMetricEventsQueryDto) {
|
||||||
|
return this.service.listMetricEvents(BigInt(id), query);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id/qrcode')
|
@Get(':id/qrcode')
|
||||||
qrcode(@Param('id') id: string) {
|
qrcode(@Param('id') id: string) {
|
||||||
return this.service.getQrcodeUrl(BigInt(id));
|
return this.service.getQrcodeUrl(BigInt(id));
|
||||||
|
|||||||
@@ -87,3 +87,37 @@ export class UpdatePromoCodeStatusDto {
|
|||||||
@IsIn(['ACTIVE', 'DISABLED'])
|
@IsIn(['ACTIVE', 'DISABLED'])
|
||||||
status: PromoCodeStatus;
|
status: PromoCodeStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class PromoMetricTimelineQueryDto {
|
||||||
|
@IsString()
|
||||||
|
dateFrom: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
dateTo: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['day', 'hour'])
|
||||||
|
granularity?: 'day' | 'hour';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PromoMetricEventsQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
pageSize?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['SCAN', 'ATTRIBUTION', 'REGISTER', 'ORDER'])
|
||||||
|
eventType?: 'SCAN' | 'ATTRIBUTION' | 'REGISTER' | 'ORDER';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
dateFrom?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
dateTo?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { randomBytes } from 'crypto';
|
|||||||
import {
|
import {
|
||||||
PROMO_CODE_SCENE_LABELS,
|
PROMO_CODE_SCENE_LABELS,
|
||||||
PromoCodeScene,
|
PromoCodeScene,
|
||||||
|
PromoMetricEventType,
|
||||||
buildPromoLandingUrl,
|
buildPromoLandingUrl,
|
||||||
loadAppConfig,
|
loadAppConfig,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
@@ -16,12 +17,30 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator'
|
|||||||
import { OSS_PROVIDER, WECHAT_PROVIDER } from '../../integrations/integrations.constants';
|
import { OSS_PROVIDER, WECHAT_PROVIDER } from '../../integrations/integrations.constants';
|
||||||
import type { IOssProvider } from '../../integrations/oss/oss.interface';
|
import type { IOssProvider } from '../../integrations/oss/oss.interface';
|
||||||
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
|
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
|
||||||
|
import { PromoMetricLogService } from './promo-metric-log.service';
|
||||||
|
import {
|
||||||
|
computePromoMetricPeak,
|
||||||
|
eachPromoMetricDay,
|
||||||
|
eachPromoMetricHour,
|
||||||
|
endOfDay,
|
||||||
|
parsePromoMetricYmd,
|
||||||
|
promoMetricBucketKey,
|
||||||
|
promoMetricEventField,
|
||||||
|
startOfDay,
|
||||||
|
} from './promo-metric.util';
|
||||||
import type {
|
import type {
|
||||||
CreatePromoCodeDto,
|
CreatePromoCodeDto,
|
||||||
PromoCodeListQueryDto,
|
PromoCodeListQueryDto,
|
||||||
|
PromoMetricEventsQueryDto,
|
||||||
|
PromoMetricTimelineQueryDto,
|
||||||
UpdatePromoCodeDto,
|
UpdatePromoCodeDto,
|
||||||
} from './dto/promo-code.dto';
|
} from './dto/promo-code.dto';
|
||||||
|
|
||||||
|
type PromoTouchMeta = {
|
||||||
|
clientIp?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type PromoRow = {
|
type PromoRow = {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
code: string;
|
code: string;
|
||||||
@@ -69,6 +88,7 @@ function maskPhone(phone: string | null | undefined) {
|
|||||||
export class PromoCodeService {
|
export class PromoCodeService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly promoMetricLog: PromoMetricLogService,
|
||||||
@Inject(OSS_PROVIDER) private readonly oss: IOssProvider,
|
@Inject(OSS_PROVIDER) private readonly oss: IOssProvider,
|
||||||
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
|
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
|
||||||
) {}
|
) {}
|
||||||
@@ -88,7 +108,11 @@ export class PromoCodeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 代下单绑定推广码:归因 + 用户来源(若可写) */
|
/** 代下单绑定推广码:归因 + 用户来源(若可写) */
|
||||||
async attributeUserToPromo(userId: bigint, promoId: bigint) {
|
async attributeUserToPromo(
|
||||||
|
userId: bigint,
|
||||||
|
promoId: bigint,
|
||||||
|
meta?: PromoTouchMeta,
|
||||||
|
) {
|
||||||
const promo = await this.prisma.commonPromoCode.findUnique({
|
const promo = await this.prisma.commonPromoCode.findUnique({
|
||||||
where: { id: promoId },
|
where: { id: promoId },
|
||||||
select: { id: true, name: true, status: true },
|
select: { id: true, name: true, status: true },
|
||||||
@@ -108,9 +132,13 @@ export class PromoCodeService {
|
|||||||
firstTouchAt: new Date(),
|
firstTouchAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
this.logPromoMetric(promo.id, 'ATTRIBUTION', meta, { userId });
|
||||||
}
|
}
|
||||||
await this.applyPromoSourceToUser(userId, promo);
|
const sourceApplied = await this.applyPromoSourceToUser(userId, promo, meta);
|
||||||
return promo;
|
if (sourceApplied) {
|
||||||
|
this.logPromoMetric(promo.id, 'REGISTER', meta, { userId });
|
||||||
|
}
|
||||||
|
return { promo, sourceApplied };
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapOwnerUser(user: PromoRow['ownerUser']) {
|
private mapOwnerUser(user: PromoRow['ownerUser']) {
|
||||||
@@ -393,6 +421,7 @@ export class PromoCodeService {
|
|||||||
countScan?: boolean;
|
countScan?: boolean;
|
||||||
},
|
},
|
||||||
userId?: bigint,
|
userId?: bigint,
|
||||||
|
meta?: PromoTouchMeta,
|
||||||
) {
|
) {
|
||||||
const promoCode = input.promoCode?.trim().toUpperCase();
|
const promoCode = input.promoCode?.trim().toUpperCase();
|
||||||
const qrcodeId = input.qrcodeId?.trim();
|
const qrcodeId = input.qrcodeId?.trim();
|
||||||
@@ -433,7 +462,17 @@ export class PromoCodeService {
|
|||||||
attributed = true;
|
attributed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceApplied = await this.applyPromoSourceToUser(userId, promo);
|
sourceApplied = await this.applyPromoSourceToUser(userId, promo, meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldCountScan) {
|
||||||
|
this.logPromoMetric(promo.id, 'SCAN', meta, { userId });
|
||||||
|
}
|
||||||
|
if (attributed) {
|
||||||
|
this.logPromoMetric(promo.id, 'ATTRIBUTION', meta, { userId });
|
||||||
|
}
|
||||||
|
if (sourceApplied) {
|
||||||
|
this.logPromoMetric(promo.id, 'REGISTER', meta, { userId });
|
||||||
}
|
}
|
||||||
|
|
||||||
return serializeBigInt({
|
return serializeBigInt({
|
||||||
@@ -450,6 +489,7 @@ export class PromoCodeService {
|
|||||||
async applyPromoSourceToUser(
|
async applyPromoSourceToUser(
|
||||||
userId: bigint,
|
userId: bigint,
|
||||||
promo: { id: bigint; name: string },
|
promo: { id: bigint; name: string },
|
||||||
|
meta?: PromoTouchMeta,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
@@ -470,6 +510,132 @@ export class PromoCodeService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logPromoOrderEvent(
|
||||||
|
promoCodeId: bigint,
|
||||||
|
orderId: bigint,
|
||||||
|
userId: bigint,
|
||||||
|
clientIp?: string,
|
||||||
|
): void {
|
||||||
|
this.logPromoMetric(promoCodeId, 'ORDER', { clientIp }, { userId, orderId });
|
||||||
|
}
|
||||||
|
|
||||||
|
private logPromoMetric(
|
||||||
|
promoCodeId: bigint,
|
||||||
|
eventType: PromoMetricEventType,
|
||||||
|
meta: PromoTouchMeta | undefined,
|
||||||
|
ids: { userId?: bigint; orderId?: bigint },
|
||||||
|
): void {
|
||||||
|
this.promoMetricLog.logEvent({
|
||||||
|
promoCodeId,
|
||||||
|
eventType,
|
||||||
|
userId: ids.userId,
|
||||||
|
orderId: ids.orderId,
|
||||||
|
sessionId: meta?.sessionId,
|
||||||
|
clientIp: meta?.clientIp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMetricsTimeline(promoId: bigint, query: PromoMetricTimelineQueryDto) {
|
||||||
|
await this.ensurePromoExists(promoId);
|
||||||
|
|
||||||
|
const granularity = query.granularity === 'hour' ? 'hour' : 'day';
|
||||||
|
const from = parsePromoMetricYmd(query.dateFrom) ?? startOfDay(new Date());
|
||||||
|
const toParsed = parsePromoMetricYmd(query.dateTo);
|
||||||
|
const to = toParsed ? endOfDay(toParsed) : endOfDay(new Date());
|
||||||
|
|
||||||
|
const keys =
|
||||||
|
granularity === 'hour'
|
||||||
|
? eachPromoMetricHour(from, to)
|
||||||
|
: eachPromoMetricDay(from, to);
|
||||||
|
|
||||||
|
const bucketMap = new Map(
|
||||||
|
keys.map((key) => [key, { key, scan: 0, attribution: 0, register: 0, order: 0 }]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = await this.prisma.logPromoEvent.findMany({
|
||||||
|
where: {
|
||||||
|
promoCodeId: promoId,
|
||||||
|
createdAt: { gte: from, lte: to },
|
||||||
|
},
|
||||||
|
select: { eventType: true, createdAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = promoMetricBucketKey(row.createdAt, granularity);
|
||||||
|
const bucket = bucketMap.get(key);
|
||||||
|
if (!bucket) continue;
|
||||||
|
bucket[promoMetricEventField(row.eventType)] += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buckets = keys.map((key) => bucketMap.get(key)!);
|
||||||
|
return serializeBigInt({
|
||||||
|
granularity,
|
||||||
|
dateFrom: query.dateFrom,
|
||||||
|
dateTo: query.dateTo,
|
||||||
|
buckets,
|
||||||
|
peak: computePromoMetricPeak(buckets),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listMetricEvents(promoId: bigint, query: PromoMetricEventsQueryDto) {
|
||||||
|
await this.ensurePromoExists(promoId);
|
||||||
|
|
||||||
|
const page = query.page && query.page > 0 ? query.page : 1;
|
||||||
|
const pageSize = query.pageSize && query.pageSize > 0 ? Math.min(query.pageSize, 100) : 20;
|
||||||
|
|
||||||
|
const from = query.dateFrom ? parsePromoMetricYmd(query.dateFrom) : undefined;
|
||||||
|
const toParsed = query.dateTo ? parsePromoMetricYmd(query.dateTo) : undefined;
|
||||||
|
const to = toParsed ? endOfDay(toParsed) : undefined;
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
promoCodeId: promoId,
|
||||||
|
...(query.eventType ? { eventType: query.eventType } : {}),
|
||||||
|
...(from || to
|
||||||
|
? {
|
||||||
|
createdAt: {
|
||||||
|
...(from ? { gte: from } : {}),
|
||||||
|
...(to ? { lte: to } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.logPromoEvent.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
this.prisma.logPromoEvent.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return serializeBigInt({
|
||||||
|
items: items.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
eventType: row.eventType,
|
||||||
|
userId: row.userId,
|
||||||
|
orderId: row.orderId,
|
||||||
|
sessionId: row.sessionId,
|
||||||
|
clientIp: row.clientIp,
|
||||||
|
ipProvince: row.ipProvince,
|
||||||
|
ipCity: row.ipCity,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
})),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensurePromoExists(promoId: bigint) {
|
||||||
|
const promo = await this.prisma.commonPromoCode.findUnique({
|
||||||
|
where: { id: promoId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!promo) throw new NotFoundException('推广码不存在');
|
||||||
|
}
|
||||||
|
|
||||||
private async statsFromRow(row: { id: bigint; scanCount: number; orderCount: number }) {
|
private async statsFromRow(row: { id: bigint; scanCount: number; orderCount: number }) {
|
||||||
const scanCount = row.scanCount;
|
const scanCount = row.scanCount;
|
||||||
const orderCount = row.orderCount;
|
const orderCount = row.orderCount;
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { PromoMetricEventType } from '@dukang/shared-types';
|
||||||
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
|
import { IpGeoService } from '../../common/geo/ip-geo.service';
|
||||||
|
|
||||||
|
export type PromoMetricLogInput = {
|
||||||
|
promoCodeId: bigint;
|
||||||
|
eventType: PromoMetricEventType;
|
||||||
|
userId?: bigint;
|
||||||
|
orderId?: bigint;
|
||||||
|
sessionId?: string;
|
||||||
|
clientIp?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PromoMetricLogService {
|
||||||
|
private readonly logger = new Logger(PromoMetricLogService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly ipGeoService: IpGeoService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
logEvent(input: PromoMetricLogInput): void {
|
||||||
|
void this.writeEvent(input).catch((err) => {
|
||||||
|
this.logger.debug(
|
||||||
|
`promo metric log failed: ${err instanceof Error ? err.message : err}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async writeEvent(input: PromoMetricLogInput): Promise<void> {
|
||||||
|
const clientIp = input.clientIp?.trim() || null;
|
||||||
|
const geo = clientIp ? this.ipGeoService.resolve(clientIp) : { province: null, city: null };
|
||||||
|
|
||||||
|
await this.prisma.logPromoEvent.create({
|
||||||
|
data: {
|
||||||
|
promoCodeId: input.promoCodeId,
|
||||||
|
eventType: input.eventType,
|
||||||
|
userId: input.userId ?? null,
|
||||||
|
orderId: input.orderId ?? null,
|
||||||
|
sessionId: input.sessionId?.trim() || null,
|
||||||
|
clientIp,
|
||||||
|
ipProvince: geo.province,
|
||||||
|
ipCity: geo.city,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import type { PromoMetricEventType, PromoMetricTimelinePeak } from '@dukang/shared-types';
|
||||||
|
|
||||||
|
function startOfDay(d: Date) {
|
||||||
|
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function endOfDay(d: Date) {
|
||||||
|
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePromoMetricYmd(s: string): Date | null {
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
|
||||||
|
const d = new Date(`${s}T00:00:00`);
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPromoMetricYmd(d: Date): string {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPromoMetricHour(d: Date): string {
|
||||||
|
const ymd = formatPromoMetricYmd(d);
|
||||||
|
const h = String(d.getHours()).padStart(2, '0');
|
||||||
|
return `${ymd} ${h}:00`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function eachPromoMetricDay(from: Date, to: Date): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
const cur = startOfDay(from);
|
||||||
|
const end = startOfDay(to);
|
||||||
|
while (cur <= end) {
|
||||||
|
out.push(formatPromoMetricYmd(cur));
|
||||||
|
cur.setDate(cur.getDate() + 1);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function eachPromoMetricHour(from: Date, to: Date): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
const cur = new Date(from);
|
||||||
|
cur.setMinutes(0, 0, 0);
|
||||||
|
const end = endOfDay(to);
|
||||||
|
while (cur <= end) {
|
||||||
|
out.push(formatPromoMetricHour(cur));
|
||||||
|
cur.setHours(cur.getHours() + 1);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function promoMetricBucketKey(
|
||||||
|
createdAt: Date,
|
||||||
|
granularity: 'day' | 'hour',
|
||||||
|
): string {
|
||||||
|
return granularity === 'day'
|
||||||
|
? formatPromoMetricYmd(createdAt)
|
||||||
|
: formatPromoMetricHour(createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function promoMetricEventField(
|
||||||
|
eventType: PromoMetricEventType,
|
||||||
|
): 'scan' | 'attribution' | 'register' | 'order' {
|
||||||
|
switch (eventType) {
|
||||||
|
case 'SCAN':
|
||||||
|
return 'scan';
|
||||||
|
case 'ATTRIBUTION':
|
||||||
|
return 'attribution';
|
||||||
|
case 'REGISTER':
|
||||||
|
return 'register';
|
||||||
|
case 'ORDER':
|
||||||
|
return 'order';
|
||||||
|
default:
|
||||||
|
return 'scan';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computePromoMetricPeak(
|
||||||
|
buckets: Array<{
|
||||||
|
key: string;
|
||||||
|
scan: number;
|
||||||
|
attribution: number;
|
||||||
|
register: number;
|
||||||
|
order: number;
|
||||||
|
}>,
|
||||||
|
): PromoMetricTimelinePeak {
|
||||||
|
const pick = (field: 'scan' | 'attribution' | 'register' | 'order') => {
|
||||||
|
let best: { key: string; count: number } | null = null;
|
||||||
|
for (const b of buckets) {
|
||||||
|
const count = b[field];
|
||||||
|
if (!best || count > best.count) {
|
||||||
|
best = { key: b.key, count };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best && best.count > 0 ? best : null;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
scan: pick('scan'),
|
||||||
|
attribution: pick('attribution'),
|
||||||
|
register: pick('register'),
|
||||||
|
order: pick('order'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { startOfDay, endOfDay };
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { GeoModule } from '../../common/geo/geo.module';
|
||||||
import { IntegrationsModule } from '../../integrations/integrations.module';
|
import { IntegrationsModule } from '../../integrations/integrations.module';
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||||
import { AdminPromoCodeController } from './admin-promo-code.controller';
|
import { AdminPromoCodeController } from './admin-promo-code.controller';
|
||||||
import { PromoCodeService } from './promo-code.service';
|
import { PromoCodeService } from './promo-code.service';
|
||||||
|
import { PromoMetricLogService } from './promo-metric-log.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
GeoModule,
|
||||||
IntegrationsModule,
|
IntegrationsModule,
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: process.env.JWT_SECRET || 'dukang-prev1-dev-secret',
|
secret: process.env.JWT_SECRET || 'dukang-prev1-dev-secret',
|
||||||
@@ -15,7 +18,7 @@ import { PromoCodeService } from './promo-code.service';
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [AdminPromoCodeController],
|
controllers: [AdminPromoCodeController],
|
||||||
providers: [PromoCodeService, JwtAuthGuard, HqAuthGuard],
|
providers: [PromoCodeService, PromoMetricLogService, JwtAuthGuard, HqAuthGuard],
|
||||||
exports: [PromoCodeService],
|
exports: [PromoCodeService],
|
||||||
})
|
})
|
||||||
export class PromoModule {}
|
export class PromoModule {}
|
||||||
|
|||||||
@@ -272,6 +272,15 @@ export class TradeService {
|
|||||||
return created;
|
return created;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (promoCodeId) {
|
||||||
|
this.promoCodeService.logPromoOrderEvent(
|
||||||
|
promoCodeId,
|
||||||
|
order.id,
|
||||||
|
userId,
|
||||||
|
location.clientIp ?? undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
this.analyticsService.trackOneSafe(userId, 'USER_H5', {
|
this.analyticsService.trackOneSafe(userId, 'USER_H5', {
|
||||||
eventName: 'order_submit',
|
eventName: 'order_submit',
|
||||||
refType: 'ORDER',
|
refType: 'ORDER',
|
||||||
@@ -1571,12 +1580,6 @@ export class TradeService {
|
|||||||
|
|
||||||
const paySnapshot = await this.partnerCityService.resolveForOrder(city.id, commissionDistrict);
|
const paySnapshot = await this.partnerCityService.resolveForOrder(city.id, commissionDistrict);
|
||||||
|
|
||||||
let promoCodeId: bigint | undefined;
|
|
||||||
if (body.promoCodeId?.trim()) {
|
|
||||||
promoCodeId = BigInt(body.promoCodeId.trim());
|
|
||||||
await this.promoCodeService.attributeUserToPromo(user.id, promoCodeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const orderNo = generateOrderNo();
|
const orderNo = generateOrderNo();
|
||||||
const payExpireAt = new Date(Date.now() + 30 * 60 * 1000);
|
const payExpireAt = new Date(Date.now() + 30 * 60 * 1000);
|
||||||
const location = buildOrderClientLocationSnapshot(
|
const location = buildOrderClientLocationSnapshot(
|
||||||
@@ -1585,6 +1588,14 @@ export class TradeService {
|
|||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let promoCodeId: bigint | undefined;
|
||||||
|
if (body.promoCodeId?.trim()) {
|
||||||
|
promoCodeId = BigInt(body.promoCodeId.trim());
|
||||||
|
await this.promoCodeService.attributeUserToPromo(user.id, promoCodeId, {
|
||||||
|
clientIp: location.clientIp ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const order = await this.prisma.$transaction(async (tx) => {
|
const order = await this.prisma.$transaction(async (tx) => {
|
||||||
const created = await tx.order.create({
|
const created = await tx.order.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -1651,6 +1662,15 @@ export class TradeService {
|
|||||||
return created;
|
return created;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (promoCodeId) {
|
||||||
|
this.promoCodeService.logPromoOrderEvent(
|
||||||
|
promoCodeId,
|
||||||
|
order.id,
|
||||||
|
user.id,
|
||||||
|
location.clientIp ?? undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
|
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
|
||||||
partnerAccountId: primary.id,
|
partnerAccountId: primary.id,
|
||||||
eventName: 'partner_proxy_order_create',
|
eventName: 'partner_proxy_order_create',
|
||||||
@@ -1905,12 +1925,6 @@ export class TradeService {
|
|||||||
|
|
||||||
const paySnapshot = await this.partnerCityService.resolveForOrder(city.id, commissionDistrict);
|
const paySnapshot = await this.partnerCityService.resolveForOrder(city.id, commissionDistrict);
|
||||||
|
|
||||||
let promoCodeId: bigint | undefined;
|
|
||||||
if (body.promoCodeId?.trim()) {
|
|
||||||
promoCodeId = BigInt(body.promoCodeId.trim());
|
|
||||||
await this.promoCodeService.attributeUserToPromo(user.id, promoCodeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const orderNo = generateOrderNo();
|
const orderNo = generateOrderNo();
|
||||||
const payExpireAt = new Date(Date.now() + 30 * 60 * 1000);
|
const payExpireAt = new Date(Date.now() + 30 * 60 * 1000);
|
||||||
const location = buildOrderClientLocationSnapshot(
|
const location = buildOrderClientLocationSnapshot(
|
||||||
@@ -1919,6 +1933,14 @@ export class TradeService {
|
|||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let promoCodeId: bigint | undefined;
|
||||||
|
if (body.promoCodeId?.trim()) {
|
||||||
|
promoCodeId = BigInt(body.promoCodeId.trim());
|
||||||
|
await this.promoCodeService.attributeUserToPromo(user.id, promoCodeId, {
|
||||||
|
clientIp: location.clientIp ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const order = await this.prisma.$transaction(async (tx) => {
|
const order = await this.prisma.$transaction(async (tx) => {
|
||||||
const created = await tx.order.create({
|
const created = await tx.order.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -1985,6 +2007,15 @@ export class TradeService {
|
|||||||
return created;
|
return created;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (promoCodeId) {
|
||||||
|
this.promoCodeService.logPromoOrderEvent(
|
||||||
|
promoCodeId,
|
||||||
|
order.id,
|
||||||
|
user.id,
|
||||||
|
location.clientIp ?? undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: order.id.toString(),
|
id: order.id.toString(),
|
||||||
orderNo: order.orderNo,
|
orderNo: order.orderNo,
|
||||||
|
|||||||
+1
-1
@@ -48,7 +48,7 @@
|
|||||||
|
|
||||||
| 版本 | 日期 | 说明 |
|
| 版本 | 日期 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| **3.4.13** | 2026-08-05 | 推广码归因统计、核销用户信息、技术支持工单优先级、mini-user 门店/商品/提货/版本/**物流增强(签收照/拨号/ETA/路由回调签收→已完成)**、H5 登录校验、合伙人微信暂停禁登、**OSS 图片超 10MB 客户端压缩**;开发设计见 [`杜康好客-v3.4.13-体验优化开发文档.md`](./杜康好客-v3.4.13-体验优化开发文档.md) |
|
| **3.4.13** | 2026-08-05 | 推广码归因统计 + **指标事件日志/高峰趋势**、核销用户信息、技术支持工单优先级、mini-user 门店/商品/提货/版本/**物流增强(签收照/拨号/ETA/路由回调签收→已完成)**、H5 登录校验、合伙人微信暂停禁登、**OSS 图片超 10MB 客户端压缩**;开发设计见 [`杜康好客-v3.4.13-体验优化开发文档.md`](./杜康好客-v3.4.13-体验优化开发文档.md) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -334,7 +334,7 @@ C2~C7、C14 见 §1.3。
|
|||||||
|
|
||||||
| 项 | 状态 | 说明 |
|
| 项 | 状态 | 说明 |
|
||||||
|----|------|------|
|
|----|------|------|
|
||||||
| 推广码 attributionCount | ✅ | HQ 详情统计卡 |
|
| 推广码 attributionCount | ✅ | HQ 详情统计卡 + **log_promo_event 事件日志 + metrics/timeline ECharts** |
|
||||||
| 核销用户信息 | ✅ | RedeemRecordsPage 列表+详情 |
|
| 核销用户信息 | ✅ | RedeemRecordsPage 列表+详情 |
|
||||||
| 技术支持优先级 | ✅ | Prisma + shared-types + HQ UI |
|
| 技术支持优先级 | ✅ | Prisma + shared-types + HQ UI |
|
||||||
| 合伙人微信暂停禁登 | ✅ | loginPartnerWechat |
|
| 合伙人微信暂停禁登 | ✅ | loginPartnerWechat |
|
||||||
|
|||||||
@@ -7,16 +7,16 @@
|
|||||||
|
|
||||||
| 模块 | 内容 |
|
| 模块 | 内容 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| admin-web | 推广码 attributionCount;核销记录用户信息;技术支持工单优先级;**大图上传前压缩** |
|
| admin-web | 推广码 attributionCount + **指标事件日志/ECharts 趋势**;核销记录用户信息;技术支持工单优先级;**大图上传前压缩** |
|
||||||
| mini-user | 门店电话脱敏+拨打埋点;门头/套餐展示;商品去分享+首图 preview;提货确认弹框;版本更新提示;**物流增强(签收照/拨号/时间线/ETA)**;**头像超 10MB 压缩** |
|
| mini-user | 门店电话脱敏+拨打埋点;门头/套餐展示;商品去分享+首图 preview;提货确认弹框;版本更新提示;**物流增强(签收照/拨号/时间线/ETA)**;**头像超 10MB 压缩** |
|
||||||
| h5-partner / h5-shop / h5-user | 登录 phone/code 前端校验;**OSS 图片超 10MB 自动 canvas 压缩后上传** |
|
| h5-partner / h5-shop / h5-user | 登录 phone/code 前端校验;**OSS 图片超 10MB 自动 canvas 压缩后上传** |
|
||||||
| 后端 | 工单 priority;client-config minClientVersion;合伙人微信暂停禁登;**Courier 适配器(100108/100301/路由回调)** |
|
| 后端 | 工单 priority;client-config minClientVersion;合伙人微信暂停禁登;**Courier 适配器**;**log_promo_event 推广码指标日志** |
|
||||||
|
|
||||||
## 2. ST 映射
|
## 2. ST 映射
|
||||||
|
|
||||||
| ST | 标题 | 状态 |
|
| ST | 标题 | 状态 |
|
||||||
|----|------|------|
|
|----|------|------|
|
||||||
| ST1785925037781309 | 总部端-推广码数据跟踪优化 | ✅ attributionCount 统计卡 |
|
| ST1785925037781309 | 总部端-推广码数据跟踪优化 | ✅ attributionCount + **事件日志 + ECharts 趋势** |
|
||||||
| ST1785924286682833 | 用户端-门店电话加密+拨打埋点 | ✅ maskPhone + store_phone_call |
|
| ST1785924286682833 | 用户端-门店电话加密+拨打埋点 | ✅ maskPhone + store_phone_call |
|
||||||
| ST1785921693982470 | 技术支持-工单优先级 | ✅ priority 枚举 + HQ UI |
|
| ST1785921693982470 | 技术支持-工单优先级 | ✅ priority 枚举 + HQ UI |
|
||||||
| ST1785921585900725 | 门店端扫一扫授权异常 | ✅ 已有(v3.4.12 前) |
|
| ST1785921585900725 | 门店端扫一扫授权异常 | ✅ 已有(v3.4.12 前) |
|
||||||
@@ -48,6 +48,23 @@
|
|||||||
| POST | `/callbacks/courier/xfx/track` | 小飞侠路由变化回调(适配器入口) |
|
| POST | `/callbacks/courier/xfx/track` | 小飞侠路由变化回调(适配器入口) |
|
||||||
| POST | `/callbacks/courier/logistics/track` | 跨城物流回调占位(记录日志,后续接入) |
|
| POST | `/callbacks/courier/logistics/track` | 跨城物流回调占位(记录日志,后续接入) |
|
||||||
| POST | `/callbacks/delivery/track` | 兼容旧路径,等同 `xfx` |
|
| POST | `/callbacks/delivery/track` | 兼容旧路径,等同 `xfx` |
|
||||||
|
| GET | `/admin/promo-codes/:id/metrics/timeline` | 推广码四指标时间序列(按日/按时 + peak) |
|
||||||
|
| GET | `/admin/promo-codes/:id/metrics/events` | 推广码指标事件分页日志(时间/ID/IP/地点) |
|
||||||
|
|
||||||
|
### 3.1 推广码指标日志(ST1785925037781309)
|
||||||
|
|
||||||
|
**表** `log_promo_event`
|
||||||
|
|
||||||
|
| event_type | 统计卡 | 写入时机 | ID |
|
||||||
|
|------------|--------|----------|-----|
|
||||||
|
| `SCAN` | 扫码进入数 | `POST /promo/touch` 且 `countScan !== false` | userId / sessionId |
|
||||||
|
| `ATTRIBUTION` | 归因用户数 | 首次写入 `user_promo_attribution` | userId |
|
||||||
|
| `REGISTER` | 扫码注册用户数 | `applyPromoSourceToUser` 成功 | userId |
|
||||||
|
| `ORDER` | 订单数 | 带推广码下单 `orderCount++` | orderId + userId |
|
||||||
|
|
||||||
|
每条日志含 `created_at`、可选 ID、`client_ip`、`ip_province`/`ip_city`。仅统计**上线后**新事件;累计 Statistic 卡逻辑不变。
|
||||||
|
|
||||||
|
**HQ UI**:推广码详情页 → 数据趋势 Card(DatePicker + 按日/按时 + ECharts 四曲线 + 事件 Table)
|
||||||
|
|
||||||
### 4. mini-user 物流(ST1785939375449985)
|
### 4. mini-user 物流(ST1785939375449985)
|
||||||
|
|
||||||
@@ -109,6 +126,7 @@ POST https://api-test.dukanghaoke.com/api/v1/callbacks/courier/xfx/track
|
|||||||
## 5. 数据表
|
## 5. 数据表
|
||||||
|
|
||||||
- `common_support_ticket.priority` ENUM,默认 `NORMAL`
|
- `common_support_ticket.priority` ENUM,默认 `NORMAL`
|
||||||
|
- `log_promo_event`:推广码指标事件(`promo_code_id`, `event_type`, `user_id`, `order_id`, `session_id`, `client_ip`, 地点, `created_at`)
|
||||||
|
|
||||||
## 6. HQ 开发计划
|
## 6. HQ 开发计划
|
||||||
|
|
||||||
@@ -116,7 +134,8 @@ POST https://api-test.dukanghaoke.com/api/v1/callbacks/courier/xfx/track
|
|||||||
|
|
||||||
## 7. 验收 ACC
|
## 7. 验收 ACC
|
||||||
|
|
||||||
- [ ] 推广码详情展示 attributionCount
|
- [ ] 推广码详情展示 attributionCount;**四指标事件日志 + ECharts 按日/按时趋势 + 高峰标注**
|
||||||
|
- [ ] 推广码 SCAN/ATTRIBUTION/REGISTER/ORDER 事件含 time + IP;幂等(重复 touch 不计 SCAN)
|
||||||
- [ ] 核销记录含 userNo/nickname/phone
|
- [ ] 核销记录含 userNo/nickname/phone
|
||||||
- [ ] 技术支持可创建/筛选/编辑优先级
|
- [ ] 技术支持可创建/筛选/编辑优先级
|
||||||
- [ ] 合伙人/门店登录空字段前端提示;暂停合伙人微信登录被拒
|
- [ ] 合伙人/门店登录空字段前端提示;暂停合伙人微信登录被拒
|
||||||
|
|||||||
Reference in New Issue
Block a user