Files
dukang/server/dukang-api/src/modules/promo/promo-code.service.ts
T
jacy 53e0db6a98 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>
2026-08-06 00:06:52 +08:00

725 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) {
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 失败,请重试');
}
/**
* 调用微信 getwxacodeunlimitscene=推广活动 IDPNG 上传 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 });
}
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;
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,
});
}
}