Merge commit 'fcf1d45522e3ba6e6faf2590b60f2db1ef90e73c' into dev_jacy

This commit is contained in:
2026-07-07 13:55:10 +08:00
85 changed files with 11210 additions and 164 deletions
@@ -1,15 +1,30 @@
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { AnalyticsService } from './analytics.service';
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';
import { PromoTouchDto } from './dto/promo.dto';
import { ActorType } from '@dukang/shared-types';
@Controller('analytics')
@UseGuards(JwtAuthGuard)
export class AnalyticsController {
constructor(private readonly analyticsService: AnalyticsService) {}
@Post('events')
@UseGuards(JwtAuthGuard)
track(@CurrentUser() user: AuthUser, @Body() body: { events: Array<{ eventName: string; params?: Record<string, unknown> }> }) {
return this.analyticsService.trackBatch(user.actorId, user.clientApp, body.events);
}
}
@Controller('promo')
export class PromoController {
constructor(private readonly analyticsService: AnalyticsService) {}
@Post('touch')
@UseGuards(OptionalJwtAuthGuard)
touch(@CurrentUser() user: AuthUser | undefined, @Body() dto: PromoTouchDto) {
const userId = user?.actorType === ActorType.USER ? user.actorId : undefined;
return this.analyticsService.touchPromo(dto.promoCode, userId);
}
}
@@ -1,11 +1,11 @@
import { Module, forwardRef } from '@nestjs/common';
import { IamModule } from '../iam/iam.module';
import { AnalyticsController } from './analytics.controller';
import { AnalyticsController, PromoController } from './analytics.controller';
import { AnalyticsService } from './analytics.service';
@Module({
imports: [forwardRef(() => IamModule)],
controllers: [AnalyticsController],
imports: [IamModule],
controllers: [AnalyticsController, PromoController],
providers: [AnalyticsService],
exports: [AnalyticsService],
})
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
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;
@@ -84,4 +85,42 @@ export class AnalyticsService {
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,
});
}
}
@@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class PromoTouchDto {
@IsString()
@IsNotEmpty()
promoCode: string;
}