feat: 技术支持工单/企微权限/开发版本管理/消息推送等迭代

This commit is contained in:
2026-08-04 21:32:13 +08:00
parent c8ea5a3119
commit 9d96c73246
1341 changed files with 0 additions and 195605 deletions
@@ -1,84 +0,0 @@
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);
}
}
@@ -1,89 +0,0 @@
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;
}
@@ -1,558 +0,0 @@
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 失败,请重试');
}
/**
* 调用微信 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,
) {
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,
});
}
}
@@ -1,21 +0,0 @@
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 {}