feat: multi-module iteration
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||
import { PromoCodeService } from './promo-code.service';
|
||||
import {
|
||||
CreatePromoCodeDto,
|
||||
PromoCodeListQueryDto,
|
||||
UpdatePromoCodeDto,
|
||||
UpdatePromoCodeStatusDto,
|
||||
} from './dto/promo-code.dto';
|
||||
|
||||
@Controller('admin/promo-codes')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminPromoCodeController {
|
||||
constructor(private readonly service: PromoCodeService) {}
|
||||
|
||||
@Get('scenes')
|
||||
listScenes() {
|
||||
return this.service.listScenes();
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: PromoCodeListQueryDto) {
|
||||
return this.service.list(query);
|
||||
}
|
||||
|
||||
@Get(':id/users')
|
||||
listUsers(@Param('id') id: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
||||
return this.service.listUsers(
|
||||
BigInt(id),
|
||||
page ? Number(page) : 1,
|
||||
pageSize ? Number(pageSize) : 20,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id/stats')
|
||||
stats(@Param('id') id: string) {
|
||||
return this.service.stats(BigInt(id));
|
||||
}
|
||||
|
||||
@Get(':id/qrcode')
|
||||
qrcode(@Param('id') id: string) {
|
||||
return this.service.getQrcodeUrl(BigInt(id));
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.service.detail(BigInt(id));
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HqOperation({
|
||||
action: HqOperationAction.PROMO_CODE_CREATE,
|
||||
refType: 'PROMO_CODE',
|
||||
batch: true,
|
||||
includeBody: true,
|
||||
})
|
||||
create(@Body() dto: CreatePromoCodeDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.PROMO_CODE_UPDATE,
|
||||
refType: 'PROMO_CODE',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
update(@Param('id') id: string, @Body() dto: UpdatePromoCodeDto) {
|
||||
return this.service.update(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Put(':id/status')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.PROMO_CODE_UPDATE_STATUS,
|
||||
refType: 'PROMO_CODE',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
updateStatus(@Param('id') id: string, @Body() dto: UpdatePromoCodeStatusDto) {
|
||||
return this.service.updateStatus(BigInt(id), dto.status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsIn, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { PromoCodeScene, PromoCodeStatus } from '@dukang/shared-types';
|
||||
|
||||
export class PromoCodeListQueryDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
pageSize?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
code?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['ACTIVE', 'DISABLED'])
|
||||
status?: PromoCodeStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['ONLINE_LINK', 'OFFLINE_PICKUP', 'PARTNER_CHANNEL', 'EVENT', 'OTHER'])
|
||||
scene?: PromoCodeScene;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ownerUserId?: string;
|
||||
}
|
||||
|
||||
export class CreatePromoCodeDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(128)
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
code?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['ONLINE_LINK', 'OFFLINE_PICKUP', 'PARTNER_CHANNEL', 'EVENT', 'OTHER'])
|
||||
scene?: PromoCodeScene;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ownerUserId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
remark?: string;
|
||||
|
||||
/** 小程序码落地页路径,如 pages/home/index;留空用环境变量 WX_MINI_PROMO_PAGE */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
page?: string;
|
||||
}
|
||||
|
||||
export class UpdatePromoCodeDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['ONLINE_LINK', 'OFFLINE_PICKUP', 'PARTNER_CHANNEL', 'EVENT', 'OTHER'])
|
||||
scene?: PromoCodeScene;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ownerUserId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
remark?: string | null;
|
||||
}
|
||||
|
||||
export class UpdatePromoCodeStatusDto {
|
||||
@IsIn(['ACTIVE', 'DISABLED'])
|
||||
status: PromoCodeStatus;
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { randomBytes } from 'crypto';
|
||||
import {
|
||||
PROMO_CODE_SCENE_LABELS,
|
||||
PromoCodeScene,
|
||||
buildPromoLandingUrl,
|
||||
loadAppConfig,
|
||||
} from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
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 type {
|
||||
CreatePromoCodeDto,
|
||||
PromoCodeListQueryDto,
|
||||
UpdatePromoCodeDto,
|
||||
} from './dto/promo-code.dto';
|
||||
|
||||
type PromoRow = {
|
||||
id: bigint;
|
||||
code: string;
|
||||
name: string;
|
||||
scene: string;
|
||||
qrcodeId: string;
|
||||
status: string;
|
||||
remark: string | null;
|
||||
scanCount: number;
|
||||
orderCount: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
ownerUser?: {
|
||||
id: bigint;
|
||||
userNo: string | null;
|
||||
nickname: string | null;
|
||||
phone: string | null;
|
||||
} | null;
|
||||
qrcodeResource?: { url: string } | null;
|
||||
};
|
||||
|
||||
function userH5Base(): string {
|
||||
return loadAppConfig().userH5Url;
|
||||
}
|
||||
|
||||
function buildLandingUrl(code: string, qrcodeId: string): string {
|
||||
return buildPromoLandingUrl(userH5Base(), code, qrcodeId);
|
||||
}
|
||||
|
||||
function randomPromoCode(): string {
|
||||
const n = Math.random().toString(36).slice(2, 8).toUpperCase();
|
||||
return `DK${n}`;
|
||||
}
|
||||
|
||||
function randomQrcodeId(): string {
|
||||
return randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
function maskPhone(phone: string | null | undefined) {
|
||||
if (!phone || phone.length < 7) return phone ?? null;
|
||||
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PromoCodeService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(OSS_PROVIDER) private readonly oss: IOssProvider,
|
||||
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
|
||||
) {}
|
||||
|
||||
listScenes() {
|
||||
return Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({ value, label }));
|
||||
}
|
||||
|
||||
async listActiveOptions() {
|
||||
const rows = await this.prisma.commonPromoCode.findMany({
|
||||
where: { status: 'ACTIVE' },
|
||||
select: { id: true, code: true, name: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
return serializeBigInt(rows);
|
||||
}
|
||||
|
||||
/** 代下单绑定推广码:归因 + 用户来源(若可写) */
|
||||
async attributeUserToPromo(userId: bigint, promoId: bigint) {
|
||||
const promo = await this.prisma.commonPromoCode.findUnique({
|
||||
where: { id: promoId },
|
||||
select: { id: true, name: true, status: true },
|
||||
});
|
||||
if (!promo || promo.status !== 'ACTIVE') {
|
||||
throw new BadRequestException('推广码无效或已停用');
|
||||
}
|
||||
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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
await this.applyPromoSourceToUser(userId, promo);
|
||||
return promo;
|
||||
}
|
||||
|
||||
private mapOwnerUser(user: PromoRow['ownerUser']) {
|
||||
if (!user) return null;
|
||||
return serializeBigInt({
|
||||
id: user.id,
|
||||
userNo: user.userNo,
|
||||
nickname: user.nickname,
|
||||
phone: maskPhone(user.phone),
|
||||
});
|
||||
}
|
||||
|
||||
private mapRow(row: PromoRow) {
|
||||
return serializeBigInt({
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
scene: row.scene,
|
||||
qrcodeId: row.qrcodeId,
|
||||
status: row.status,
|
||||
remark: row.remark,
|
||||
scanCount: row.scanCount,
|
||||
orderCount: row.orderCount,
|
||||
landingUrl: buildLandingUrl(row.code, row.qrcodeId),
|
||||
qrcodeUrl: row.qrcodeResource?.url ?? null,
|
||||
ownerUser: this.mapOwnerUser(row.ownerUser),
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
private includeRelations = {
|
||||
ownerUser: {
|
||||
select: { id: true, userNo: true, nickname: true, phone: true },
|
||||
},
|
||||
qrcodeResource: {
|
||||
select: { url: true },
|
||||
},
|
||||
} as const;
|
||||
|
||||
async list(query: PromoCodeListQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: {
|
||||
status?: 'ACTIVE' | 'DISABLED';
|
||||
scene?: PromoCodeScene;
|
||||
name?: { contains: string };
|
||||
code?: { contains: string };
|
||||
ownerUserId?: bigint;
|
||||
} = {};
|
||||
if (query.status) where.status = query.status;
|
||||
if (query.scene) where.scene = query.scene;
|
||||
if (query.name) where.name = { contains: query.name };
|
||||
if (query.code) where.code = { contains: query.code.toUpperCase() };
|
||||
if (query.ownerUserId?.trim()) {
|
||||
where.ownerUserId = BigInt(query.ownerUserId.trim());
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.commonPromoCode.findMany({
|
||||
where,
|
||||
include: this.includeRelations,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.commonPromoCode.count({ where }),
|
||||
]);
|
||||
|
||||
return serializeBigInt({
|
||||
items: items.map((r) => this.mapRow(r)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
async detail(id: bigint) {
|
||||
const row = await this.prisma.commonPromoCode.findUnique({
|
||||
where: { id },
|
||||
include: this.includeRelations,
|
||||
});
|
||||
if (!row) throw new NotFoundException('推广码不存在');
|
||||
const stats = await this.statsFromRow(row);
|
||||
return serializeBigInt({ ...this.mapRow(row), stats });
|
||||
}
|
||||
|
||||
private async resolveOwnerUserId(ownerUserId?: string) {
|
||||
if (!ownerUserId?.trim()) return undefined;
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { id: BigInt(ownerUserId.trim()), mergedIntoUserId: null, status: 1 },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!user) throw new BadRequestException('关联用户不存在');
|
||||
return user.id;
|
||||
}
|
||||
|
||||
private async generateUniqueCode(custom?: string) {
|
||||
let code = custom?.trim().toUpperCase();
|
||||
if (code) {
|
||||
const exists = await this.prisma.commonPromoCode.findUnique({ where: { code } });
|
||||
if (exists) throw new BadRequestException('推广码已存在');
|
||||
return code;
|
||||
}
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const candidate = randomPromoCode();
|
||||
const exists = await this.prisma.commonPromoCode.findUnique({ where: { code: candidate } });
|
||||
if (!exists) return candidate;
|
||||
}
|
||||
throw new BadRequestException('生成推广码失败,请重试');
|
||||
}
|
||||
|
||||
private async generateUniqueQrcodeId() {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const candidate = randomQrcodeId();
|
||||
const exists = await this.prisma.commonPromoCode.findUnique({ where: { qrcodeId: candidate } });
|
||||
if (!exists) return candidate;
|
||||
}
|
||||
throw new BadRequestException('生成二维码 ID 失败,请重试');
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用微信 getwxacodeunlimit,scene=推广活动 ID,PNG 上传 OSS uploads/qrcode/
|
||||
*/
|
||||
private async createQrcodeResource(promoId: bigint, code: string, pagePath?: string) {
|
||||
const scene = promoId.toString();
|
||||
if (scene.length > 32) {
|
||||
throw new BadRequestException('推广活动 ID 过长,无法写入小程序码 scene');
|
||||
}
|
||||
const page = (
|
||||
pagePath?.trim() ||
|
||||
process.env.WX_MINI_PROMO_PAGE ||
|
||||
'pages/home/index'
|
||||
).replace(/^\//, '');
|
||||
const pngBuffer = await this.wechat.getWxaCodeUnlimited({
|
||||
scene,
|
||||
page,
|
||||
width: 430,
|
||||
checkPath: false,
|
||||
});
|
||||
const fileName = `promo-${code}-${scene}.png`;
|
||||
const uploaded = await this.oss.putObject({
|
||||
bizType: 'QRCODE',
|
||||
mediaType: 'IMAGE',
|
||||
fileName,
|
||||
buffer: pngBuffer,
|
||||
mimeType: 'image/png',
|
||||
});
|
||||
const resource = await this.prisma.commonResource.create({
|
||||
data: {
|
||||
ownerType: 'PROMO',
|
||||
ownerId: promoId,
|
||||
bizType: 'QRCODE',
|
||||
mediaType: 'IMAGE',
|
||||
ossBucket: uploaded.bucket,
|
||||
ossKey: uploaded.ossKey,
|
||||
url: uploaded.url,
|
||||
fileName,
|
||||
fileSize: BigInt(pngBuffer.length),
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
});
|
||||
return resource;
|
||||
}
|
||||
|
||||
async create(dto: CreatePromoCodeDto) {
|
||||
const code = await this.generateUniqueCode(dto.code);
|
||||
const qrcodeId = await this.generateUniqueQrcodeId();
|
||||
const ownerUserId = await this.resolveOwnerUserId(dto.ownerUserId);
|
||||
const scene = (dto.scene ?? 'ONLINE_LINK') as PromoCodeScene;
|
||||
|
||||
const row = await this.prisma.commonPromoCode.create({
|
||||
data: {
|
||||
code,
|
||||
name: dto.name.trim(),
|
||||
scene,
|
||||
qrcodeId,
|
||||
status: 'ACTIVE',
|
||||
ownerUserId,
|
||||
remark: dto.remark?.trim() || null,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const resource = await this.createQrcodeResource(row.id, code, dto.page);
|
||||
const updated = await this.prisma.commonPromoCode.update({
|
||||
where: { id: row.id },
|
||||
data: { qrcodeResourceId: resource.id },
|
||||
include: this.includeRelations,
|
||||
});
|
||||
return this.mapRow(updated);
|
||||
} catch (err) {
|
||||
await this.prisma.commonPromoCode.delete({ where: { id: row.id } }).catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: bigint, dto: UpdatePromoCodeDto) {
|
||||
await this.detail(id);
|
||||
const data: {
|
||||
name?: string;
|
||||
scene?: PromoCodeScene;
|
||||
remark?: string | null;
|
||||
ownerUserId?: bigint | null;
|
||||
} = {};
|
||||
if (dto.name !== undefined) data.name = dto.name.trim();
|
||||
if (dto.scene !== undefined) data.scene = dto.scene;
|
||||
if (dto.remark !== undefined) data.remark = dto.remark?.trim() || null;
|
||||
if (dto.ownerUserId !== undefined) {
|
||||
if (dto.ownerUserId === null || dto.ownerUserId === '') {
|
||||
data.ownerUserId = null;
|
||||
} else {
|
||||
data.ownerUserId = await this.resolveOwnerUserId(dto.ownerUserId);
|
||||
}
|
||||
}
|
||||
|
||||
const row = await this.prisma.commonPromoCode.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: this.includeRelations,
|
||||
});
|
||||
return this.mapRow(row);
|
||||
}
|
||||
|
||||
async updateStatus(id: bigint, status: 'ACTIVE' | 'DISABLED') {
|
||||
const row = await this.prisma.commonPromoCode.update({
|
||||
where: { id },
|
||||
data: { status },
|
||||
include: this.includeRelations,
|
||||
});
|
||||
return this.mapRow(row);
|
||||
}
|
||||
|
||||
async stats(id: bigint) {
|
||||
const row = await this.prisma.commonPromoCode.findUnique({ where: { id } });
|
||||
if (!row) throw new NotFoundException('推广码不存在');
|
||||
return serializeBigInt(await this.statsFromRow(row));
|
||||
}
|
||||
|
||||
async getQrcodeUrl(id: bigint) {
|
||||
const row = await this.prisma.commonPromoCode.findUnique({
|
||||
where: { id },
|
||||
include: { qrcodeResource: { select: { url: true } } },
|
||||
});
|
||||
if (!row) throw new NotFoundException('推广码不存在');
|
||||
if (!row.qrcodeResource?.url) {
|
||||
throw new NotFoundException('二维码资源不存在,请重新生成推广码');
|
||||
}
|
||||
return {
|
||||
qrcodeUrl: row.qrcodeResource.url,
|
||||
landingUrl: buildLandingUrl(row.code, row.qrcodeId),
|
||||
name: row.name,
|
||||
code: row.code,
|
||||
};
|
||||
}
|
||||
|
||||
async findByCodeOrQrcodeId(input: { code?: string; qrcodeId?: string; promoId?: string }) {
|
||||
const code = input.code?.trim().toUpperCase();
|
||||
const qrcodeId = input.qrcodeId?.trim();
|
||||
const promoId = input.promoId?.trim();
|
||||
if (promoId && /^\d+$/.test(promoId)) {
|
||||
return this.prisma.commonPromoCode.findUnique({ where: { id: BigInt(promoId) } });
|
||||
}
|
||||
if (code) {
|
||||
return this.prisma.commonPromoCode.findUnique({ where: { code } });
|
||||
}
|
||||
if (qrcodeId) {
|
||||
return this.prisma.commonPromoCode.findUnique({ where: { qrcodeId } });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** C 端扫码/带参进入:累加 scan_count、归因、标记用户来源 */
|
||||
async touch(
|
||||
input: {
|
||||
promoCode?: string;
|
||||
qrcodeId?: string;
|
||||
promoId?: string;
|
||||
/** 默认 true;登录后归因传 false 避免重复计扫码 */
|
||||
countScan?: boolean;
|
||||
},
|
||||
userId?: bigint,
|
||||
) {
|
||||
const promoCode = input.promoCode?.trim().toUpperCase();
|
||||
const qrcodeId = input.qrcodeId?.trim();
|
||||
const promoId = input.promoId?.trim();
|
||||
if (!promoCode && !qrcodeId && !promoId) {
|
||||
throw new BadRequestException('请提供 promoId、promoCode 或 qrcodeId');
|
||||
}
|
||||
|
||||
const promo = await this.findByCodeOrQrcodeId({ code: promoCode, qrcodeId, promoId });
|
||||
if (!promo || promo.status !== 'ACTIVE') {
|
||||
throw new NotFoundException('推广码无效或已停用');
|
||||
}
|
||||
|
||||
const shouldCountScan = input.countScan !== false;
|
||||
if (shouldCountScan) {
|
||||
await this.prisma.commonPromoCode.update({
|
||||
where: { id: promo.id },
|
||||
data: { scanCount: { increment: 1 } },
|
||||
});
|
||||
}
|
||||
|
||||
let attributed = false;
|
||||
let sourceApplied = 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;
|
||||
}
|
||||
|
||||
sourceApplied = await this.applyPromoSourceToUser(userId, promo);
|
||||
}
|
||||
|
||||
return serializeBigInt({
|
||||
promoCode: promo.code,
|
||||
promoCodeId: promo.id,
|
||||
channelName: promo.name,
|
||||
attributed,
|
||||
sourceApplied,
|
||||
scanCounted: shouldCountScan,
|
||||
});
|
||||
}
|
||||
|
||||
/** 用户来源:PROMO_CODE + 推广码 ID(仅 ORGANIC 可写入,不覆盖已有来源) */
|
||||
async applyPromoSourceToUser(
|
||||
userId: bigint,
|
||||
promo: { id: bigint; name: string },
|
||||
): Promise<boolean> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { sourceType: true, mergedIntoUserId: true, status: true },
|
||||
});
|
||||
if (!user || user.mergedIntoUserId || user.status !== 1 || user.sourceType !== 'ORGANIC') {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
sourceType: 'PROMO_CODE',
|
||||
sourceRefId: promo.id,
|
||||
sourceLabel: promo.name,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private async statsFromRow(row: { id: bigint; scanCount: number; orderCount: number }) {
|
||||
const scanCount = row.scanCount;
|
||||
const orderCount = row.orderCount;
|
||||
const conversionRate =
|
||||
scanCount > 0 ? Math.round((orderCount / scanCount) * 1000) / 10 : 0;
|
||||
const [attributionCount, sourceMarkedCount] = await Promise.all([
|
||||
this.prisma.userPromoAttribution.count({
|
||||
where: { promoCodeId: row.id },
|
||||
}),
|
||||
this.prisma.user.count({
|
||||
where: {
|
||||
sourceType: 'PROMO_CODE',
|
||||
sourceRefId: row.id,
|
||||
mergedIntoUserId: null,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
scanCount,
|
||||
orderCount,
|
||||
conversionRate,
|
||||
attributionCount,
|
||||
sourceMarkedCount,
|
||||
registerCount: sourceMarkedCount,
|
||||
};
|
||||
}
|
||||
|
||||
/** 推广码关联用户:归因记录或用户来源指向本码 */
|
||||
async listUsers(promoId: bigint, page = 1, pageSize = 20) {
|
||||
const promo = await this.prisma.commonPromoCode.findUnique({
|
||||
where: { id: promoId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!promo) throw new NotFoundException('推广码不存在');
|
||||
|
||||
const where = {
|
||||
mergedIntoUserId: null,
|
||||
OR: [
|
||||
{ promoTouch: { promoCodeId: promoId } },
|
||||
{ sourceType: 'PROMO_CODE' as const, sourceRefId: promoId },
|
||||
],
|
||||
};
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.user.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
select: {
|
||||
id: true,
|
||||
userNo: true,
|
||||
nickname: true,
|
||||
phone: true,
|
||||
phoneVerifiedAt: true,
|
||||
sourceType: true,
|
||||
sourceRefId: true,
|
||||
createdAt: true,
|
||||
promoTouch: { select: { firstTouchAt: true, promoCodeId: true } },
|
||||
_count: { select: { orders: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.user.count({ where }),
|
||||
]);
|
||||
|
||||
return serializeBigInt({
|
||||
items: items.map((u) => ({
|
||||
id: u.id,
|
||||
userNo: u.userNo,
|
||||
nickname: u.nickname,
|
||||
phone: maskPhone(u.phone),
|
||||
phoneVerifiedAt: u.phoneVerifiedAt,
|
||||
sourceType: u.sourceType,
|
||||
sourceRefId: u.sourceRefId,
|
||||
firstTouchAt: u.promoTouch?.promoCodeId.toString() === promoId.toString()
|
||||
? u.promoTouch.firstTouchAt
|
||||
: null,
|
||||
orderCount: u._count.orders,
|
||||
createdAt: u.createdAt,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
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';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
IntegrationsModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET || 'dukang-prev1-dev-secret',
|
||||
signOptions: { expiresIn: process.env.JWT_EXPIRES_IN || '7d' },
|
||||
}),
|
||||
],
|
||||
controllers: [AdminPromoCodeController],
|
||||
providers: [PromoCodeService, JwtAuthGuard, HqAuthGuard],
|
||||
exports: [PromoCodeService],
|
||||
})
|
||||
export class PromoModule {}
|
||||
Reference in New Issue
Block a user