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:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 { 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 {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user