v4.0.20版本迭代推广码增加渠道负责人
This commit is contained in:
@@ -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='推广码渠道负责人(主合伙人)';
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user