fccfd7abbe
列表与详情的订单数/转化率按 COMPLETED 实时统计;扫码、订单、事件 ID 可跳到对应页。 Co-authored-by: Cursor <cursoragent@cursor.com>
787 lines
24 KiB
TypeScript
787 lines
24 KiB
TypeScript
import {
|
||
BadRequestException,
|
||
Inject,
|
||
Injectable,
|
||
NotFoundException,
|
||
} from '@nestjs/common';
|
||
import { randomBytes } from 'crypto';
|
||
import {
|
||
PROMO_CODE_SCENE_LABELS,
|
||
PromoCodeScene,
|
||
PromoMetricEventType,
|
||
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 { 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;
|
||
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,
|
||
private readonly promoMetricLog: PromoMetricLogService,
|
||
@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,
|
||
meta?: PromoTouchMeta,
|
||
) {
|
||
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(),
|
||
},
|
||
});
|
||
this.logPromoMetric(promo.id, 'ATTRIBUTION', meta, { userId });
|
||
}
|
||
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']) {
|
||
if (!user) return null;
|
||
return serializeBigInt({
|
||
id: user.id,
|
||
userNo: user.userNo,
|
||
nickname: user.nickname,
|
||
phone: maskPhone(user.phone),
|
||
});
|
||
}
|
||
|
||
private mapRow(row: PromoRow, orderCount?: number) {
|
||
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: 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 }),
|
||
]);
|
||
const completedByPromo = await this.completedOrderCounts(items.map((r) => r.id));
|
||
|
||
return serializeBigInt({
|
||
items: items.map((r) => this.mapRow(r, completedByPromo.get(r.id.toString()) ?? 0)),
|
||
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.orderCount), 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,
|
||
meta?: PromoTouchMeta,
|
||
) {
|
||
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, 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({
|
||
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 },
|
||
meta?: PromoTouchMeta,
|
||
): 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;
|
||
}
|
||
|
||
logPromoOrderEvent(
|
||
promoCodeId: bigint,
|
||
orderId: bigint,
|
||
userId: bigint,
|
||
clientIp?: string,
|
||
): void {
|
||
this.logPromoMetric(promoCodeId, 'ORDER', { clientIp }, { userId, orderId });
|
||
}
|
||
|
||
/** 订单完成时计入推广码订单数(下单/待付款不计入) */
|
||
async recordCompletedOrder(
|
||
promoCodeId: bigint,
|
||
orderId: bigint,
|
||
userId: bigint,
|
||
clientIp?: string,
|
||
): Promise<void> {
|
||
const already = await this.prisma.logPromoEvent.findFirst({
|
||
where: { orderId, eventType: 'ORDER' },
|
||
select: { id: true },
|
||
});
|
||
if (already) return;
|
||
await this.prisma.commonPromoCode.update({
|
||
where: { id: promoCodeId },
|
||
data: { orderCount: { increment: 1 } },
|
||
});
|
||
this.logPromoOrderEvent(promoCodeId, orderId, userId, clientIp);
|
||
}
|
||
|
||
private async completedOrderCounts(promoIds: bigint[]): Promise<Map<string, number>> {
|
||
const map = new Map<string, number>();
|
||
if (!promoIds.length) return map;
|
||
const rows = await this.prisma.order.groupBy({
|
||
by: ['promoCodeId'],
|
||
where: { promoCodeId: { in: promoIds }, status: 'COMPLETED' },
|
||
_count: { _all: true },
|
||
});
|
||
for (const row of rows) {
|
||
if (row.promoCodeId == null) continue;
|
||
map.set(row.promoCodeId.toString(), row._count._all);
|
||
}
|
||
return map;
|
||
}
|
||
|
||
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 }),
|
||
]);
|
||
|
||
const userIds = [...new Set(items.map((row) => row.userId).filter((id): id is bigint => id != null))];
|
||
const orderIds = [...new Set(items.map((row) => row.orderId).filter((id): id is bigint => id != null))];
|
||
const [users, orders] = await Promise.all([
|
||
userIds.length
|
||
? this.prisma.user.findMany({
|
||
where: { id: { in: userIds } },
|
||
select: { id: true, userNo: true },
|
||
})
|
||
: [],
|
||
orderIds.length
|
||
? this.prisma.order.findMany({
|
||
where: { id: { in: orderIds } },
|
||
select: { id: true, orderNo: true },
|
||
})
|
||
: [],
|
||
]);
|
||
const userNoById = new Map<string, string | null>(
|
||
users.map((u): [string, string | null] => [u.id.toString(), u.userNo]),
|
||
);
|
||
const orderNoById = new Map<string, string>(
|
||
orders.map((o): [string, string] => [o.id.toString(), o.orderNo]),
|
||
);
|
||
|
||
return serializeBigInt({
|
||
items: items.map((row) => ({
|
||
id: row.id,
|
||
eventType: row.eventType,
|
||
userId: row.userId,
|
||
userNo: row.userId ? userNoById.get(row.userId.toString()) ?? null : null,
|
||
orderId: row.orderId,
|
||
orderNo: row.orderId ? orderNoById.get(row.orderId.toString()) ?? null : null,
|
||
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 }) {
|
||
const scanCount = row.scanCount;
|
||
const [attributionCount, sourceMarkedCount, orderCount] = await Promise.all([
|
||
this.prisma.userPromoAttribution.count({
|
||
where: { promoCodeId: row.id },
|
||
}),
|
||
this.prisma.user.count({
|
||
where: {
|
||
sourceType: 'PROMO_CODE',
|
||
sourceRefId: row.id,
|
||
mergedIntoUserId: null,
|
||
},
|
||
}),
|
||
this.prisma.order.count({
|
||
where: { promoCodeId: row.id, status: 'COMPLETED' },
|
||
}),
|
||
]);
|
||
const conversionRate =
|
||
scanCount > 0 ? Math.round((orderCount / scanCount) * 1000) / 10 : 0;
|
||
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: { where: { status: 'COMPLETED' } } } },
|
||
},
|
||
}),
|
||
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,
|
||
});
|
||
}
|
||
}
|