diff --git a/apps/admin-web/src/pages/promo/PromoCodeDetailPage.tsx b/apps/admin-web/src/pages/promo/PromoCodeDetailPage.tsx
index 0092b3f..0137f3c 100644
--- a/apps/admin-web/src/pages/promo/PromoCodeDetailPage.tsx
+++ b/apps/admin-web/src/pages/promo/PromoCodeDetailPage.tsx
@@ -24,6 +24,7 @@ import {
import { request } from '../../lib/api';
import { fmtTime } from '../../lib/constants';
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
+import PromoCodeMetricsPanel from './PromoCodeMetricsPanel';
const descLabelStyle: CSSProperties = {
whiteSpace: 'nowrap',
@@ -243,6 +244,8 @@ export default function PromoCodeDetailPage() {
+
+
([dayjs().subtract(6, 'day'), dayjs()]);
+ const [granularity, setGranularity] = useState<'day' | 'hour'>('day');
+ const [timeline, setTimeline] = useState(null);
+ const [timelineLoading, setTimelineLoading] = useState(false);
+ const [eventType, setEventType] = useState();
+ const [eventsPage, setEventsPage] = useState(1);
+ const [events, setEvents] = useState | null>(null);
+ const [eventsLoading, setEventsLoading] = useState(false);
+
+ const loadTimeline = useCallback(() => {
+ setTimelineLoading(true);
+ return request(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>(
+ 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(() => {
+ 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 = [
+ {
+ 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 (
+
+ 仅统计功能上线后的新事件
+
+ }
+ >
+
+ {
+ if (v?.[0] && v[1]) {
+ setRange([v[0], v[1]]);
+ setEventsPage(1);
+ }
+ }}
+ allowClear={false}
+ />
+ setGranularity(e.target.value as 'day' | 'hour')}
+ optionType="button"
+ buttonStyle="solid"
+ options={[
+ { label: '按日', value: 'day' },
+ { label: '按时', value: 'hour' },
+ ]}
+ />
+
+
+
+
+
+ 事件日志
+
+
+
+ setEventsPage(p),
+ }}
+ />
+
+ );
+}
diff --git a/packages/shared-types/src/promo.ts b/packages/shared-types/src/promo.ts
index 59d5081..71cd2d3 100644
--- a/packages/shared-types/src/promo.ts
+++ b/packages/shared-types/src/promo.ts
@@ -85,6 +85,50 @@ export function promoConversion(scan: number, orders: number): string {
return `${Math.round((orders / scan) * 1000) / 10}%`;
}
+export type PromoMetricEventType = 'SCAN' | 'ATTRIBUTION' | 'REGISTER' | 'ORDER';
+
+export const PROMO_METRIC_EVENT_LABELS: Record = {
+ 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 {
const base = baseUrl.replace(/\/$/, '');
return `${base}/?promo=${encodeURIComponent(code)}&pid=${encodeURIComponent(qrcodeId)}`;
diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma
index 7630161..c405e9e 100644
--- a/server/dukang-api/prisma/schema.prisma
+++ b/server/dukang-api/prisma/schema.prisma
@@ -181,6 +181,13 @@ enum PromoCodeScene {
OTHER
}
+enum PromoMetricEventType {
+ SCAN
+ ATTRIBUTION
+ REGISTER
+ ORDER
+}
+
enum CityStatus {
PENDING
ACTIVE
@@ -856,6 +863,7 @@ model CommonPromoCode {
qrcodeResource CommonResource? @relation("PromoQrcode", fields: [qrcodeResourceId], references: [id], onDelete: SetNull)
attributions UserPromoAttribution[]
orders Order[]
+ metricEvents LogPromoEvent[]
@@index([ownerUserId])
@@index([scene, status])
@@ -1759,6 +1767,25 @@ model LogThirdParty {
@@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 {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
userId BigInt? @map("user_id") @db.UnsignedBigInt
diff --git a/server/dukang-api/src/modules/analytics/analytics.controller.ts b/server/dukang-api/src/modules/analytics/analytics.controller.ts
index 84d0e5a..ccbedc7 100644
--- a/server/dukang-api/src/modules/analytics/analytics.controller.ts
+++ b/server/dukang-api/src/modules/analytics/analytics.controller.ts
@@ -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 { PromoCodeService } from '../promo/promo-code.service';
+import { extractClientIp } from '../../common/geo/client-ip.util';
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
@@ -66,8 +68,13 @@ export class PromoController {
@Post('touch')
@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 clientIp = extractClientIp(req) ?? undefined;
const result = await this.promoCodeService.touch(
{
promoCode: dto.promoCode,
@@ -76,6 +83,7 @@ export class PromoController {
countScan: dto.countScan,
},
userId,
+ { clientIp, sessionId: dto.sessionId },
);
void this.analyticsService.trackBatchOptional(
diff --git a/server/dukang-api/src/modules/promo/admin-promo-code.controller.ts b/server/dukang-api/src/modules/promo/admin-promo-code.controller.ts
index 0095cc2..316d6e7 100644
--- a/server/dukang-api/src/modules/promo/admin-promo-code.controller.ts
+++ b/server/dukang-api/src/modules/promo/admin-promo-code.controller.ts
@@ -6,6 +6,8 @@ import { PromoCodeService } from './promo-code.service';
import {
CreatePromoCodeDto,
PromoCodeListQueryDto,
+ PromoMetricEventsQueryDto,
+ PromoMetricTimelineQueryDto,
UpdatePromoCodeDto,
UpdatePromoCodeStatusDto,
} from './dto/promo-code.dto';
@@ -39,6 +41,16 @@ export class AdminPromoCodeController {
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')
qrcode(@Param('id') id: string) {
return this.service.getQrcodeUrl(BigInt(id));
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 5f9e131..0cfb386 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
@@ -87,3 +87,37 @@ export class UpdatePromoCodeStatusDto {
@IsIn(['ACTIVE', 'DISABLED'])
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;
+}
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 834b6d2..6efdf8a 100644
--- a/server/dukang-api/src/modules/promo/promo-code.service.ts
+++ b/server/dukang-api/src/modules/promo/promo-code.service.ts
@@ -8,6 +8,7 @@ import { randomBytes } from 'crypto';
import {
PROMO_CODE_SCENE_LABELS,
PromoCodeScene,
+ PromoMetricEventType,
buildPromoLandingUrl,
loadAppConfig,
} 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 type { IOssProvider } from '../../integrations/oss/oss.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 {
CreatePromoCodeDto,
PromoCodeListQueryDto,
+ PromoMetricEventsQueryDto,
+ PromoMetricTimelineQueryDto,
UpdatePromoCodeDto,
} from './dto/promo-code.dto';
+type PromoTouchMeta = {
+ clientIp?: string;
+ sessionId?: string;
+};
+
type PromoRow = {
id: bigint;
code: string;
@@ -69,6 +88,7 @@ function maskPhone(phone: string | null | undefined) {
export class PromoCodeService {
constructor(
private readonly prisma: PrismaService,
+ private readonly promoMetricLog: PromoMetricLogService,
@Inject(OSS_PROVIDER) private readonly oss: IOssProvider,
@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({
where: { id: promoId },
select: { id: true, name: true, status: true },
@@ -108,9 +132,13 @@ export class PromoCodeService {
firstTouchAt: new Date(),
},
});
+ this.logPromoMetric(promo.id, 'ATTRIBUTION', meta, { userId });
}
- await this.applyPromoSourceToUser(userId, promo);
- return promo;
+ const sourceApplied = await this.applyPromoSourceToUser(userId, promo, meta);
+ if (sourceApplied) {
+ this.logPromoMetric(promo.id, 'REGISTER', meta, { userId });
+ }
+ return { promo, sourceApplied };
}
private mapOwnerUser(user: PromoRow['ownerUser']) {
@@ -393,6 +421,7 @@ export class PromoCodeService {
countScan?: boolean;
},
userId?: bigint,
+ meta?: PromoTouchMeta,
) {
const promoCode = input.promoCode?.trim().toUpperCase();
const qrcodeId = input.qrcodeId?.trim();
@@ -433,7 +462,17 @@ export class PromoCodeService {
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({
@@ -450,6 +489,7 @@ export class PromoCodeService {
async applyPromoSourceToUser(
userId: bigint,
promo: { id: bigint; name: string },
+ meta?: PromoTouchMeta,
): Promise {
const user = await this.prisma.user.findUnique({
where: { id: userId },
@@ -470,6 +510,132 @@ export class PromoCodeService {
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 }) {
const scanCount = row.scanCount;
const orderCount = row.orderCount;
diff --git a/server/dukang-api/src/modules/promo/promo-metric-log.service.ts b/server/dukang-api/src/modules/promo/promo-metric-log.service.ts
new file mode 100644
index 0000000..8085c23
--- /dev/null
+++ b/server/dukang-api/src/modules/promo/promo-metric-log.service.ts
@@ -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 {
+ 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,
+ },
+ });
+ }
+}
diff --git a/server/dukang-api/src/modules/promo/promo-metric.util.ts b/server/dukang-api/src/modules/promo/promo-metric.util.ts
new file mode 100644
index 0000000..2676578
--- /dev/null
+++ b/server/dukang-api/src/modules/promo/promo-metric.util.ts
@@ -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 };
diff --git a/server/dukang-api/src/modules/promo/promo.module.ts b/server/dukang-api/src/modules/promo/promo.module.ts
index f883aff..f0049f0 100644
--- a/server/dukang-api/src/modules/promo/promo.module.ts
+++ b/server/dukang-api/src/modules/promo/promo.module.ts
@@ -1,13 +1,16 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
+import { GeoModule } from '../../common/geo/geo.module';
import { IntegrationsModule } from '../../integrations/integrations.module';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { AdminPromoCodeController } from './admin-promo-code.controller';
import { PromoCodeService } from './promo-code.service';
+import { PromoMetricLogService } from './promo-metric-log.service';
@Module({
imports: [
+ GeoModule,
IntegrationsModule,
JwtModule.register({
secret: process.env.JWT_SECRET || 'dukang-prev1-dev-secret',
@@ -15,7 +18,7 @@ import { PromoCodeService } from './promo-code.service';
}),
],
controllers: [AdminPromoCodeController],
- providers: [PromoCodeService, JwtAuthGuard, HqAuthGuard],
+ providers: [PromoCodeService, PromoMetricLogService, JwtAuthGuard, HqAuthGuard],
exports: [PromoCodeService],
})
export class PromoModule {}
diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts
index 30d61f0..4099d62 100644
--- a/server/dukang-api/src/modules/trade/trade.service.ts
+++ b/server/dukang-api/src/modules/trade/trade.service.ts
@@ -272,6 +272,15 @@ export class TradeService {
return created;
});
+ if (promoCodeId) {
+ this.promoCodeService.logPromoOrderEvent(
+ promoCodeId,
+ order.id,
+ userId,
+ location.clientIp ?? undefined,
+ );
+ }
+
this.analyticsService.trackOneSafe(userId, 'USER_H5', {
eventName: 'order_submit',
refType: 'ORDER',
@@ -1571,12 +1580,6 @@ export class TradeService {
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 payExpireAt = new Date(Date.now() + 30 * 60 * 1000);
const location = buildOrderClientLocationSnapshot(
@@ -1585,6 +1588,14 @@ export class TradeService {
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 created = await tx.order.create({
data: {
@@ -1651,6 +1662,15 @@ export class TradeService {
return created;
});
+ if (promoCodeId) {
+ this.promoCodeService.logPromoOrderEvent(
+ promoCodeId,
+ order.id,
+ user.id,
+ location.clientIp ?? undefined,
+ );
+ }
+
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
partnerAccountId: primary.id,
eventName: 'partner_proxy_order_create',
@@ -1905,12 +1925,6 @@ export class TradeService {
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 payExpireAt = new Date(Date.now() + 30 * 60 * 1000);
const location = buildOrderClientLocationSnapshot(
@@ -1919,6 +1933,14 @@ export class TradeService {
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 created = await tx.order.create({
data: {
@@ -1985,6 +2007,15 @@ export class TradeService {
return created;
});
+ if (promoCodeId) {
+ this.promoCodeService.logPromoOrderEvent(
+ promoCodeId,
+ order.id,
+ user.id,
+ location.clientIp ?? undefined,
+ );
+ }
+
return {
id: order.id.toString(),
orderNo: order.orderNo,
diff --git a/杜康好客-v3-PRD.md b/杜康好客-v3-PRD.md
index 58cec30..d08d357 100644
--- a/杜康好客-v3-PRD.md
+++ b/杜康好客-v3-PRD.md
@@ -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) |
---
diff --git a/杜康好客-v3-现状对照.md b/杜康好客-v3-现状对照.md
index 3a88911..3fedb13 100644
--- a/杜康好客-v3-现状对照.md
+++ b/杜康好客-v3-现状对照.md
@@ -334,7 +334,7 @@ C2~C7、C14 见 §1.3。
| 项 | 状态 | 说明 |
|----|------|------|
-| 推广码 attributionCount | ✅ | HQ 详情统计卡 |
+| 推广码 attributionCount | ✅ | HQ 详情统计卡 + **log_promo_event 事件日志 + metrics/timeline ECharts** |
| 核销用户信息 | ✅ | RedeemRecordsPage 列表+详情 |
| 技术支持优先级 | ✅ | Prisma + shared-types + HQ UI |
| 合伙人微信暂停禁登 | ✅ | loginPartnerWechat |
diff --git a/杜康好客-v3.4.13-体验优化开发文档.md b/杜康好客-v3.4.13-体验优化开发文档.md
index f4ee091..336b589 100644
--- a/杜康好客-v3.4.13-体验优化开发文档.md
+++ b/杜康好客-v3.4.13-体验优化开发文档.md
@@ -7,16 +7,16 @@
| 模块 | 内容 |
|------|------|
-| admin-web | 推广码 attributionCount;核销记录用户信息;技术支持工单优先级;**大图上传前压缩** |
+| admin-web | 推广码 attributionCount + **指标事件日志/ECharts 趋势**;核销记录用户信息;技术支持工单优先级;**大图上传前压缩** |
| mini-user | 门店电话脱敏+拨打埋点;门头/套餐展示;商品去分享+首图 preview;提货确认弹框;版本更新提示;**物流增强(签收照/拨号/时间线/ETA)**;**头像超 10MB 压缩** |
| 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 映射
| ST | 标题 | 状态 |
|----|------|------|
-| ST1785925037781309 | 总部端-推广码数据跟踪优化 | ✅ attributionCount 统计卡 |
+| ST1785925037781309 | 总部端-推广码数据跟踪优化 | ✅ attributionCount + **事件日志 + ECharts 趋势** |
| ST1785924286682833 | 用户端-门店电话加密+拨打埋点 | ✅ maskPhone + store_phone_call |
| ST1785921693982470 | 技术支持-工单优先级 | ✅ priority 枚举 + HQ UI |
| ST1785921585900725 | 门店端扫一扫授权异常 | ✅ 已有(v3.4.12 前) |
@@ -48,6 +48,23 @@
| POST | `/callbacks/courier/xfx/track` | 小飞侠路由变化回调(适配器入口) |
| POST | `/callbacks/courier/logistics/track` | 跨城物流回调占位(记录日志,后续接入) |
| 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)
@@ -109,6 +126,7 @@ POST https://api-test.dukanghaoke.com/api/v1/callbacks/courier/xfx/track
## 5. 数据表
- `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 开发计划
@@ -116,7 +134,8 @@ POST https://api-test.dukanghaoke.com/api/v1/callbacks/courier/xfx/track
## 7. 验收 ACC
-- [ ] 推广码详情展示 attributionCount
+- [ ] 推广码详情展示 attributionCount;**四指标事件日志 + ECharts 按日/按时趋势 + 高峰标注**
+- [ ] 推广码 SCAN/ATTRIBUTION/REGISTER/ORDER 事件含 time + IP;幂等(重复 touch 不计 SCAN)
- [ ] 核销记录含 userNo/nickname/phone
- [ ] 技术支持可创建/筛选/编辑优先级
- [ ] 合伙人/门店登录空字段前端提示;暂停合伙人微信登录被拒