import { Injectable, NotFoundException } from '@nestjs/common'; import type { ClientApp } from '@prisma/client'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; export type TrackEventInput = { eventName: string; pagePath?: string; refType?: string; refId?: bigint; extraJson?: Record; }; export type TrackStoreEventInput = TrackEventInput & { storeAccountId?: bigint; storeId: bigint; }; export type TrackPartnerEventInput = TrackEventInput & { partnerAccountId?: bigint; partnerId: bigint; }; @Injectable() export class AnalyticsService { constructor(private readonly prisma: PrismaService) {} async trackBatch( userId: bigint, clientApp: string, events: Array<{ eventName: string; params?: Record }>, ) { if (!events?.length) return { count: 0 }; await this.prisma.logUserAnalytics.createMany({ data: events.map((e) => this.toRow(userId, clientApp, { eventName: e.eventName, extraJson: e.params, refType: typeof e.params?.refType === 'string' ? e.params.refType : undefined, refId: e.params?.refId != null ? BigInt(String(e.params.refId)) : undefined, pagePath: typeof e.params?.pagePath === 'string' ? e.params.pagePath : undefined, })), }); return { count: events.length }; } async trackOne(userId: bigint, clientApp: ClientApp | string, event: TrackEventInput) { await this.prisma.logUserAnalytics.create({ data: this.toRow(userId, clientApp, event), }); } trackOneSafe(userId: bigint, clientApp: ClientApp | string, event: TrackEventInput) { void this.trackOne(userId, clientApp, event).catch(() => {}); } async trackStoreOne(storeAccountId: bigint | undefined, clientApp: ClientApp | string, event: TrackStoreEventInput) { await this.prisma.logStoreAnalytics.create({ data: this.toStoreRow(storeAccountId, clientApp, event), }); } trackStoreOneSafe(storeAccountId: bigint | undefined, clientApp: ClientApp | string, event: TrackStoreEventInput) { void this.trackStoreOne(storeAccountId, clientApp, event).catch(() => {}); } async trackPartnerOne( partnerAccountId: bigint | undefined, clientApp: ClientApp | string, event: TrackPartnerEventInput, ) { await this.prisma.logPartnerAnalytics.create({ data: this.toPartnerRow(partnerAccountId, clientApp, event), }); } trackPartnerOneSafe( partnerAccountId: bigint | undefined, clientApp: ClientApp | string, event: TrackPartnerEventInput, ) { void this.trackPartnerOne(partnerAccountId, clientApp, event).catch(() => {}); } private toRow(userId: bigint, clientApp: ClientApp | string, event: TrackEventInput) { return { userId, eventName: event.eventName, clientApp: clientApp as ClientApp, pagePath: event.pagePath, refType: event.refType, refId: event.refId, extraJson: event.extraJson as never, }; } private toStoreRow( storeAccountId: bigint | undefined, clientApp: ClientApp | string, event: TrackStoreEventInput, ) { return { storeAccountId, storeId: event.storeId, eventName: event.eventName, clientApp: clientApp as ClientApp, refType: event.refType, refId: event.refId, extraJson: event.extraJson as never, }; } private toPartnerRow( partnerAccountId: bigint | undefined, clientApp: ClientApp | string, event: TrackPartnerEventInput, ) { return { partnerAccountId, partnerId: event.partnerId, eventName: event.eventName, clientApp: clientApp as ClientApp, refType: event.refType, refId: event.refId, extraJson: event.extraJson as never, }; } /** 扫码归因:始终累加 scan_count;已登录用户首次写入 user_promo_attribution */ async touchPromo(promoCode: string, userId?: bigint) { const code = promoCode.trim().toUpperCase(); const promo = await this.prisma.commonPromoCode.findUnique({ where: { code } }); if (!promo || promo.status !== 'ACTIVE') { throw new NotFoundException('推广码无效或已停用'); } await this.prisma.commonPromoCode.update({ where: { id: promo.id }, data: { scanCount: { increment: 1 } }, }); let attributed = false; if (userId) { const existing = await this.prisma.userPromoAttribution.findUnique({ where: { userId }, }); if (!existing) { await this.prisma.userPromoAttribution.create({ data: { userId, promoCodeId: promo.id, channelName: promo.name, firstTouchAt: new Date(), }, }); attributed = true; } } return serializeBigInt({ promoCode: promo.code, channelName: promo.name, attributed, }); } }