Files
dukang/server/dukang-api/src/modules/promo/promo-code.service.ts
T
jacy fccfd7abbe feat(promo): HQ 推广码订单只计已完成并补扫码订单快链
列表与详情的订单数/转化率按 COMPLETED 实时统计;扫码、订单、事件 ID 可跳到对应页。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 12:55:19 +08:00

787 lines
24 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, 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 失败,请重试');
}
/**
* 调用微信 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 });
}
/** 订单完成时计入推广码订单数(下单/待付款不计入) */
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,
});
}
}