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 { 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 { 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> { const map = new Map(); 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( users.map((u): [string, string | null] => [u.id.toString(), u.userNo]), ); const orderNoById = new Map( 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, }); } }