合伙人端登录验证调整还有日志落地

This commit is contained in:
2026-07-07 14:34:02 +08:00
parent af989c9d33
commit 2e61caa49c
23 changed files with 1018 additions and 47 deletions
+17
View File
@@ -889,3 +889,20 @@ model LogStoreAnalytics {
@@index([eventName, createdAt])
@@map("log_store_analytics")
}
model LogPartnerAnalytics {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
partnerAccountId BigInt? @map("partner_account_id") @db.UnsignedBigInt
partnerId BigInt @map("partner_id") @db.UnsignedBigInt
eventName String @map("event_name") @db.VarChar(64)
clientApp ClientApp? @map("client_app")
refType String? @map("ref_type") @db.VarChar(32)
refId BigInt? @map("ref_id") @db.UnsignedBigInt
extraJson Json? @map("extra_json")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
@@index([partnerId, createdAt])
@@index([partnerAccountId, createdAt])
@@index([eventName, createdAt])
@@map("log_partner_analytics")
}
@@ -16,6 +16,11 @@ export type TrackStoreEventInput = TrackEventInput & {
storeId: bigint;
};
export type TrackPartnerEventInput = TrackEventInput & {
partnerAccountId?: bigint;
partnerId: bigint;
};
@Injectable()
export class AnalyticsService {
constructor(private readonly prisma: PrismaService) {}
@@ -58,6 +63,24 @@ export class AnalyticsService {
void this.trackStoreOne(storeAccountId, clientApp, event).catch(() => {});
}
async trackPartnerOne(
partnerAccountId: bigint | undefined,
clientApp: ClientApp | string,
event: TrackPartnerEventInput,
) {
await this.prisma.logPartnerAnalytics.create({
data: this.toPartnerRow(partnerAccountId, clientApp, event),
});
}
trackPartnerOneSafe(
partnerAccountId: bigint | undefined,
clientApp: ClientApp | string,
event: TrackPartnerEventInput,
) {
void this.trackPartnerOne(partnerAccountId, clientApp, event).catch(() => {});
}
private toRow(userId: bigint, clientApp: ClientApp | string, event: TrackEventInput) {
return {
userId,
@@ -85,6 +108,23 @@ export class AnalyticsService {
extraJson: event.extraJson as never,
};
}
private toPartnerRow(
partnerAccountId: bigint | undefined,
clientApp: ClientApp | string,
event: TrackPartnerEventInput,
) {
return {
partnerAccountId,
partnerId: event.partnerId,
eventName: event.eventName,
clientApp: clientApp as ClientApp,
refType: event.refType,
refId: event.refId,
extraJson: event.extraJson as never,
};
}
/** 扫码归因:始终累加 scan_count;已登录用户首次写入 user_promo_attribution */
async touchPromo(promoCode: string, userId?: bigint) {
const code = promoCode.trim().toUpperCase();
@@ -6,6 +6,7 @@ import {
BindWechatDto,
BindWechatPhoneDto,
BootstrapSessionDto,
CheckPartnerPhoneDto,
LoginSmsDto,
LoginWechatDto,
RefreshTokenDto,
@@ -142,6 +143,11 @@ export class ShopAuthController {
export class PartnerAuthController {
constructor(private readonly authService: AuthService) {}
@Post('phone/check')
checkPhone(@Body() dto: CheckPartnerPhoneDto) {
return this.authService.checkPartnerPhone(dto.phone);
}
@Post('sms/send')
sendSms(@Body() dto: SendSmsDto) {
return this.authService.sendSms(dto.phone, dto.scene, { clientApp: ClientApp.PARTNER_H5 });
@@ -167,6 +167,44 @@ export class AuthService {
});
}
private trackPartnerEvent(
partnerAccountId: bigint | undefined,
partnerId: bigint,
clientApp: ClientApp | string,
eventName: string,
extraJson?: Record<string, unknown>,
ref?: { refType?: string; refId?: bigint },
) {
this.analyticsService.trackPartnerOneSafe(partnerAccountId, clientApp, {
partnerId,
eventName,
refType: ref?.refType,
refId: ref?.refId,
extraJson,
});
}
private async assertPartnerAccountByPhone(phone: string) {
const account = await this.prisma.partnerAccount.findUnique({
where: { phone },
include: { partner: true },
});
if (!account) throw new BadRequestException('未找到合伙人账号');
if (account.status !== 'ACTIVE') throw new BadRequestException('合伙人账号已停用');
return account;
}
async checkPartnerPhone(phone: string) {
const normalizedPhone = this.assertMobilePhone(phone);
const account = await this.assertPartnerAccountByPhone(normalizedPhone);
return {
ok: true,
maskedPhone: this.maskPhone(normalizedPhone),
name: account.name,
companyName: account.partner.companyName,
};
}
private async assertSmsSendAllowed(phone: string, scene: SmsScene) {
if (scene === SmsScene.STORE_LOGIN) {
const account = await this.prisma.storeAccount.findUnique({ where: { phone } });
@@ -177,6 +215,10 @@ export class AuthService {
if (scene === SmsScene.STORE_ACCOUNT_OPEN) {
const existing = await this.prisma.storeAccount.findUnique({ where: { phone } });
if (existing) throw new BadRequestException('该手机号已绑定门店');
return;
}
if (scene === SmsScene.PARTNER_LOGIN || scene === SmsScene.PARTNER_STAFF_ADD) {
await this.assertPartnerAccountByPhone(phone);
}
}
@@ -252,6 +294,28 @@ export class AuthService {
});
}
}
if (
(scene === SmsScene.PARTNER_LOGIN || scene === SmsScene.PARTNER_STAFF_ADD) &&
actorRef?.refType === 'PARTNER'
) {
const partnerAccount = await this.prisma.partnerAccount.findUnique({
where: { id: actorRef.refId },
select: { id: true, partnerId: true },
});
if (partnerAccount) {
this.trackPartnerEvent(
partnerAccount.id,
partnerAccount.partnerId,
clientApp,
'partner_sms_send',
{
scene,
phone: this.maskPhone(normalizedPhone),
status: 'success',
},
);
}
}
} catch (err) {
if (err instanceof BadRequestException) throw err;
const message = err instanceof Error ? err.message : '短信发送失败';
@@ -504,16 +568,34 @@ export class AuthService {
async loginPartner(phone: string, code: string, clientApp: ClientApp) {
const normalizedPhone = this.assertMobilePhone(phone);
await this.smsProvider.verify(normalizedPhone, code, SmsScene.PARTNER_LOGIN);
try {
await this.smsProvider.verify(normalizedPhone, code, SmsScene.PARTNER_LOGIN);
} catch (err) {
const account = await this.prisma.partnerAccount.findUnique({ where: { phone: normalizedPhone } });
if (account) {
this.trackPartnerEvent(account.id, account.partnerId, clientApp, 'partner_sms_verify_fail', {
phone: this.maskPhone(normalizedPhone),
reason: err instanceof BadRequestException ? err.message : '验证码错误',
});
}
throw err;
}
const account = await this.prisma.partnerAccount.findUnique({
where: { phone: normalizedPhone },
include: { partner: true },
});
if (!account) throw new BadRequestException('合伙人账号不存在');
if (!account) throw new BadRequestException('未找到合伙人账号');
if (account.status !== 'ACTIVE') throw new BadRequestException('合伙人账号已停用');
await this.prisma.partnerAccount.update({
where: { id: account.id },
data: { lastLoginAt: new Date() },
});
this.trackPartnerEvent(account.id, account.partnerId, clientApp, 'partner_sms_login', {
phone: this.maskPhone(normalizedPhone),
});
this.trackPartnerEvent(account.id, account.partnerId, clientApp, 'partner_login_success', {
method: 'sms',
});
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, {
id: account.id.toString(),
partnerId: account.partnerId.toString(),
@@ -1012,6 +1094,8 @@ export class AuthService {
include: { partner: true },
});
this.trackPartnerEvent(updated.id, updated.partnerId, clientApp, 'partner_wechat_bind', { platform });
return this.issueToken('PARTNER', updated.id, clientApp, false, undefined, undefined, {
id: updated.id.toString(),
partnerId: updated.partnerId.toString(),
@@ -1057,6 +1141,11 @@ export class AuthService {
include: { partner: true },
});
this.trackPartnerEvent(account.id, account.partnerId, clientApp, 'partner_wechat_login', { platform });
this.trackPartnerEvent(account.id, account.partnerId, clientApp, 'partner_login_success', {
method: 'wechat',
});
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, {
id: account.id.toString(),
partnerId: account.partnerId.toString(),
@@ -93,3 +93,9 @@ export class BindWechatDto {
@IsOptional()
platform?: 'h5' | 'mini';
}
export class CheckPartnerPhoneDto {
@IsString()
@IsNotEmpty()
phone: string;
}
@@ -0,0 +1,20 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { AdminPartnerLogsService } from './admin-partner-logs.service';
import { AdminPartnerLogsQueryDto } from './dto/admin-query.dto';
@Controller('admin/logs/partners')
@UseGuards(HqAuthGuard)
export class AdminPartnerLogsController {
constructor(private readonly service: AdminPartnerLogsService) {}
@Get()
list(@Query() query: AdminPartnerLogsQueryDto) {
return this.service.list(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(id);
}
}
@@ -0,0 +1,160 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
eventNamesForPartnerLogCategory,
resolvePartnerLogCategory,
} from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { AdminPartnerLogsQueryDto } from './dto/admin-query.dto';
@Injectable()
export class AdminPartnerLogsService {
constructor(private readonly prisma: PrismaService) {}
async list(query: AdminPartnerLogsQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const partnerIds = await this.resolvePartnerIds(query);
if (partnerIds && partnerIds.length === 0) {
return { items: [], total: 0, page, pageSize };
}
const where = this.buildWhere(query, partnerIds);
const [rows, total] = await Promise.all([
this.prisma.logPartnerAnalytics.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.logPartnerAnalytics.count({ where }),
]);
const items = await this.enrichRows(rows);
return serializeBigInt({ items, total, page, pageSize });
}
async detail(id: string) {
const row = await this.prisma.logPartnerAnalytics.findUnique({ where: { id: BigInt(id) } });
if (!row) throw new NotFoundException('日志不存在');
const [item] = await this.enrichRows([row]);
return serializeBigInt(item);
}
private buildWhere(
query: AdminPartnerLogsQueryDto,
partnerIds?: bigint[],
): Prisma.LogPartnerAnalyticsWhereInput {
const where: Prisma.LogPartnerAnalyticsWhereInput = {};
if (partnerIds) where.partnerId = { in: partnerIds };
if (query.partnerAccountId) where.partnerAccountId = BigInt(query.partnerAccountId);
const categoryEvents = query.eventName
? [query.eventName]
: query.category
? eventNamesForPartnerLogCategory(query.category)
: undefined;
if (categoryEvents?.length) where.eventName = { in: categoryEvents };
if (query.from || query.to) {
where.createdAt = {
...(query.from ? { gte: new Date(query.from) } : {}),
...(query.to ? { lte: new Date(query.to) } : {}),
};
}
return where;
}
private async resolvePartnerIds(query: AdminPartnerLogsQueryDto): Promise<bigint[] | undefined> {
if (query.partnerId) return [BigInt(query.partnerId)];
const partnerWhere: Prisma.PartnerWhereInput = {};
if (query.companyName) partnerWhere.companyName = { contains: query.companyName };
if (query.partnerAccountId || query.phone) {
const accountWhere: Prisma.PartnerAccountWhereInput = {};
if (query.partnerAccountId) accountWhere.id = BigInt(query.partnerAccountId);
if (query.phone) accountWhere.phone = { contains: query.phone };
const accounts = await this.prisma.partnerAccount.findMany({
where: accountWhere,
select: { partnerId: true },
take: 100,
});
if (accounts.length === 0) return [];
const ids = [...new Set(accounts.map((a) => a.partnerId))];
if (query.companyName) {
const partners = await this.prisma.partner.findMany({
where: { id: { in: ids }, ...partnerWhere },
select: { id: true },
});
return partners.map((p) => p.id);
}
return ids;
}
if (query.companyName) {
const partners = await this.prisma.partner.findMany({
where: partnerWhere,
select: { id: true },
take: 100,
});
return partners.map((p) => p.id);
}
return undefined;
}
private async enrichRows(
rows: Array<{
id: bigint;
partnerAccountId: bigint | null;
partnerId: bigint;
eventName: string;
clientApp: string | null;
refType: string | null;
refId: bigint | null;
extraJson: unknown;
createdAt: Date;
}>,
) {
const partnerIds = [...new Set(rows.map((r) => r.partnerId))];
const accountIds = [...new Set(rows.map((r) => r.partnerAccountId).filter((id): id is bigint => id != null))];
const [partners, accounts] = await Promise.all([
partnerIds.length
? this.prisma.partner.findMany({
where: { id: { in: partnerIds } },
select: { id: true, companyName: true },
})
: Promise.resolve([]),
accountIds.length
? this.prisma.partnerAccount.findMany({
where: { id: { in: accountIds } },
select: { id: true, name: true, phone: true },
})
: Promise.resolve([]),
]);
const partnerMap = new Map(partners.map((p) => [p.id.toString(), p] as const));
const accountMap = new Map(accounts.map((a) => [a.id.toString(), a] as const));
return rows.map((row) => {
const partner = partnerMap.get(row.partnerId.toString());
const account = row.partnerAccountId ? accountMap.get(row.partnerAccountId.toString()) : undefined;
return {
id: row.id.toString(),
partnerId: row.partnerId.toString(),
partnerAccountId: row.partnerAccountId?.toString() ?? null,
accountName: account?.name ?? null,
accountPhone: account?.phone ?? null,
companyName: partner?.companyName ?? null,
category: resolvePartnerLogCategory(row.eventName),
eventName: row.eventName,
clientApp: row.clientApp,
refType: row.refType,
refId: row.refId?.toString() ?? null,
extraJson: (row.extraJson as Record<string, unknown> | null) ?? null,
createdAt: row.createdAt,
};
});
}
}
@@ -309,6 +309,40 @@ export class AdminStoreLogsQueryDto extends PaginationQueryDto {
to?: string;
}
export class AdminPartnerLogsQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
partnerId?: string;
@IsOptional()
@IsString()
partnerAccountId?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
companyName?: string;
@IsOptional()
@IsString()
category?: string;
@IsOptional()
@IsString()
eventName?: string;
@IsOptional()
@IsString()
from?: string;
@IsOptional()
@IsString()
to?: string;
}
export class AdminHqLogsQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
@@ -25,6 +25,8 @@ import { AdminUserLogsController } from './admin-user-logs.controller';
import { AdminUserLogsService } from './admin-user-logs.service';
import { AdminStoreLogsController } from './admin-store-logs.controller';
import { AdminStoreLogsService } from './admin-store-logs.service';
import { AdminPartnerLogsController } from './admin-partner-logs.controller';
import { AdminPartnerLogsService } from './admin-partner-logs.service';
import { AdminHqLogsController } from './admin-hq-logs.controller';
import { AdminHqLogsService } from './admin-hq-logs.service';
import { AdminTicketsController } from './admin-tickets.controller';
@@ -63,6 +65,7 @@ import { AdminRedeemDebugService } from './admin-redeem-debug.service';
AdminProductsController,
AdminUserLogsController,
AdminStoreLogsController,
AdminPartnerLogsController,
AdminHqLogsController,
AdminTicketsController,
AdminXiaofeixiaController,
@@ -84,6 +87,7 @@ import { AdminRedeemDebugService } from './admin-redeem-debug.service';
AdminProductsService,
AdminUserLogsService,
AdminStoreLogsService,
AdminPartnerLogsService,
AdminHqLogsService,
AdminTicketsService,
AdminXiaofeixiaService,
@@ -160,6 +160,11 @@ export class SettlementService {
where: { partnerId: account.partnerId },
orderBy: { createdAt: 'desc' },
});
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
partnerId: account.partnerId,
eventName: 'partner_bill_view',
extraJson: { count: bills.length },
});
return serializeBigInt(bills);
}
@@ -192,6 +192,14 @@ export class StoreService {
},
});
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
partnerId: account.partnerId,
eventName: 'partner_store_create',
refType: 'STORE',
refId: store.id,
extraJson: { storeName: store.name, phone: normalizedPhone },
});
return serializeBigInt({ store, audit });
}
@@ -217,14 +225,14 @@ export class StoreService {
data: { status },
include: { coverResource: true },
});
this.analyticsService.trackStoreOneSafe(undefined, 'PARTNER_H5', {
storeId,
eventName: 'store_status_change',
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
partnerId: account.partnerId,
eventName: 'partner_store_status_change',
refType: 'STORE',
refId: storeId,
extraJson: {
status,
previousStatus: store.status,
actor: 'PARTNER',
partnerAccountId: partnerAccountId.toString(),
},
});
return serializeBigInt(mapStoreCompat(updated));
@@ -522,6 +522,25 @@ export class TradeService {
});
if (!order) throw new NotFoundException('订单不存在');
await this.applyStatusTransition(order.id, order.status, targetStatus);
if (targetStatus === 'SHIPPING') {
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
partnerId: account.partnerId,
eventName: 'partner_order_ship',
refType: 'ORDER',
refId: orderId,
extraJson: { fromStatus: order.status },
});
}
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
partnerId: account.partnerId,
eventName: 'partner_delivery_advance',
refType: 'ORDER',
refId: orderId,
extraJson: {
fromStatus: order.status,
targetStatus,
},
});
return this.getPartnerOrder(partnerAccountId, orderId);
}