v4.0.20版本迭代推广码增加渠道负责人

This commit is contained in:
2026-09-17 09:06:29 +08:00
parent 1867e7ea55
commit b9921b1a75
36 changed files with 12138 additions and 112 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@dukang/api",
"version": "4.0.19",
"version": "4.0.20",
"private": true,
"scripts": {
"predev": "pnpm --dir ../../packages/domain build",
@@ -0,0 +1,18 @@
-- v4.0.20:推广码关联合伙人 + 渠道负责人(主合伙人多选)
-- Review 后再在生产执行。
ALTER TABLE `common_promo_code`
ADD COLUMN `assoc_partner_account_id` BIGINT UNSIGNED NULL AFTER `owner_user_id`,
ADD KEY `idx_common_promo_code_assoc_partner` (`assoc_partner_account_id`),
ADD CONSTRAINT `fk_common_promo_code_assoc_partner`
FOREIGN KEY (`assoc_partner_account_id`) REFERENCES `partner_account`(`id`) ON DELETE SET NULL;
CREATE TABLE IF NOT EXISTS `promo_code_channel_owner` (
`promo_code_id` BIGINT UNSIGNED NOT NULL,
`partner_account_id` BIGINT UNSIGNED NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`promo_code_id`, `partner_account_id`),
KEY `idx_promo_channel_owner_partner` (`partner_account_id`),
CONSTRAINT `fk_promo_channel_owner_promo` FOREIGN KEY (`promo_code_id`) REFERENCES `common_promo_code`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_promo_channel_owner_partner` FOREIGN KEY (`partner_account_id`) REFERENCES `partner_account`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='推广码渠道负责人(主合伙人)';
+35 -15
View File
@@ -1069,31 +1069,49 @@ model StoreCategoryLink {
}
model CommonPromoCode {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
code String @unique @db.VarChar(32)
name String @db.VarChar(128)
scene PromoCodeScene @default(ONLINE_LINK)
qrcodeId String @unique @map("qrcode_id") @db.VarChar(64)
status PromoCodeStatus @default(ACTIVE)
ownerUserId BigInt? @map("owner_user_id") @db.UnsignedBigInt
remark String? @db.VarChar(256)
qrcodeResourceId BigInt? @map("qrcode_resource_id") @db.UnsignedBigInt
scanCount Int @default(0) @map("scan_count")
orderCount Int @default(0) @map("order_count")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
code String @unique @db.VarChar(32)
name String @db.VarChar(128)
scene PromoCodeScene @default(ONLINE_LINK)
qrcodeId String @unique @map("qrcode_id") @db.VarChar(64)
status PromoCodeStatus @default(ACTIVE)
ownerUserId BigInt? @map("owner_user_id") @db.UnsignedBigInt
assocPartnerAccountId BigInt? @map("assoc_partner_account_id") @db.UnsignedBigInt
remark String? @db.VarChar(256)
qrcodeResourceId BigInt? @map("qrcode_resource_id") @db.UnsignedBigInt
scanCount Int @default(0) @map("scan_count")
orderCount Int @default(0) @map("order_count")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
ownerUser User? @relation("PromoOwnerUser", fields: [ownerUserId], references: [id], onDelete: SetNull)
qrcodeResource CommonResource? @relation("PromoQrcode", fields: [qrcodeResourceId], references: [id], onDelete: SetNull)
ownerUser User? @relation("PromoOwnerUser", fields: [ownerUserId], references: [id], onDelete: SetNull)
assocPartner PartnerAccount? @relation("PromoAssocPartner", fields: [assocPartnerAccountId], references: [id], onDelete: SetNull)
qrcodeResource CommonResource? @relation("PromoQrcode", fields: [qrcodeResourceId], references: [id], onDelete: SetNull)
channelOwners PromoCodeChannelOwner[]
attributions UserPromoAttribution[]
orders Order[]
metricEvents LogPromoEvent[]
@@index([ownerUserId])
@@index([assocPartnerAccountId])
@@index([scene, status])
@@map("common_promo_code")
}
/// 推广码渠道负责人(主合伙人,可多选)
model PromoCodeChannelOwner {
promoCodeId BigInt @map("promo_code_id") @db.UnsignedBigInt
partnerAccountId BigInt @map("partner_account_id") @db.UnsignedBigInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
promoCode CommonPromoCode @relation(fields: [promoCodeId], references: [id], onDelete: Cascade)
partnerAccount PartnerAccount @relation("PromoChannelOwner", fields: [partnerAccountId], references: [id], onDelete: Cascade)
@@id([promoCodeId, partnerAccountId])
@@index([partnerAccountId])
@@map("promo_code_channel_owner")
}
model CommonCity {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
code String @unique @db.VarChar(16)
@@ -1287,6 +1305,8 @@ model PartnerAccount {
assocUsers User[] @relation("UserPartnerAssoc")
assocSubUsers User[] @relation("UserSubAccountAssoc")
userNotes PartnerUserNote[]
promoAssocCodes CommonPromoCode[] @relation("PromoAssocPartner")
promoChannelOwnerLinks PromoCodeChannelOwner[] @relation("PromoChannelOwner")
assocQrcodeResource CommonResource? @relation("PartnerAssocQrcode", fields: [assocQrcodeResourceId], references: [id], onDelete: SetNull)
activityPoster ActivityPoster? @relation(fields: [activityPosterId], references: [id], onDelete: SetNull)
@@ -152,7 +152,7 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
group: G.wechat_mini,
type: 'string',
requiresRestart: false,
placeholder: '4.0.19',
placeholder: '4.0.20',
description: 'semver 格式(可带或不带 v);客户端低于此版本时提示更新',
},
{
@@ -7,7 +7,7 @@ import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guar
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
@Module({
imports: [forwardRef(() => IamModule), PromoModule],
imports: [forwardRef(() => IamModule), forwardRef(() => PromoModule)],
controllers: [AnalyticsController, PromoController],
providers: [AnalyticsService, OptionalJwtAuthGuard, JwtAuthGuard],
exports: [AnalyticsService],
@@ -89,6 +89,47 @@ export class PartnerCityService {
return snapshot;
}
/**
* 推广码关联合伙人:未绑定则 first-lock;已绑同一人 noop;已绑他人静默跳过。
* 不改 sourceType、不写 assocSubAccountId、不增加关联码扫码计数。
*/
async tryBindIfUnbound(userId: bigint, partnerAccountId: bigint): Promise<{
bound: boolean;
alreadyBound: boolean;
skipped: boolean;
}> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { id: true, assocPartnerAccountId: true },
});
if (!user) {
return { bound: false, alreadyBound: false, skipped: true };
}
if (user.assocPartnerAccountId) {
if (user.assocPartnerAccountId === partnerAccountId) {
return { bound: true, alreadyBound: true, skipped: false };
}
return { bound: false, alreadyBound: false, skipped: true };
}
const primary = await this.prisma.partnerAccount.findUnique({
where: { id: partnerAccountId },
select: { id: true, isPrimary: true, status: true },
});
if (!primary || primary.isPrimary !== 1 || primary.status !== 'ACTIVE') {
return { bound: false, alreadyBound: false, skipped: true };
}
await this.prisma.user.update({
where: { id: userId },
data: {
assocPartnerAccountId: primary.id,
assocBoundAt: new Date(),
},
});
return { bound: true, alreadyBound: false, skipped: false };
}
async validatePrimaryBinding(
cityId: bigint,
input: {
@@ -28,7 +28,7 @@ export class HealthController {
return {
status,
service: 'dukang-api',
version: '4.0.19',
version: '4.0.20',
checks: { db, redis },
};
}
@@ -1,7 +1,14 @@
import { Type } from 'class-transformer';
import { IsIn, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
import { Transform, Type } from 'class-transformer';
import { IsArray, IsIn, IsNotEmpty, IsOptional, IsString, MaxLength, ValidateIf } from 'class-validator';
import { PromoCodeScene, PromoCodeStatus } from '@dukang/shared-types';
function toOptionalNullableString(value: unknown): string | null | undefined {
if (value === undefined) return undefined;
if (value === null) return null;
const t = String(value).trim();
return t.length ? t : null;
}
export class PromoCodeListQueryDto {
@IsOptional()
@Type(() => Number)
@@ -30,6 +37,14 @@ export class PromoCodeListQueryDto {
@IsOptional()
@IsString()
ownerUserId?: string;
@IsOptional()
@IsString()
channelOwnerPartnerId?: string;
@IsOptional()
@IsString()
assocPartnerAccountId?: string;
}
export class CreatePromoCodeDto {
@@ -51,6 +66,17 @@ export class CreatePromoCodeDto {
@IsString()
ownerUserId?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
channelOwnerPartnerIds?: string[];
@IsOptional()
@Transform(({ value }) => toOptionalNullableString(value))
@ValidateIf((_, v) => v !== null && v !== undefined)
@IsString()
assocPartnerAccountId?: string | null;
@IsOptional()
@IsString()
@MaxLength(256)
@@ -77,6 +103,17 @@ export class UpdatePromoCodeDto {
@IsString()
ownerUserId?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
channelOwnerPartnerIds?: string[];
@IsOptional()
@Transform(({ value }) => toOptionalNullableString(value))
@ValidateIf((_, v) => v !== null && v !== undefined)
@IsString()
assocPartnerAccountId?: string | null;
@IsOptional()
@IsString()
@MaxLength(256)
@@ -0,0 +1,16 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { PromoCodeService } from './promo-code.service';
@Controller('partner/promo-codes')
@UseGuards(JwtAuthGuard, PartnerPrimaryGuard)
export class PartnerPromoCodeController {
constructor(private readonly service: PromoCodeService) {}
@Get()
list(@CurrentUser() user: AuthUser) {
return this.service.listForPartner(user.actorId);
}
}
@@ -5,6 +5,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { randomBytes } from 'crypto';
import { Prisma } from '@prisma/client';
import {
PROMO_CODE_SCENE_LABELS,
PromoCodeScene,
@@ -17,6 +18,7 @@ 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 { PartnerCityService } from '../city-scope/partner-city.service';
import { PromoMetricLogService } from './promo-metric-log.service';
import {
computePromoMetricPeak,
@@ -41,6 +43,13 @@ type PromoTouchMeta = {
sessionId?: string;
};
type PromoPartnerBrief = {
id: bigint;
companyName: string | null;
name: string;
phone: string;
};
type PromoRow = {
id: bigint;
code: string;
@@ -59,6 +68,8 @@ type PromoRow = {
nickname: string | null;
phone: string | null;
} | null;
assocPartner?: PromoPartnerBrief | null;
channelOwners?: Array<{ partnerAccount: PromoPartnerBrief }>;
qrcodeResource?: { url: string } | null;
};
@@ -89,6 +100,7 @@ export class PromoCodeService {
constructor(
private readonly prisma: PrismaService,
private readonly promoMetricLog: PromoMetricLogService,
private readonly partnerCity: PartnerCityService,
@Inject(OSS_PROVIDER) private readonly oss: IOssProvider,
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
) {}
@@ -151,6 +163,16 @@ export class PromoCodeService {
});
}
private mapPartnerBrief(partner?: PromoPartnerBrief | null) {
if (!partner) return null;
return serializeBigInt({
id: partner.id,
companyName: partner.companyName,
name: partner.name,
phone: partner.phone,
});
}
private mapRow(row: PromoRow, orderCount?: number) {
return serializeBigInt({
id: row.id,
@@ -165,15 +187,30 @@ export class PromoCodeService {
landingUrl: buildLandingUrl(row.code, row.qrcodeId),
qrcodeUrl: row.qrcodeResource?.url ?? null,
ownerUser: this.mapOwnerUser(row.ownerUser),
channelOwners: (row.channelOwners ?? []).map((link) => this.mapPartnerBrief(link.partnerAccount)),
assocPartner: this.mapPartnerBrief(row.assocPartner),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
});
}
private partnerSelect = {
id: true,
companyName: true,
name: true,
phone: true,
} as const;
private includeRelations = {
ownerUser: {
select: { id: true, userNo: true, nickname: true, phone: true },
},
assocPartner: {
select: this.partnerSelect,
},
channelOwners: {
include: { partnerAccount: { select: this.partnerSelect } },
},
qrcodeResource: {
select: { url: true },
},
@@ -182,13 +219,7 @@ export class PromoCodeService {
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;
} = {};
const where: Prisma.CommonPromoCodeWhereInput = {};
if (query.status) where.status = query.status;
if (query.scene) where.scene = query.scene;
if (query.name) where.name = { contains: query.name };
@@ -196,6 +227,14 @@ export class PromoCodeService {
if (query.ownerUserId?.trim()) {
where.ownerUserId = BigInt(query.ownerUserId.trim());
}
if (query.channelOwnerPartnerId?.trim()) {
where.channelOwners = {
some: { partnerAccountId: BigInt(query.channelOwnerPartnerId.trim()) },
};
}
if (query.assocPartnerAccountId?.trim()) {
where.assocPartnerAccountId = BigInt(query.assocPartnerAccountId.trim());
}
const [items, total] = await Promise.all([
this.prisma.commonPromoCode.findMany({
@@ -237,6 +276,30 @@ export class PromoCodeService {
return user.id;
}
private async resolvePrimaryPartnerId(partnerId?: string | null) {
if (partnerId === undefined) return undefined;
if (partnerId === null || !partnerId.trim()) return null;
const partner = await this.prisma.partnerAccount.findFirst({
where: { id: BigInt(partnerId.trim()), isPrimary: 1, status: 'ACTIVE' },
select: { id: true },
});
if (!partner) throw new BadRequestException('关联合伙人必须是有效的主合伙人');
return partner.id;
}
private async resolveChannelOwnerIds(ids?: string[]) {
const unique = [...new Set((ids ?? []).map((s) => s.trim()).filter(Boolean))];
if (!unique.length) return [] as bigint[];
const rows = await this.prisma.partnerAccount.findMany({
where: { id: { in: unique.map((id) => BigInt(id)) }, isPrimary: 1, status: 'ACTIVE' },
select: { id: true },
});
if (rows.length !== unique.length) {
throw new BadRequestException('渠道负责人必须是有效的主合伙人');
}
return unique.map((id) => BigInt(id));
}
private async generateUniqueCode(custom?: string) {
let code = custom?.trim().toUpperCase();
if (code) {
@@ -309,6 +372,8 @@ export class PromoCodeService {
const code = await this.generateUniqueCode(dto.code);
const qrcodeId = await this.generateUniqueQrcodeId();
const ownerUserId = await this.resolveOwnerUserId(dto.ownerUserId);
const assocPartnerAccountId = await this.resolvePrimaryPartnerId(dto.assocPartnerAccountId);
const channelOwnerIds = await this.resolveChannelOwnerIds(dto.channelOwnerPartnerIds);
const scene = (dto.scene ?? 'ONLINE_LINK') as PromoCodeScene;
const row = await this.prisma.commonPromoCode.create({
@@ -319,7 +384,11 @@ export class PromoCodeService {
qrcodeId,
status: 'ACTIVE',
ownerUserId,
assocPartnerAccountId: assocPartnerAccountId ?? undefined,
remark: dto.remark?.trim() || null,
channelOwners: channelOwnerIds.length
? { create: channelOwnerIds.map((partnerAccountId) => ({ partnerAccountId })) }
: undefined,
},
});
@@ -339,22 +408,29 @@ export class PromoCodeService {
async update(id: bigint, dto: UpdatePromoCodeDto) {
await this.detail(id);
const data: {
name?: string;
scene?: PromoCodeScene;
remark?: string | null;
ownerUserId?: bigint | null;
} = {};
const data: Prisma.CommonPromoCodeUpdateInput = {};
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;
data.ownerUser = { disconnect: true };
} else {
data.ownerUserId = await this.resolveOwnerUserId(dto.ownerUserId);
const ownerUserId = await this.resolveOwnerUserId(dto.ownerUserId);
data.ownerUser = { connect: { id: ownerUserId } };
}
}
if (dto.assocPartnerAccountId !== undefined) {
const assocId = await this.resolvePrimaryPartnerId(dto.assocPartnerAccountId);
data.assocPartner = assocId ? { connect: { id: assocId } } : { disconnect: true };
}
if (dto.channelOwnerPartnerIds !== undefined) {
const channelOwnerIds = await this.resolveChannelOwnerIds(dto.channelOwnerPartnerIds);
data.channelOwners = {
deleteMany: {},
create: channelOwnerIds.map((partnerAccountId) => ({ partnerAccountId })),
};
}
const row = await this.prisma.commonPromoCode.update({
where: { id },
@@ -464,6 +540,10 @@ export class PromoCodeService {
}
sourceApplied = await this.applyPromoSourceToUser(userId, promo, meta);
if (promo.assocPartnerAccountId) {
await this.partnerCity.tryBindIfUnbound(userId, promo.assocPartnerAccountId);
}
}
if (shouldCountScan) {
@@ -783,4 +863,43 @@ export class PromoCodeService {
pageSize,
});
}
/** 合伙人 H5:仅扫码/归因/订单三项汇总,不含用户或订单明细 */
async listForPartner(partnerAccountId: bigint) {
const where: Prisma.CommonPromoCodeWhereInput = {
OR: [
{ assocPartnerAccountId: partnerAccountId },
{ channelOwners: { some: { partnerAccountId } } },
],
};
const rows = await this.prisma.commonPromoCode.findMany({
where,
select: { id: true, name: true, code: true, status: true, scanCount: true },
orderBy: { createdAt: 'desc' },
take: 100,
});
const ids = rows.map((r) => r.id);
const [attributionRows, orderMap] = await Promise.all([
ids.length
? this.prisma.userPromoAttribution.groupBy({
by: ['promoCodeId'],
where: { promoCodeId: { in: ids } },
_count: { _all: true },
})
: Promise.resolve([] as Array<{ promoCodeId: bigint; _count: { _all: number } }>),
this.completedOrderCounts(ids),
]);
const attributionMap = new Map(attributionRows.map((r) => [r.promoCodeId.toString(), r._count._all]));
return serializeBigInt({
items: rows.map((row) => ({
id: row.id,
name: row.name,
code: row.code,
status: row.status,
scanCount: row.scanCount,
attributionCount: attributionMap.get(row.id.toString()) ?? 0,
orderCount: orderMap.get(row.id.toString()) ?? 0,
})),
});
}
}
@@ -4,7 +4,10 @@ import { GeoModule } from '../../common/geo/geo.module';
import { IntegrationsModule } from '../../integrations/integrations.module';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
import { CityScopeModule } from '../city-scope/city-scope.module';
import { AdminPromoCodeController } from './admin-promo-code.controller';
import { PartnerPromoCodeController } from './partner-promo-code.controller';
import { PromoCodeService } from './promo-code.service';
import { PromoMetricLogService } from './promo-metric-log.service';
@@ -12,13 +15,14 @@ import { PromoMetricLogService } from './promo-metric-log.service';
imports: [
GeoModule,
IntegrationsModule,
CityScopeModule,
JwtModule.register({
secret: process.env.JWT_SECRET || 'dukang-prev1-dev-secret',
signOptions: { expiresIn: process.env.JWT_EXPIRES_IN || '7d' },
}),
],
controllers: [AdminPromoCodeController],
providers: [PromoCodeService, PromoMetricLogService, JwtAuthGuard, HqAuthGuard],
controllers: [AdminPromoCodeController, PartnerPromoCodeController],
providers: [PromoCodeService, PromoMetricLogService, JwtAuthGuard, HqAuthGuard, PartnerPrimaryGuard],
exports: [PromoCodeService],
})
export class PromoModule {}
@@ -131,6 +131,10 @@ export class PartnerAssocService {
};
}
async tryBindIfUnbound(userId: bigint, partnerAccountId: bigint) {
return this.partnerCityService.tryBindIfUnbound(userId, partnerAccountId);
}
async touchScan(input: { scene?: string; partnerId?: string; countScan?: boolean }) {
const { primary, sub } = await this.resolveAssocTarget(input);
const shouldCountScan = input.countScan !== false;