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:
@@ -112,3 +112,5 @@ SHIP_FROM_LAT=34.757
|
||||
# Admin 概览「发布更新」:调用本机 deploy webhook(与 deploy/auto-release.env 中 SECRET 一致)
|
||||
# DEPLOY_WEBHOOK_URL=http://127.0.0.1:8095/deploy
|
||||
# DEPLOY_WEBHOOK_SECRET=change-me-to-a-long-random-string
|
||||
|
||||
# Sentry DSN 在 HQ「系统设置 → 发布部署」维护(SENTRY_DSN);本地调试可临时写入 .env.local
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"@nestjs/platform-express": "^10.4.0",
|
||||
"@nestjs/schedule": "^6.1.3",
|
||||
"@prisma/client": "^5.18.0",
|
||||
"@sentry/node": "^10.69.0",
|
||||
"@wecom/aibot-node-sdk": "^1.0.7",
|
||||
"ali-oss": "^6.23.0",
|
||||
"bullmq": "^5.12.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { PrismaModule } from './common/prisma/prisma.module';
|
||||
@@ -23,6 +23,8 @@ import { HqOperationModule } from './common/hq-operation/hq-operation.module';
|
||||
import { SystemConfigModule } from './common/system-config/system-config.module';
|
||||
import { CallbacksModule } from './callbacks/callbacks.module';
|
||||
import { WecomModule } from './integrations/wecom/wecom.module';
|
||||
import { LoggingModule } from './common/logging/logging.module';
|
||||
import { RequestIdMiddleware } from './common/logging/request-id.middleware';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -52,8 +54,13 @@ import { WecomModule } from './integrations/wecom/wecom.module';
|
||||
CityScopeModule,
|
||||
CommonModule,
|
||||
HqOperationModule,
|
||||
LoggingModule,
|
||||
CallbacksModule,
|
||||
WecomModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer.apply(RequestIdMiddleware).forRoutes('*');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@ import {
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
import { Observable, catchError, tap, throwError } from 'rxjs';
|
||||
import type { AuthUser } from '../guards/jwt-auth.guard';
|
||||
import type { RequestWithId } from '../logging/request-id.middleware';
|
||||
import { HQ_OPERATION_KEY, type HqOperationMeta } from './hq-operation.decorator';
|
||||
import { HqOperationLogService } from './hq-operation-log.service';
|
||||
|
||||
@@ -61,42 +62,51 @@ export class HqOperationInterceptor implements NestInterceptor {
|
||||
);
|
||||
if (!meta) return next.handle();
|
||||
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const req = context.switchToHttp().getRequest<RequestWithId & { user?: AuthUser }>();
|
||||
const user = req.user as AuthUser | undefined;
|
||||
if (!user || user.actorType !== 'HQ') {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
const writeLog = (status: 'SUCCESS' | 'FAILED', data?: unknown, errorMessage?: string) => {
|
||||
const refId = meta.batch
|
||||
? 0n
|
||||
: pickRefId(meta.refIdParam ? req.params?.[meta.refIdParam] : null)
|
||||
?? pickRefId(
|
||||
meta.refIdField
|
||||
? (data as Record<string, unknown> | null)?.[meta.refIdField]
|
||||
: (data as Record<string, unknown> | null)?.id,
|
||||
)
|
||||
?? 0n;
|
||||
|
||||
const detail: Record<string, unknown> = {
|
||||
method: req.method,
|
||||
path: req.originalUrl ?? req.url,
|
||||
requestId: req.requestId,
|
||||
};
|
||||
if (meta.includeBody && req.body) {
|
||||
detail.requestBody = sanitizeBody(req.body);
|
||||
}
|
||||
if (status === 'SUCCESS' && meta.includeResponse !== false && data != null) {
|
||||
detail.response = summarizeResponse(data);
|
||||
}
|
||||
if (errorMessage) detail.error = errorMessage;
|
||||
|
||||
this.logService.logSafe({
|
||||
hqAccountId: user.actorId,
|
||||
action: meta.action,
|
||||
refType: meta.refType,
|
||||
refId,
|
||||
status,
|
||||
detail,
|
||||
});
|
||||
};
|
||||
|
||||
return next.handle().pipe(
|
||||
tap((data) => {
|
||||
const refId = meta.batch
|
||||
? 0n
|
||||
: pickRefId(meta.refIdParam ? req.params?.[meta.refIdParam] : null)
|
||||
?? pickRefId(
|
||||
meta.refIdField
|
||||
? (data as Record<string, unknown> | null)?.[meta.refIdField]
|
||||
: (data as Record<string, unknown> | null)?.id,
|
||||
)
|
||||
?? 0n;
|
||||
|
||||
const detail: Record<string, unknown> = {
|
||||
method: req.method,
|
||||
path: req.originalUrl ?? req.url,
|
||||
};
|
||||
if (meta.includeBody && req.body) {
|
||||
detail.requestBody = sanitizeBody(req.body);
|
||||
}
|
||||
if (meta.includeResponse !== false && data != null) {
|
||||
detail.response = summarizeResponse(data);
|
||||
}
|
||||
|
||||
this.logService.logSafe({
|
||||
hqAccountId: user.actorId,
|
||||
action: meta.action,
|
||||
refType: meta.refType,
|
||||
refId,
|
||||
detail,
|
||||
});
|
||||
tap((data) => writeLog('SUCCESS', data)),
|
||||
catchError((err: { message?: string }) => {
|
||||
writeLog('FAILED', undefined, err?.message ?? '操作失败');
|
||||
return throwError(() => err);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
Logger,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
import type { Response } from 'express';
|
||||
import type { AuthUser } from '../guards/jwt-auth.guard';
|
||||
import type { RequestWithId } from './request-id.middleware';
|
||||
|
||||
@Injectable()
|
||||
export class LoggingInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger('HTTP');
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const started = Date.now();
|
||||
const http = context.switchToHttp();
|
||||
const req = http.getRequest<RequestWithId & { user?: AuthUser }>();
|
||||
const res = http.getResponse<Response>();
|
||||
|
||||
return next.handle().pipe(
|
||||
tap({
|
||||
next: () => this.logLine(req, res.statusCode, Date.now() - started),
|
||||
error: (err: { status?: number; message?: string }) => {
|
||||
const status = err?.status ?? res.statusCode ?? 500;
|
||||
this.logLine(req, status, Date.now() - started, err?.message);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private logLine(
|
||||
req: RequestWithId & { user?: AuthUser },
|
||||
status: number,
|
||||
latencyMs: number,
|
||||
error?: string,
|
||||
) {
|
||||
const line = {
|
||||
requestId: req.requestId,
|
||||
method: req.method,
|
||||
path: req.originalUrl || req.url,
|
||||
status,
|
||||
latencyMs,
|
||||
clientApp: req.headers['x-client-app'],
|
||||
actorType: req.user?.actorType,
|
||||
actorId: req.user?.actorId != null ? String(req.user.actorId) : undefined,
|
||||
error: error?.slice(0, 200),
|
||||
};
|
||||
if (status >= 500) this.logger.error(JSON.stringify(line));
|
||||
else if (status >= 400) this.logger.warn(JSON.stringify(line));
|
||||
else this.logger.log(JSON.stringify(line));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RequestIdMiddleware } from './request-id.middleware';
|
||||
import { LoggingInterceptor } from './logging.interceptor';
|
||||
|
||||
@Module({
|
||||
providers: [RequestIdMiddleware, LoggingInterceptor],
|
||||
exports: [RequestIdMiddleware, LoggingInterceptor],
|
||||
})
|
||||
export class LoggingModule {}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
|
||||
export const REQUEST_ID_HEADER = 'x-request-id';
|
||||
|
||||
export type RequestWithId = Request & { requestId?: string };
|
||||
|
||||
@Injectable()
|
||||
export class RequestIdMiddleware implements NestMiddleware {
|
||||
use(req: RequestWithId, res: Response, next: NextFunction) {
|
||||
const incoming = req.headers[REQUEST_ID_HEADER];
|
||||
const requestId =
|
||||
typeof incoming === 'string' && incoming.trim()
|
||||
? incoming.trim().slice(0, 64)
|
||||
: randomUUID();
|
||||
req.requestId = requestId;
|
||||
res.setHeader('X-Request-Id', requestId);
|
||||
next();
|
||||
}
|
||||
}
|
||||
@@ -159,6 +159,15 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
|
||||
|
||||
{ key: 'DEPLOY_WEBHOOK_URL', label: '发布 Webhook URL', group: G.deploy, type: 'string', requiresRestart: false },
|
||||
{ key: 'DEPLOY_WEBHOOK_SECRET', label: '发布 Webhook Secret', group: G.deploy, type: 'password', secret: true, requiresRestart: false },
|
||||
{
|
||||
key: 'SENTRY_DSN',
|
||||
label: 'Sentry DSN',
|
||||
group: G.deploy,
|
||||
type: 'password',
|
||||
secret: true,
|
||||
requiresRestart: true,
|
||||
description: '后端错误聚合;留空不启用。配置后需重启 API 生效',
|
||||
},
|
||||
|
||||
{
|
||||
key: 'WINERY_BANK_ACCOUNT_NAME',
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
|
||||
/** Optional Sentry bootstrap — reads SENTRY_DSN from system_config (preloaded) or .env. */
|
||||
export function initSentryIfConfigured() {
|
||||
const dsn = process.env.SENTRY_DSN?.trim();
|
||||
if (!dsn) return;
|
||||
|
||||
Sentry.init({
|
||||
dsn,
|
||||
environment: process.env.NODE_ENV ?? 'development',
|
||||
tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 0.2,
|
||||
});
|
||||
console.log('[sentry] initialized');
|
||||
}
|
||||
@@ -7,8 +7,10 @@ import { json } from 'express';
|
||||
import { AppModule } from './app.module';
|
||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
||||
import { LoggingInterceptor } from './common/logging/logging.interceptor';
|
||||
import { preloadSystemConfigEnv } from './common/system-config/system-config.env';
|
||||
import { AlertService } from './common/alert/alert.service';
|
||||
import { initSentryIfConfigured } from './integrations/sentry/sentry.bootstrap';
|
||||
|
||||
async function bootstrap() {
|
||||
const preloaded = await preloadSystemConfigEnv().catch((e) => {
|
||||
@@ -18,6 +20,7 @@ async function bootstrap() {
|
||||
if (preloaded > 0) {
|
||||
console.log(`[config] loaded ${preloaded} keys from system_config`);
|
||||
}
|
||||
initSentryIfConfigured();
|
||||
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, { bodyParser: false });
|
||||
app.setGlobalPrefix('api/v1');
|
||||
@@ -38,7 +41,7 @@ async function bootstrap() {
|
||||
);
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
||||
app.useGlobalFilters(new HttpExceptionFilter(app.get(AlertService)));
|
||||
app.useGlobalInterceptors(new ResponseInterceptor());
|
||||
app.useGlobalInterceptors(new ResponseInterceptor(), app.get(LoggingInterceptor));
|
||||
const port = process.env.PORT || 3000;
|
||||
const cfg = loadAppConfig();
|
||||
const smsMode = cfg.mockSms ? 'MOCK' : 'ALIYUN';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user