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:
2026-08-06 00:06:52 +08:00
parent 8df0a2bea7
commit 53e0db6a98
15 changed files with 785 additions and 25 deletions
@@ -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<boolean> {
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;