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
+9 -2
View File
@@ -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');
}
+4 -1
View File
@@ -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;
}