feat(analytics): persona logging upgrade and Sentry system config

Add client-logging SDK, expanded event taxonomy, API observability, admin domain events UI, and move SENTRY_DSN to HQ system settings with @sentry/node bootstrap after config preload.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 22:30:02 +08:00
parent ab10431001
commit 89fd333702
91 changed files with 1805 additions and 136 deletions
@@ -1,31 +1,74 @@
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { BadRequestException, Body, Controller, Post, UseGuards } from '@nestjs/common';
import { AnalyticsService } from './analytics.service';
import { PromoCodeService } from '../promo/promo-code.service';
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { PromoTouchDto } from './dto/promo.dto';
import { ActorType } from '@dukang/shared-types';
import {
TrackPartnerEventsDto,
TrackStoreEventsDto,
TrackUserEventsDto,
} from './dto/track-events.dto';
import { ActorType, ClientApp } from '@dukang/shared-types';
@Controller('analytics')
export class AnalyticsController {
constructor(private readonly analyticsService: AnalyticsService) {}
@Post('events')
@UseGuards(OptionalJwtAuthGuard)
track(@CurrentUser() user: AuthUser | undefined, @Body() body: TrackUserEventsDto) {
const userId = user?.actorType === ActorType.USER ? user.actorId : undefined;
const clientApp = user?.clientApp ?? body.clientApp ?? ClientApp.USER_H5;
return this.analyticsService.trackBatchOptional(userId, clientApp, body.events, body.sessionId);
}
@Post('store-events')
@UseGuards(JwtAuthGuard)
track(@CurrentUser() user: AuthUser, @Body() body: { events: Array<{ eventName: string; params?: Record<string, unknown> }> }) {
return this.analyticsService.trackBatch(user.actorId, user.clientApp, body.events);
trackStore(@CurrentUser() user: AuthUser, @Body() body: TrackStoreEventsDto) {
if (user.actorType !== ActorType.STORE) {
throw new BadRequestException('仅门店端可上报');
}
const storeId = body.storeId ? BigInt(body.storeId) : user.storeId;
if (storeId == null) throw new BadRequestException('缺少门店信息');
return this.analyticsService.trackStoreBatch(
user.actorId,
storeId,
user.clientApp,
body.events,
body.sessionId,
);
}
@Post('partner-events')
@UseGuards(JwtAuthGuard)
trackPartner(@CurrentUser() user: AuthUser, @Body() body: TrackPartnerEventsDto) {
if (user.actorType !== ActorType.PARTNER) {
throw new BadRequestException('仅合伙人端可上报');
}
return this.analyticsService.trackPartnerBatch(
user.actorId,
user.actorId,
user.clientApp,
body.events,
body.sessionId,
);
}
}
@Controller('promo')
export class PromoController {
constructor(private readonly promoCodeService: PromoCodeService) {}
constructor(
private readonly promoCodeService: PromoCodeService,
private readonly analyticsService: AnalyticsService,
) {}
@Post('touch')
@UseGuards(OptionalJwtAuthGuard)
touch(@CurrentUser() user: AuthUser | undefined, @Body() dto: PromoTouchDto) {
async touch(@CurrentUser() user: AuthUser | undefined, @Body() dto: PromoTouchDto) {
const userId = user?.actorType === ActorType.USER ? user.actorId : undefined;
return this.promoCodeService.touch(
const result = await this.promoCodeService.touch(
{
promoCode: dto.promoCode,
qrcodeId: dto.qrcodeId,
@@ -34,5 +77,29 @@ export class PromoController {
},
userId,
);
void this.analyticsService.trackBatchOptional(
userId,
user?.clientApp ?? ClientApp.USER_H5,
[
{
eventName: 'promo_touch',
params: {
sessionId: dto.sessionId,
promoCode: result.promoCode,
promoCodeId: result.promoCodeId,
channelName: result.channelName,
attributed: result.attributed,
sourceApplied: result.sourceApplied,
scanCounted: result.scanCounted,
sourceType: 'PROMO_CODE',
sourceRefId: result.promoCodeId,
},
},
],
dto.sessionId,
);
return result;
}
}
}
@@ -3,10 +3,13 @@ import { IamModule } from '../iam/iam.module';
import { PromoModule } from '../promo/promo.module';
import { AnalyticsController, PromoController } from './analytics.controller';
import { AnalyticsService } from './analytics.service';
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
@Module({
imports: [forwardRef(() => IamModule), PromoModule], controllers: [AnalyticsController, PromoController],
providers: [AnalyticsService],
imports: [forwardRef(() => IamModule), PromoModule],
controllers: [AnalyticsController, PromoController],
providers: [AnalyticsService, OptionalJwtAuthGuard, JwtAuthGuard],
exports: [AnalyticsService],
})
export class AnalyticsModule {}
@@ -1,13 +1,15 @@
import { Injectable } from '@nestjs/common';
import type { ClientApp } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
export type TrackEventInput = {
eventName: string;
pagePath?: string;
refType?: string;
refId?: bigint;
sessionId?: string;
sourceType?: string;
sourceRefId?: bigint;
extraJson?: Record<string, unknown>;
};
@@ -17,35 +19,80 @@ export type TrackStoreEventInput = TrackEventInput & {
};
export type TrackPartnerEventInput = TrackEventInput & {
/** 主账号 ID,用于合伙人维度聚合 */
partnerAccountId: bigint;
};
type RawEvent = { eventName: string; params?: Record<string, unknown> };
@Injectable()
export class AnalyticsService {
constructor(private readonly prisma: PrismaService) {}
async trackBatch(
userId: bigint,
async trackBatchOptional(
userId: bigint | null | undefined,
clientApp: string,
events: Array<{ eventName: string; params?: Record<string, unknown> }>,
events: RawEvent[],
sessionId?: string,
) {
if (!events?.length) return { count: 0 };
await this.prisma.logUserAnalytics.createMany({
data: events.map((e) => this.toRow(userId, clientApp, {
eventName: e.eventName,
extraJson: e.params,
refType: typeof e.params?.refType === 'string' ? e.params.refType : undefined,
refId: e.params?.refId != null ? BigInt(String(e.params.refId)) : undefined,
pagePath: typeof e.params?.pagePath === 'string' ? e.params.pagePath : undefined,
})),
data: events.map((e) =>
this.toUserRow(userId ?? null, clientApp, {
eventName: e.eventName,
...this.parseParams(e.params, sessionId),
}),
),
});
return { count: events.length };
}
async trackBatch(userId: bigint, clientApp: string, events: RawEvent[], sessionId?: string) {
return this.trackBatchOptional(userId, clientApp, events, sessionId);
}
async trackStoreBatch(
storeAccountId: bigint | undefined,
storeId: bigint,
clientApp: ClientApp | string,
events: RawEvent[],
sessionId?: string,
) {
if (!events?.length) return { count: 0 };
await this.prisma.logStoreAnalytics.createMany({
data: events.map((e) =>
this.toStoreRow(storeAccountId, clientApp, {
storeId,
eventName: e.eventName,
...this.parseParams(e.params, sessionId),
}),
),
});
return { count: events.length };
}
async trackPartnerBatch(
actorAccountId: bigint | undefined,
partnerAccountId: bigint,
clientApp: ClientApp | string,
events: RawEvent[],
sessionId?: string,
) {
if (!events?.length) return { count: 0 };
await this.prisma.logPartnerAnalytics.createMany({
data: events.map((e) =>
this.toPartnerRow(actorAccountId, clientApp, {
partnerAccountId,
eventName: e.eventName,
...this.parseParams(e.params, sessionId),
}),
),
});
return { count: events.length };
}
async trackOne(userId: bigint, clientApp: ClientApp | string, event: TrackEventInput) {
await this.prisma.logUserAnalytics.create({
data: this.toRow(userId, clientApp, event),
data: this.toUserRow(userId, clientApp, event),
});
}
@@ -53,7 +100,24 @@ export class AnalyticsService {
void this.trackOne(userId, clientApp, event).catch(() => {});
}
async trackStoreOne(storeAccountId: bigint | undefined, clientApp: ClientApp | string, event: TrackStoreEventInput) {
trackOneSafeOptional(
userId: bigint | null | undefined,
clientApp: ClientApp | string,
event: TrackEventInput,
) {
void this.trackBatchOptional(
userId,
clientApp,
[{ eventName: event.eventName, params: this.eventToParams(event) }],
event.sessionId,
).catch(() => {});
}
async trackStoreOne(
storeAccountId: bigint | undefined,
clientApp: ClientApp | string,
event: TrackStoreEventInput,
) {
if (event.storeId == null) return;
await this.prisma.logStoreAnalytics.create({
data: this.toStoreRow(storeAccountId, clientApp, event),
@@ -83,14 +147,61 @@ export class AnalyticsService {
void this.trackPartnerOne(actorAccountId, clientApp, event).catch(() => {});
}
private toRow(userId: bigint, clientApp: ClientApp | string, event: TrackEventInput) {
private parseParams(
params?: Record<string, unknown>,
fallbackSessionId?: string,
): Omit<TrackEventInput, 'eventName'> {
const p = params ?? {};
const sessionId =
typeof p.sessionId === 'string' ? p.sessionId.slice(0, 64) : fallbackSessionId?.slice(0, 64);
const pagePath = typeof p.pagePath === 'string' ? p.pagePath.slice(0, 128) : undefined;
const refType = typeof p.refType === 'string' ? p.refType : undefined;
const refId = p.refId != null ? BigInt(String(p.refId)) : undefined;
const sourceType = typeof p.sourceType === 'string' ? p.sourceType.slice(0, 32) : undefined;
const sourceRefId = p.sourceRefId != null ? BigInt(String(p.sourceRefId)) : undefined;
const {
sessionId: _s,
pagePath: _p,
refType: _rt,
refId: _ri,
sourceType: _st,
sourceRefId: _sr,
...rest
} = p;
return {
sessionId,
pagePath,
refType,
refId,
sourceType,
sourceRefId,
extraJson: Object.keys(rest).length ? rest : undefined,
};
}
private eventToParams(event: TrackEventInput): Record<string, unknown> {
return {
...(event.pagePath ? { pagePath: event.pagePath } : {}),
...(event.refType ? { refType: event.refType } : {}),
...(event.refId != null ? { refId: event.refId.toString() } : {}),
...(event.sessionId ? { sessionId: event.sessionId } : {}),
...(event.sourceType ? { sourceType: event.sourceType } : {}),
...(event.sourceRefId != null ? { sourceRefId: event.sourceRefId.toString() } : {}),
...(event.extraJson ?? {}),
};
}
private toUserRow(userId: bigint | null, clientApp: ClientApp | string, event: TrackEventInput) {
return {
userId,
sessionId: event.sessionId,
eventName: event.eventName,
clientApp: clientApp as ClientApp,
pagePath: event.pagePath,
refType: event.refType,
refId: event.refId,
sourceType: event.sourceType,
sourceRefId: event.sourceRefId,
extraJson: event.extraJson as never,
};
}
@@ -126,4 +237,3 @@ export class AnalyticsService {
};
}
}
@@ -27,4 +27,8 @@ export class PromoTouchDto {
})
@IsBoolean()
countScan?: boolean;
@IsOptional()
@IsString()
sessionId?: string;
}
@@ -0,0 +1,56 @@
import { IsArray, IsOptional, IsString, MaxLength, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class AnalyticsEventDto {
@IsString()
@MaxLength(64)
eventName!: string;
@IsOptional()
params?: Record<string, unknown>;
}
export class TrackUserEventsDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => AnalyticsEventDto)
events!: AnalyticsEventDto[];
@IsOptional()
@IsString()
@MaxLength(64)
sessionId?: string;
@IsOptional()
@IsString()
@MaxLength(32)
clientApp?: string;
}
export class TrackStoreEventsDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => AnalyticsEventDto)
events!: AnalyticsEventDto[];
@IsOptional()
@IsString()
@MaxLength(64)
sessionId?: string;
@IsOptional()
@IsString()
storeId?: string;
}
export class TrackPartnerEventsDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => AnalyticsEventDto)
events!: AnalyticsEventDto[];
@IsOptional()
@IsString()
@MaxLength(64)
sessionId?: string;
}
@@ -1476,6 +1476,17 @@ export class AuthService {
data,
include: { avatar: true },
});
this.analyticsService.trackOneSafe(userId, 'USER_MINI', {
eventName: 'profile_update',
refType: 'USER',
refId: userId,
extraJson: {
fields: [
...(data.nickname ? ['nickname'] : []),
...(data.avatarResourceId ? ['avatar'] : []),
],
},
});
return this.formatUserProfile(updated);
}
@@ -0,0 +1,36 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import type { EventType } from '@prisma/client';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { AdminDomainEventsService } from './admin-domain-events.service';
@Controller('admin/logs/domain-events')
@UseGuards(HqAuthGuard)
export class AdminDomainEventsController {
constructor(private readonly service: AdminDomainEventsService) {}
@Get()
list(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('eventType') eventType?: EventType,
@Query('refType') refType?: string,
@Query('refId') refId?: string,
@Query('from') from?: string,
@Query('to') to?: string,
) {
return this.service.list({
page: page ? Number(page) : undefined,
pageSize: pageSize ? Number(pageSize) : undefined,
eventType,
refType,
refId,
from,
to,
});
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(BigInt(id));
}
}
@@ -0,0 +1,59 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import type { EventType, Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
const DOMAIN_EVENT_TYPES: EventType[] = [
'ORDER_STATUS',
'BENEFIT_LEDGER',
'STORE_AUDIT',
'TICKET_COLLAB',
'PROMO_TOUCH',
];
@Injectable()
export class AdminDomainEventsService {
constructor(private readonly prisma: PrismaService) {}
async list(query: {
page?: number;
pageSize?: number;
eventType?: EventType;
refType?: string;
refId?: string;
from?: string;
to?: string;
}) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.CommonEventWhereInput = {
eventType: query.eventType ?? { in: DOMAIN_EVENT_TYPES },
};
if (query.refType) where.refType = query.refType;
if (query.refId) where.refId = BigInt(query.refId);
if (query.from || query.to) {
where.createdAt = {};
if (query.from) where.createdAt.gte = new Date(query.from);
if (query.to) where.createdAt.lte = new Date(query.to);
}
const [items, total] = await Promise.all([
this.prisma.commonEvent.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.commonEvent.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
async detail(id: bigint) {
const row = await this.prisma.commonEvent.findUnique({ where: { id } });
if (!row || !DOMAIN_EVENT_TYPES.includes(row.eventType)) {
throw new NotFoundException('领域事件不存在');
}
return serializeBigInt(row);
}
}
@@ -7,6 +7,7 @@ import { mapStoreCompat } from '../../common/compat/v31-compat';
import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto';
import { PartnerCityService } from '../city-scope/partner-city.service';
import { StoreCategoryService } from '../store/store-category.service';
import { AnalyticsService } from '../analytics/analytics.service';
import type {
CreateStoreAccountDto,
CreateStoreDto,
@@ -49,6 +50,7 @@ export class AdminStoresService {
private readonly prisma: PrismaService,
private readonly partnerCityService: PartnerCityService,
private readonly storeCategoryService: StoreCategoryService,
private readonly analyticsService: AnalyticsService,
) {}
async listStores(query: AdminStoresQueryDto) {
@@ -205,6 +207,19 @@ export class AdminStoresService {
},
});
if (updated.partnerAccountId) {
this.analyticsService.trackPartnerOneSafe(undefined, 'HQ_WEB', {
partnerAccountId: updated.partnerAccountId,
eventName: dto.approved ? 'partner_store_audit_approved' : 'partner_store_audit_rejected',
refType: 'STORE',
refId: id,
extraJson: {
storeId: id.toString(),
remark: dto.remark?.trim() || null,
},
});
}
return serializeBigInt({
...updated,
notifyHint: dto.approved
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { CityScopeModule } from '../city-scope/city-scope.module';
import { IamModule } from '../iam/iam.module';
import { TradeModule } from '../trade/trade.module';
import { AnalyticsModule } from '../analytics/analytics.module';
import { FulfillmentModule } from '../fulfillment/fulfillment.module';
import { StoreModule } from '../store/store.module';
import { AdminDashboardController } from './admin-dashboard.controller';
@@ -71,9 +72,11 @@ import { AdminLlmConfigsService } from './admin-llm-configs.service';
import { AdminKnowledgeBasesController } from './admin-knowledge-bases.controller';
import { AdminKnowledgeBasesService } from './admin-knowledge-bases.service';
import { AdminFulfillmentProvidersController } from './admin-fulfillment-providers.controller';
import { AdminDomainEventsController } from './admin-domain-events.controller';
import { AdminDomainEventsService } from './admin-domain-events.service';
@Module({
imports: [CityScopeModule, IamModule, TradeModule, FulfillmentModule, BenefitModule, CommonModule, IntegrationsModule, WecomModule, LlmModule, RedeemModule, StoreModule],
imports: [CityScopeModule, IamModule, TradeModule, AnalyticsModule, FulfillmentModule, BenefitModule, CommonModule, IntegrationsModule, WecomModule, LlmModule, RedeemModule, StoreModule],
controllers: [
AdminDashboardController,
AdminDeployController,
@@ -100,6 +103,7 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide
AdminStoreLogsController,
AdminPartnerLogsController,
AdminHqLogsController,
AdminDomainEventsController,
AdminOssLogsController,
AdminTicketsController,
AdminSupportTicketsController,
@@ -133,6 +137,7 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide
AdminStoreLogsService,
AdminPartnerLogsService,
AdminHqLogsService,
AdminDomainEventsService,
AdminOssLogsService,
AdminTicketsService,
AdminXiaofeixiaService,
@@ -519,6 +519,17 @@ export class RedeemService {
REDEEM_TOKEN_TTL_SECONDS,
);
this.analyticsService.trackOneSafe(userId, 'USER_H5', {
eventName: 'benefit_redeem_start',
refType: body.storeId ? 'STORE' : 'BENEFIT_COUPON',
refId: body.storeId ? BigInt(body.storeId) : primaryCouponId,
extraJson: {
amount: body.amount,
storeId: body.storeId ?? null,
couponId: primaryCouponId.toString(),
},
});
return { token, expireAt, amount: body.amount, boundStoreId: body.storeId ?? null };
}
@@ -29,6 +29,11 @@ export class SettlementController {
return this.settlementService.listPartnerBills(user.actorId);
}
@Get('bills/:id')
billDetail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.settlementService.getPartnerBill(user.actorId, BigInt(id));
}
@Post('bills/batch-confirm')
batchConfirm(@CurrentUser() user: AuthUser, @Body() body: { ids: string[] }) {
return this.settlementService.batchPartnerConfirmBills(user.actorId, body.ids ?? []);
@@ -1169,6 +1169,22 @@ export class SettlementService {
return serializeBigInt(bills);
}
async getPartnerBill(partnerAccountId: bigint, billId: bigint) {
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const bill = await this.prisma.partnerBill.findFirst({
where: { id: billId, partnerAccountId: primary.id },
});
if (!bill) throw new NotFoundException('账单不存在');
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
partnerAccountId: primary.id,
eventName: 'partner_bill_detail_view',
refType: 'PARTNER_BILL',
refId: billId,
extraJson: { billId: billId.toString(), status: bill.status },
});
return serializeBigInt(bill);
}
async listAdminPartnerBills(query: {
page?: number;
pageSize?: number;
@@ -310,6 +310,12 @@ export class TradeService {
orderNo: order.orderNo,
userId,
});
this.analyticsService.trackOneSafe(userId, clientApp ?? 'USER_H5', {
eventName: 'pay_fail',
refType: 'ORDER',
refId: orderId,
extraJson: { orderId: orderId.toString(), failReason: WECHAT_AUTH_REQUIRED, stage: 'auth' },
});
throw new BadRequestException(WECHAT_AUTH_REQUIRED);
}
const payPlatform = clientApp === ClientApp.USER_MINI ? 'mini' : 'h5';
@@ -317,10 +323,17 @@ export class TradeService {
try {
payResult = await this.payProvider.payOrder(orderId, openId, payPlatform);
} catch (e) {
this.payRedeemAnomaly.onPayFail(e instanceof Error ? e.message : '拉起支付失败', {
const failReason = e instanceof Error ? e.message : '拉起支付失败';
this.payRedeemAnomaly.onPayFail(failReason, {
orderNo: order.orderNo,
userId,
});
this.analyticsService.trackOneSafe(userId, clientApp ?? 'USER_H5', {
eventName: 'pay_fail',
refType: 'ORDER',
refId: orderId,
extraJson: { orderId: orderId.toString(), failReason, stage: 'prepay' },
});
throw e;
}