62 KiB
62 KiB
Analytics & Presence Service
RWA 用户活跃度与在线状态上下文微服务 - 基于 DDD 架构的 NestJS 实现
技术栈
- 框架: NestJS + TypeScript
- ORM: Prisma
- 消息队列: Kafka
- 缓存: Redis (ioredis)
- 定时任务: @nestjs/schedule
项目结构
analytics-presence-service/
├── src/
│ ├── api/ # 表现层
│ │ ├── controllers/
│ │ │ ├── analytics.controller.ts
│ │ │ └── presence.controller.ts
│ │ ├── dto/
│ │ │ ├── request/
│ │ │ │ ├── batch-events.dto.ts
│ │ │ │ ├── heartbeat.dto.ts
│ │ │ │ └── query-dau.dto.ts
│ │ │ └── response/
│ │ │ ├── dau-stats.dto.ts
│ │ │ ├── online-count.dto.ts
│ │ │ └── online-history.dto.ts
│ │ ├── validators/
│ │ │ └── event.validator.ts
│ │ └── api.module.ts
│ │
│ ├── application/ # 应用层
│ │ ├── commands/
│ │ │ ├── record-events/
│ │ │ │ ├── record-events.command.ts
│ │ │ │ └── record-events.handler.ts
│ │ │ ├── record-heartbeat/
│ │ │ │ ├── record-heartbeat.command.ts
│ │ │ │ └── record-heartbeat.handler.ts
│ │ │ └── calculate-dau/
│ │ │ ├── calculate-dau.command.ts
│ │ │ └── calculate-dau.handler.ts
│ │ ├── queries/
│ │ │ ├── get-dau-stats/
│ │ │ │ ├── get-dau-stats.query.ts
│ │ │ │ └── get-dau-stats.handler.ts
│ │ │ ├── get-online-count/
│ │ │ │ ├── get-online-count.query.ts
│ │ │ │ └── get-online-count.handler.ts
│ │ │ └── get-online-history/
│ │ │ ├── get-online-history.query.ts
│ │ │ └── get-online-history.handler.ts
│ │ ├── services/
│ │ │ └── analytics-application.service.ts
│ │ ├── schedulers/
│ │ │ └── analytics.scheduler.ts
│ │ └── application.module.ts
│ │
│ ├── domain/ # 领域层
│ │ ├── aggregates/
│ │ │ └── daily-active-stats/
│ │ │ ├── daily-active-stats.aggregate.ts
│ │ │ ├── daily-active-stats.factory.ts
│ │ │ └── daily-active-stats.spec.ts
│ │ ├── entities/
│ │ │ ├── event-log.entity.ts
│ │ │ └── online-snapshot.entity.ts
│ │ ├── value-objects/
│ │ │ ├── install-id.vo.ts
│ │ │ ├── event-name.vo.ts
│ │ │ ├── event-properties.vo.ts
│ │ │ ├── device-info.vo.ts
│ │ │ └── time-window.vo.ts
│ │ ├── events/
│ │ │ ├── session-started.event.ts
│ │ │ ├── heartbeat-received.event.ts
│ │ │ └── dau-calculated.event.ts
│ │ ├── repositories/
│ │ │ ├── event-log.repository.interface.ts
│ │ │ ├── daily-active-stats.repository.interface.ts
│ │ │ └── online-snapshot.repository.interface.ts
│ │ ├── services/
│ │ │ ├── dau-calculation.service.ts
│ │ │ └── online-detection.service.ts
│ │ └── domain.module.ts
│ │
│ ├── infrastructure/ # 基础设施层
│ │ ├── persistence/
│ │ │ ├── prisma/
│ │ │ │ ├── schema.prisma
│ │ │ │ └── prisma.service.ts
│ │ │ ├── entities/
│ │ │ │ ├── event-log.entity.ts
│ │ │ │ ├── daily-active-stats.entity.ts
│ │ │ │ └── online-snapshot.entity.ts
│ │ │ ├── mappers/
│ │ │ │ ├── event-log.mapper.ts
│ │ │ │ ├── daily-active-stats.mapper.ts
│ │ │ │ └── online-snapshot.mapper.ts
│ │ │ └── repositories/
│ │ │ ├── event-log.repository.impl.ts
│ │ │ ├── daily-active-stats.repository.impl.ts
│ │ │ └── online-snapshot.repository.impl.ts
│ │ ├── redis/
│ │ │ ├── redis.module.ts
│ │ │ ├── redis.service.ts
│ │ │ └── presence-redis.repository.ts
│ │ ├── kafka/
│ │ │ ├── kafka.module.ts
│ │ │ ├── event-publisher.service.ts
│ │ │ └── event-consumer.service.ts
│ │ └── infrastructure.module.ts
│ │
│ ├── shared/ # 共享层
│ │ ├── decorators/
│ │ │ ├── current-user.decorator.ts
│ │ │ └── public.decorator.ts
│ │ ├── guards/
│ │ │ ├── jwt-auth.guard.ts
│ │ │ └── optional-auth.guard.ts
│ │ ├── filters/
│ │ │ └── domain-exception.filter.ts
│ │ ├── interceptors/
│ │ │ └── transform.interceptor.ts
│ │ ├── exceptions/
│ │ │ ├── domain.exception.ts
│ │ │ └── application.exception.ts
│ │ └── utils/
│ │ └── timezone.util.ts
│ │
│ ├── config/
│ │ ├── app.config.ts
│ │ ├── database.config.ts
│ │ ├── redis.config.ts
│ │ ├── jwt.config.ts
│ │ └── kafka.config.ts
│ │
│ ├── app.module.ts
│ └── main.ts
│
├── test/
│ ├── unit/
│ ├── integration/
│ └── e2e/
│
├── database/
│ └── migrations/
│
├── prisma/
│ └── schema.prisma
│
├── .env.example
├── .env.development
├── .env.production
├── Dockerfile
├── docker-compose.yml
├── package.json
├── tsconfig.json
└── README.md
核心功能
- ✅ 用户行为事件批量上报与存储
- ✅ 日活 DAU 统计(按自然日去重)
- ✅ 实时在线人数统计(3分钟窗口)
- ✅ 心跳机制(前台60秒间隔)
- ✅ 按省/市维度的活跃用户分析
- ✅ 在线人数历史趋势查询
- ✅ 与 Identity Context 的用户标识集成
- ✅ Kafka 事件发布(供其他服务订阅)
一、领域层设计
1.1 聚合根:DailyActiveStats
// src/domain/aggregates/daily-active-stats/daily-active-stats.aggregate.ts
import { AggregateRoot } from '@nestjs/cqrs';
import { DauCalculatedEvent } from '../../events/dau-calculated.event';
export class DailyActiveStats extends AggregateRoot {
private _day: Date;
private _dauCount: number;
private _dauByProvince: Map<string, number>;
private _dauByCity: Map<string, number>;
private _calculatedAt: Date;
private _version: number;
private constructor() {
super();
}
// Getters
get day(): Date {
return this._day;
}
get dauCount(): number {
return this._dauCount;
}
get dauByProvince(): Map<string, number> {
return new Map(this._dauByProvince);
}
get dauByCity(): Map<string, number> {
return new Map(this._dauByCity);
}
get calculatedAt(): Date {
return this._calculatedAt;
}
get version(): number {
return this._version;
}
// 工厂方法
static create(props: {
day: Date;
dauCount: number;
dauByProvince?: Map<string, number>;
dauByCity?: Map<string, number>;
}): DailyActiveStats {
const stats = new DailyActiveStats();
stats._day = props.day;
stats._dauCount = props.dauCount;
stats._dauByProvince = props.dauByProvince ?? new Map();
stats._dauByCity = props.dauByCity ?? new Map();
stats._calculatedAt = new Date();
stats._version = 1;
stats.apply(new DauCalculatedEvent(stats._day, stats._dauCount));
return stats;
}
// 重新计算
recalculate(newDauCount: number, byProvince?: Map<string, number>, byCity?: Map<string, number>): void {
this._dauCount = newDauCount;
if (byProvince) this._dauByProvince = byProvince;
if (byCity) this._dauByCity = byCity;
this._calculatedAt = new Date();
this._version++;
this.apply(new DauCalculatedEvent(this._day, this._dauCount));
}
// 从持久化恢复
static reconstitute(props: {
day: Date;
dauCount: number;
dauByProvince: Map<string, number>;
dauByCity: Map<string, number>;
calculatedAt: Date;
version: number;
}): DailyActiveStats {
const stats = new DailyActiveStats();
stats._day = props.day;
stats._dauCount = props.dauCount;
stats._dauByProvince = props.dauByProvince;
stats._dauByCity = props.dauByCity;
stats._calculatedAt = props.calculatedAt;
stats._version = props.version;
return stats;
}
}
1.2 实体:EventLog
// src/domain/entities/event-log.entity.ts
import { InstallId } from '../value-objects/install-id.vo';
import { EventName } from '../value-objects/event-name.vo';
import { EventProperties } from '../value-objects/event-properties.vo';
export class EventLog {
private _id: bigint | null;
private _userId: bigint | null;
private _installId: InstallId;
private _eventName: EventName;
private _eventTime: Date;
private _properties: EventProperties;
private _createdAt: Date;
private constructor() {}
// Getters
get id(): bigint | null {
return this._id;
}
get userId(): bigint | null {
return this._userId;
}
get installId(): InstallId {
return this._installId;
}
get eventName(): EventName {
return this._eventName;
}
get eventTime(): Date {
return this._eventTime;
}
get properties(): EventProperties {
return this._properties;
}
get createdAt(): Date {
return this._createdAt;
}
/**
* 获取用于DAU去重的唯一标识
* 优先使用 userId,否则使用 installId
*/
get dauIdentifier(): string {
return this._userId?.toString() ?? this._installId.value;
}
// 工厂方法
static create(props: {
userId?: bigint | null;
installId: InstallId;
eventName: EventName;
eventTime: Date;
properties?: EventProperties;
}): EventLog {
const log = new EventLog();
log._id = null;
log._userId = props.userId ?? null;
log._installId = props.installId;
log._eventName = props.eventName;
log._eventTime = props.eventTime;
log._properties = props.properties ?? EventProperties.empty();
log._createdAt = new Date();
return log;
}
// 从持久化恢复
static reconstitute(props: {
id: bigint;
userId: bigint | null;
installId: InstallId;
eventName: EventName;
eventTime: Date;
properties: EventProperties;
createdAt: Date;
}): EventLog {
const log = new EventLog();
log._id = props.id;
log._userId = props.userId;
log._installId = props.installId;
log._eventName = props.eventName;
log._eventTime = props.eventTime;
log._properties = props.properties;
log._createdAt = props.createdAt;
return log;
}
}
1.3 实体:OnlineSnapshot
// src/domain/entities/online-snapshot.entity.ts
import { TimeWindow } from '../value-objects/time-window.vo';
export class OnlineSnapshot {
private _id: bigint | null;
private _ts: Date;
private _onlineCount: number;
private _windowSeconds: number;
private constructor() {}
get id(): bigint | null {
return this._id;
}
get ts(): Date {
return this._ts;
}
get onlineCount(): number {
return this._onlineCount;
}
get windowSeconds(): number {
return this._windowSeconds;
}
static create(props: {
ts: Date;
onlineCount: number;
windowSeconds?: number;
}): OnlineSnapshot {
const snapshot = new OnlineSnapshot();
snapshot._id = null;
snapshot._ts = props.ts;
snapshot._onlineCount = props.onlineCount;
snapshot._windowSeconds = props.windowSeconds ?? TimeWindow.DEFAULT_ONLINE_WINDOW_SECONDS;
return snapshot;
}
static reconstitute(props: {
id: bigint;
ts: Date;
onlineCount: number;
windowSeconds: number;
}): OnlineSnapshot {
const snapshot = new OnlineSnapshot();
snapshot._id = props.id;
snapshot._ts = props.ts;
snapshot._onlineCount = props.onlineCount;
snapshot._windowSeconds = props.windowSeconds;
return snapshot;
}
}
1.4 值对象
// src/domain/value-objects/install-id.vo.ts
import { v4 as uuidv4, validate as uuidValidate } from 'uuid';
import { DomainException } from '../../shared/exceptions/domain.exception';
export class InstallId {
private readonly _value: string;
private constructor(value: string) {
this._value = value;
}
get value(): string {
return this._value;
}
static generate(): InstallId {
return new InstallId(uuidv4());
}
static fromString(value: string): InstallId {
if (!value || value.trim() === '') {
throw new DomainException('InstallId cannot be empty');
}
// 允许非UUID格式,但需要有基本长度
if (value.length < 8 || value.length > 128) {
throw new DomainException('InstallId length must be between 8 and 128 characters');
}
return new InstallId(value.trim());
}
equals(other: InstallId): boolean {
return this._value === other._value;
}
toString(): string {
return this._value;
}
}
// src/domain/value-objects/event-name.vo.ts
import { DomainException } from '../../shared/exceptions/domain.exception';
export class EventName {
// 预定义的事件名
static readonly APP_SESSION_START = new EventName('app_session_start');
static readonly PRESENCE_HEARTBEAT = new EventName('presence_heartbeat');
static readonly APP_SESSION_END = new EventName('app_session_end');
private readonly _value: string;
private constructor(value: string) {
this._value = value;
}
get value(): string {
return this._value;
}
static fromString(value: string): EventName {
if (!value || value.trim() === '') {
throw new DomainException('EventName cannot be empty');
}
const trimmed = value.trim().toLowerCase();
if (trimmed.length > 64) {
throw new DomainException('EventName cannot exceed 64 characters');
}
// 验证格式:字母、数字、下划线
if (!/^[a-z][a-z0-9_]*$/.test(trimmed)) {
throw new DomainException('EventName must start with letter and contain only lowercase letters, numbers, and underscores');
}
return new EventName(trimmed);
}
/**
* 是否为DAU统计事件
*/
isDauEvent(): boolean {
return this._value === EventName.APP_SESSION_START.value;
}
equals(other: EventName): boolean {
return this._value === other._value;
}
toString(): string {
return this._value;
}
}
// src/domain/value-objects/event-properties.vo.ts
export interface EventPropertiesData {
os?: string;
osVersion?: string;
appVersion?: string;
networkType?: string;
clientTs?: number;
province?: string;
city?: string;
[key: string]: unknown;
}
export class EventProperties {
private readonly _data: EventPropertiesData;
private constructor(data: EventPropertiesData) {
this._data = { ...data };
}
get data(): EventPropertiesData {
return { ...this._data };
}
get os(): string | undefined {
return this._data.os;
}
get osVersion(): string | undefined {
return this._data.osVersion;
}
get appVersion(): string | undefined {
return this._data.appVersion;
}
get networkType(): string | undefined {
return this._data.networkType;
}
get clientTs(): number | undefined {
return this._data.clientTs;
}
get province(): string | undefined {
return this._data.province;
}
get city(): string | undefined {
return this._data.city;
}
static empty(): EventProperties {
return new EventProperties({});
}
static fromData(data: EventPropertiesData): EventProperties {
return new EventProperties(data);
}
toJSON(): EventPropertiesData {
return this._data;
}
}
// src/domain/value-objects/device-info.vo.ts
export class DeviceInfo {
private readonly _os: 'Android' | 'iOS';
private readonly _osVersion: string;
private readonly _deviceModel?: string;
private readonly _screenResolution?: string;
private constructor(props: {
os: 'Android' | 'iOS';
osVersion: string;
deviceModel?: string;
screenResolution?: string;
}) {
this._os = props.os;
this._osVersion = props.osVersion;
this._deviceModel = props.deviceModel;
this._screenResolution = props.screenResolution;
}
get os(): 'Android' | 'iOS' {
return this._os;
}
get osVersion(): string {
return this._osVersion;
}
get deviceModel(): string | undefined {
return this._deviceModel;
}
get screenResolution(): string | undefined {
return this._screenResolution;
}
static create(props: {
os: string;
osVersion: string;
deviceModel?: string;
screenResolution?: string;
}): DeviceInfo {
const normalizedOs = props.os.toLowerCase() === 'ios' ? 'iOS' : 'Android';
return new DeviceInfo({
os: normalizedOs,
osVersion: props.osVersion,
deviceModel: props.deviceModel,
screenResolution: props.screenResolution,
});
}
}
// src/domain/value-objects/time-window.vo.ts
export class TimeWindow {
static readonly DEFAULT_ONLINE_WINDOW_SECONDS = 180; // 3分钟
static readonly DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 60; // 60秒
private readonly _windowSeconds: number;
private constructor(windowSeconds: number) {
this._windowSeconds = windowSeconds;
}
get windowSeconds(): number {
return this._windowSeconds;
}
static default(): TimeWindow {
return new TimeWindow(TimeWindow.DEFAULT_ONLINE_WINDOW_SECONDS);
}
static ofSeconds(seconds: number): TimeWindow {
if (seconds <= 0) {
throw new Error('TimeWindow must be positive');
}
return new TimeWindow(seconds);
}
/**
* 计算在线阈值时间戳
*/
getThresholdTimestamp(now: Date = new Date()): number {
return Math.floor(now.getTime() / 1000) - this._windowSeconds;
}
}
1.5 领域事件
// src/domain/events/session-started.event.ts
export class SessionStartedEvent {
static readonly EVENT_NAME = 'analytics.session.started';
constructor(
public readonly userId: bigint | null,
public readonly installId: string,
public readonly occurredAt: Date,
public readonly metadata: {
appVersion?: string;
os?: string;
osVersion?: string;
},
) {}
}
// src/domain/events/heartbeat-received.event.ts
export class HeartbeatReceivedEvent {
static readonly EVENT_NAME = 'presence.heartbeat.received';
constructor(
public readonly userId: bigint,
public readonly installId: string,
public readonly occurredAt: Date,
) {}
}
// src/domain/events/dau-calculated.event.ts
export class DauCalculatedEvent {
static readonly EVENT_NAME = 'analytics.dau.calculated';
constructor(
public readonly day: Date,
public readonly dauCount: number,
) {}
}
1.6 仓储接口
// src/domain/repositories/event-log.repository.interface.ts
import { EventLog } from '../entities/event-log.entity';
import { EventName } from '../value-objects/event-name.vo';
export interface DauQueryResult {
total: number;
byProvince: Map<string, number>;
byCity: Map<string, number>;
}
export interface IEventLogRepository {
/**
* 批量插入事件日志
*/
batchInsert(logs: EventLog[]): Promise<void>;
/**
* 插入单条事件日志
*/
insert(log: EventLog): Promise<EventLog>;
/**
* 查询DAU(去重用户数)
*/
queryDau(
eventName: EventName,
startTime: Date,
endTime: Date,
): Promise<DauQueryResult>;
/**
* 按时间范围查询事件
*/
findByTimeRange(
eventName: EventName,
startTime: Date,
endTime: Date,
limit?: number,
): Promise<EventLog[]>;
}
export const EVENT_LOG_REPOSITORY = Symbol('IEventLogRepository');
// src/domain/repositories/daily-active-stats.repository.interface.ts
import { DailyActiveStats } from '../aggregates/daily-active-stats/daily-active-stats.aggregate';
export interface IDailyActiveStatsRepository {
/**
* 保存或更新日活统计
*/
upsert(stats: DailyActiveStats): Promise<void>;
/**
* 按日期查询
*/
findByDate(day: Date): Promise<DailyActiveStats | null>;
/**
* 按日期范围查询
*/
findByDateRange(startDate: Date, endDate: Date): Promise<DailyActiveStats[]>;
}
export const DAILY_ACTIVE_STATS_REPOSITORY = Symbol('IDailyActiveStatsRepository');
// src/domain/repositories/online-snapshot.repository.interface.ts
import { OnlineSnapshot } from '../entities/online-snapshot.entity';
export interface IOnlineSnapshotRepository {
/**
* 插入快照
*/
insert(snapshot: OnlineSnapshot): Promise<OnlineSnapshot>;
/**
* 按时间范围查询
*/
findByTimeRange(
startTime: Date,
endTime: Date,
interval?: '1m' | '5m' | '1h',
): Promise<OnlineSnapshot[]>;
/**
* 获取最新快照
*/
findLatest(): Promise<OnlineSnapshot | null>;
}
export const ONLINE_SNAPSHOT_REPOSITORY = Symbol('IOnlineSnapshotRepository');
1.7 领域服务
// src/domain/services/dau-calculation.service.ts
import { Injectable } from '@nestjs/common';
import { DailyActiveStats } from '../aggregates/daily-active-stats/daily-active-stats.aggregate';
import { DauQueryResult } from '../repositories/event-log.repository.interface';
@Injectable()
export class DauCalculationService {
/**
* 从查询结果创建日活统计聚合
*/
createStatsFromQueryResult(day: Date, result: DauQueryResult): DailyActiveStats {
return DailyActiveStats.create({
day,
dauCount: result.total,
dauByProvince: result.byProvince,
dauByCity: result.byCity,
});
}
/**
* 合并多个查询结果(用于增量计算)
*/
mergeQueryResults(results: DauQueryResult[]): DauQueryResult {
const allIdentifiers = new Set<string>();
const byProvince = new Map<string, Set<string>>();
const byCity = new Map<string, Set<string>>();
// 注意:这里简化处理,实际需要原始数据才能正确去重
// 生产环境应该在数据库层面完成去重
let total = 0;
const provinceCount = new Map<string, number>();
const cityCount = new Map<string, number>();
for (const result of results) {
total = Math.max(total, result.total);
for (const [province, count] of result.byProvince) {
provinceCount.set(province, Math.max(provinceCount.get(province) ?? 0, count));
}
for (const [city, count] of result.byCity) {
cityCount.set(city, Math.max(cityCount.get(city) ?? 0, count));
}
}
return {
total,
byProvince: provinceCount,
byCity: cityCount,
};
}
}
// src/domain/services/online-detection.service.ts
import { Injectable } from '@nestjs/common';
import { TimeWindow } from '../value-objects/time-window.vo';
@Injectable()
export class OnlineDetectionService {
private readonly timeWindow: TimeWindow;
constructor() {
this.timeWindow = TimeWindow.default();
}
/**
* 判断用户是否在线
*/
isOnline(lastHeartbeatTs: number, now: Date = new Date()): boolean {
const threshold = this.timeWindow.getThresholdTimestamp(now);
return lastHeartbeatTs > threshold;
}
/**
* 获取在线判定阈值时间戳
*/
getOnlineThreshold(now: Date = new Date()): number {
return this.timeWindow.getThresholdTimestamp(now);
}
/**
* 获取窗口秒数
*/
getWindowSeconds(): number {
return this.timeWindow.windowSeconds;
}
}
二、应用层设计
2.1 命令:RecordEvents
// src/application/commands/record-events/record-events.command.ts
export interface EventItemDto {
eventName: string;
userId?: string;
installId: string;
clientTs: number;
properties?: Record<string, unknown>;
}
export class RecordEventsCommand {
constructor(public readonly events: EventItemDto[]) {}
}
// src/application/commands/record-events/record-events.handler.ts
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { Inject, Injectable } from '@nestjs/common';
import { RecordEventsCommand, EventItemDto } from './record-events.command';
import { EventLog } from '../../../domain/entities/event-log.entity';
import { InstallId } from '../../../domain/value-objects/install-id.vo';
import { EventName } from '../../../domain/value-objects/event-name.vo';
import { EventProperties } from '../../../domain/value-objects/event-properties.vo';
import {
IEventLogRepository,
EVENT_LOG_REPOSITORY,
} from '../../../domain/repositories/event-log.repository.interface';
import { RedisService } from '../../../infrastructure/redis/redis.service';
import { EventPublisherService } from '../../../infrastructure/kafka/event-publisher.service';
import { SessionStartedEvent } from '../../../domain/events/session-started.event';
import { formatToDateKey } from '../../../shared/utils/timezone.util';
export interface RecordEventsResult {
accepted: number;
failed: number;
errors?: string[];
}
@Injectable()
@CommandHandler(RecordEventsCommand)
export class RecordEventsHandler implements ICommandHandler<RecordEventsCommand> {
constructor(
@Inject(EVENT_LOG_REPOSITORY)
private readonly eventLogRepository: IEventLogRepository,
private readonly redisService: RedisService,
private readonly eventPublisher: EventPublisherService,
) {}
async execute(command: RecordEventsCommand): Promise<RecordEventsResult> {
const { events } = command;
const errors: string[] = [];
const validLogs: EventLog[] = [];
// 1. 验证并转换事件
for (let i = 0; i < events.length; i++) {
try {
const log = this.toEventLog(events[i]);
validLogs.push(log);
} catch (e) {
errors.push(`Event[${i}]: ${e.message}`);
}
}
if (validLogs.length === 0) {
return { accepted: 0, failed: events.length, errors };
}
// 2. 批量写入数据库
await this.eventLogRepository.batchInsert(validLogs);
// 3. 更新实时DAU (HyperLogLog)
const todayKey = formatToDateKey(new Date());
for (const log of validLogs) {
if (log.eventName.isDauEvent()) {
await this.redisService.pfadd(
`analytics:dau:${todayKey}`,
log.dauIdentifier,
);
// 4. 发布领域事件
await this.eventPublisher.publish(
SessionStartedEvent.EVENT_NAME,
new SessionStartedEvent(
log.userId,
log.installId.value,
log.eventTime,
{
appVersion: log.properties.appVersion,
os: log.properties.os,
osVersion: log.properties.osVersion,
},
),
);
}
}
return {
accepted: validLogs.length,
failed: events.length - validLogs.length,
errors: errors.length > 0 ? errors : undefined,
};
}
private toEventLog(dto: EventItemDto): EventLog {
return EventLog.create({
userId: dto.userId ? BigInt(dto.userId) : null,
installId: InstallId.fromString(dto.installId),
eventName: EventName.fromString(dto.eventName),
eventTime: new Date(dto.clientTs * 1000),
properties: EventProperties.fromData(dto.properties ?? {}),
});
}
}
2.2 命令:RecordHeartbeat
// src/application/commands/record-heartbeat/record-heartbeat.command.ts
export class RecordHeartbeatCommand {
constructor(
public readonly userId: bigint,
public readonly installId: string,
public readonly appVersion: string,
public readonly clientTs: number,
) {}
}
// src/application/commands/record-heartbeat/record-heartbeat.handler.ts
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { Injectable } from '@nestjs/common';
import { RecordHeartbeatCommand } from './record-heartbeat.command';
import { PresenceRedisRepository } from '../../../infrastructure/redis/presence-redis.repository';
import { EventPublisherService } from '../../../infrastructure/kafka/event-publisher.service';
import { HeartbeatReceivedEvent } from '../../../domain/events/heartbeat-received.event';
export interface RecordHeartbeatResult {
ok: boolean;
serverTs: number;
}
@Injectable()
@CommandHandler(RecordHeartbeatCommand)
export class RecordHeartbeatHandler implements ICommandHandler<RecordHeartbeatCommand> {
constructor(
private readonly presenceRedisRepository: PresenceRedisRepository,
private readonly eventPublisher: EventPublisherService,
) {}
async execute(command: RecordHeartbeatCommand): Promise<RecordHeartbeatResult> {
const { userId, installId, appVersion, clientTs } = command;
const now = Math.floor(Date.now() / 1000);
// 1. 更新Redis在线状态
await this.presenceRedisRepository.updateUserPresence(userId.toString(), now);
// 2. 发布领域事件
await this.eventPublisher.publish(
HeartbeatReceivedEvent.EVENT_NAME,
new HeartbeatReceivedEvent(userId, installId, new Date()),
);
return { ok: true, serverTs: now };
}
}
2.3 命令:CalculateDau
// src/application/commands/calculate-dau/calculate-dau.command.ts
export class CalculateDauCommand {
constructor(public readonly date: Date) {}
}
// src/application/commands/calculate-dau/calculate-dau.handler.ts
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { CalculateDauCommand } from './calculate-dau.command';
import {
IEventLogRepository,
EVENT_LOG_REPOSITORY,
} from '../../../domain/repositories/event-log.repository.interface';
import {
IDailyActiveStatsRepository,
DAILY_ACTIVE_STATS_REPOSITORY,
} from '../../../domain/repositories/daily-active-stats.repository.interface';
import { DauCalculationService } from '../../../domain/services/dau-calculation.service';
import { EventName } from '../../../domain/value-objects/event-name.vo';
import {
startOfDayInTimezone,
endOfDayInTimezone,
} from '../../../shared/utils/timezone.util';
@Injectable()
@CommandHandler(CalculateDauCommand)
export class CalculateDauHandler implements ICommandHandler<CalculateDauCommand> {
private readonly logger = new Logger(CalculateDauHandler.name);
constructor(
@Inject(EVENT_LOG_REPOSITORY)
private readonly eventLogRepository: IEventLogRepository,
@Inject(DAILY_ACTIVE_STATS_REPOSITORY)
private readonly dauStatsRepository: IDailyActiveStatsRepository,
private readonly dauCalculationService: DauCalculationService,
) {}
async execute(command: CalculateDauCommand): Promise<void> {
const { date } = command;
const timezone = 'Asia/Shanghai';
const startOfDay = startOfDayInTimezone(date, timezone);
const endOfDay = endOfDayInTimezone(date, timezone);
this.logger.log(`Calculating DAU for ${date.toISOString().split('T')[0]}`);
// 1. 查询去重用户数
const result = await this.eventLogRepository.queryDau(
EventName.APP_SESSION_START,
startOfDay,
endOfDay,
);
// 2. 创建或更新统计聚合
const existingStats = await this.dauStatsRepository.findByDate(date);
if (existingStats) {
existingStats.recalculate(result.total, result.byProvince, result.byCity);
await this.dauStatsRepository.upsert(existingStats);
} else {
const stats = this.dauCalculationService.createStatsFromQueryResult(date, result);
await this.dauStatsRepository.upsert(stats);
}
this.logger.log(`DAU calculated: ${result.total} users`);
}
}
2.4 查询:GetOnlineCount
// src/application/queries/get-online-count/get-online-count.query.ts
export class GetOnlineCountQuery {
constructor() {}
}
// src/application/queries/get-online-count/get-online-count.handler.ts
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { Injectable } from '@nestjs/common';
import { GetOnlineCountQuery } from './get-online-count.query';
import { PresenceRedisRepository } from '../../../infrastructure/redis/presence-redis.repository';
import { OnlineDetectionService } from '../../../domain/services/online-detection.service';
export interface OnlineCountResult {
count: number;
windowSeconds: number;
queriedAt: Date;
}
@Injectable()
@QueryHandler(GetOnlineCountQuery)
export class GetOnlineCountHandler implements IQueryHandler<GetOnlineCountQuery> {
constructor(
private readonly presenceRedisRepository: PresenceRedisRepository,
private readonly onlineDetectionService: OnlineDetectionService,
) {}
async execute(query: GetOnlineCountQuery): Promise<OnlineCountResult> {
const now = new Date();
const threshold = this.onlineDetectionService.getOnlineThreshold(now);
const count = await this.presenceRedisRepository.countOnlineUsers(threshold);
return {
count,
windowSeconds: this.onlineDetectionService.getWindowSeconds(),
queriedAt: now,
};
}
}
2.5 查询:GetDauStats
// src/application/queries/get-dau-stats/get-dau-stats.query.ts
export class GetDauStatsQuery {
constructor(
public readonly startDate: Date,
public readonly endDate: Date,
) {}
}
// src/application/queries/get-dau-stats/get-dau-stats.handler.ts
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { Inject, Injectable } from '@nestjs/common';
import { GetDauStatsQuery } from './get-dau-stats.query';
import {
IDailyActiveStatsRepository,
DAILY_ACTIVE_STATS_REPOSITORY,
} from '../../../domain/repositories/daily-active-stats.repository.interface';
export interface DauStatsItem {
day: string;
dauCount: number;
byProvince?: Record<string, number>;
byCity?: Record<string, number>;
}
export interface DauStatsResult {
data: DauStatsItem[];
total: number;
}
@Injectable()
@QueryHandler(GetDauStatsQuery)
export class GetDauStatsHandler implements IQueryHandler<GetDauStatsQuery> {
constructor(
@Inject(DAILY_ACTIVE_STATS_REPOSITORY)
private readonly dauStatsRepository: IDailyActiveStatsRepository,
) {}
async execute(query: GetDauStatsQuery): Promise<DauStatsResult> {
const { startDate, endDate } = query;
const statsList = await this.dauStatsRepository.findByDateRange(startDate, endDate);
const data: DauStatsItem[] = statsList.map((stats) => ({
day: stats.day.toISOString().split('T')[0],
dauCount: stats.dauCount,
byProvince: Object.fromEntries(stats.dauByProvince),
byCity: Object.fromEntries(stats.dauByCity),
}));
return {
data,
total: data.length,
};
}
}
2.6 定时任务
// src/application/schedulers/analytics.scheduler.ts
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { CommandBus } from '@nestjs/cqrs';
import { subDays } from 'date-fns';
import { CalculateDauCommand } from '../commands/calculate-dau/calculate-dau.command';
import { PresenceRedisRepository } from '../../infrastructure/redis/presence-redis.repository';
import { OnlineDetectionService } from '../../domain/services/online-detection.service';
import { OnlineSnapshot } from '../../domain/entities/online-snapshot.entity';
import { IOnlineSnapshotRepository, ONLINE_SNAPSHOT_REPOSITORY } from '../../domain/repositories/online-snapshot.repository.interface';
import { Inject } from '@nestjs/common';
@Injectable()
export class AnalyticsScheduler {
private readonly logger = new Logger(AnalyticsScheduler.name);
constructor(
private readonly commandBus: CommandBus,
private readonly presenceRedisRepository: PresenceRedisRepository,
private readonly onlineDetectionService: OnlineDetectionService,
@Inject(ONLINE_SNAPSHOT_REPOSITORY)
private readonly snapshotRepository: IOnlineSnapshotRepository,
) {}
/**
* 每分钟记录在线人数快照
*/
@Cron(CronExpression.EVERY_MINUTE)
async recordOnlineSnapshot(): Promise<void> {
try {
const now = new Date();
const threshold = this.onlineDetectionService.getOnlineThreshold(now);
const count = await this.presenceRedisRepository.countOnlineUsers(threshold);
const snapshot = OnlineSnapshot.create({
ts: now,
onlineCount: count,
windowSeconds: this.onlineDetectionService.getWindowSeconds(),
});
await this.snapshotRepository.insert(snapshot);
this.logger.debug(`Online snapshot recorded: ${count} users`);
} catch (error) {
this.logger.error('Failed to record online snapshot', error);
}
}
/**
* 每小时清理过期在线数据
*/
@Cron(CronExpression.EVERY_HOUR)
async cleanupExpiredPresence(): Promise<void> {
try {
const threshold = Math.floor(Date.now() / 1000) - 86400; // 24小时前
await this.presenceRedisRepository.removeExpiredUsers(threshold);
this.logger.log('Expired presence data cleaned up');
} catch (error) {
this.logger.error('Failed to cleanup expired presence', error);
}
}
/**
* 每天凌晨 1:00 计算前一天 DAU (Asia/Shanghai)
*/
@Cron('0 0 1 * * *', { timeZone: 'Asia/Shanghai' })
async calculateYesterdayDau(): Promise<void> {
try {
const yesterday = subDays(new Date(), 1);
await this.commandBus.execute(new CalculateDauCommand(yesterday));
this.logger.log('Yesterday DAU calculated');
} catch (error) {
this.logger.error('Failed to calculate yesterday DAU', error);
}
}
/**
* 每小时滚动计算当天 DAU (用于实时看板)
*/
@Cron(CronExpression.EVERY_HOUR)
async calculateTodayDauRolling(): Promise<void> {
try {
await this.commandBus.execute(new CalculateDauCommand(new Date()));
this.logger.debug('Today DAU rolling calculation completed');
} catch (error) {
this.logger.error('Failed to calculate today DAU', error);
}
}
}
三、基础设施层设计
3.1 Prisma Schema
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// 事件日志表 (append-only)
model EventLog {
id BigInt @id @default(autoincrement())
userId BigInt? @map("user_id")
installId String @map("install_id") @db.VarChar(64)
eventName String @map("event_name") @db.VarChar(64)
eventTime DateTime @map("event_time") @db.Timestamptz()
properties Json? @db.JsonB
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
@@index([eventTime], name: "idx_event_log_event_time")
@@index([eventName], name: "idx_event_log_event_name")
@@index([eventName, eventTime], name: "idx_event_log_event_name_time")
@@map("analytics_event_log")
}
// 日活统计表
model DailyActiveStats {
day DateTime @id @map("day") @db.Date
dauCount Int @map("dau_count")
dauByProvince Json? @map("dau_by_province") @db.JsonB
dauByCity Json? @map("dau_by_city") @db.JsonB
calculatedAt DateTime @map("calculated_at") @db.Timestamptz()
version Int @default(1)
@@map("analytics_daily_active_users")
}
// 在线人数快照表
model OnlineSnapshot {
id BigInt @id @default(autoincrement())
ts DateTime @unique @db.Timestamptz()
onlineCount Int @map("online_count")
windowSeconds Int @default(180) @map("window_seconds")
@@index([ts(sort: Desc)], name: "idx_online_snapshots_ts")
@@map("analytics_online_snapshots")
}
3.2 Redis 仓储实现
// src/infrastructure/redis/presence-redis.repository.ts
import { Injectable } from '@nestjs/common';
import { RedisService } from './redis.service';
@Injectable()
export class PresenceRedisRepository {
private readonly ONLINE_USERS_KEY = 'presence:online_users';
constructor(private readonly redisService: RedisService) {}
/**
* 更新用户在线状态
*/
async updateUserPresence(userId: string, timestamp: number): Promise<void> {
await this.redisService.zadd(this.ONLINE_USERS_KEY, timestamp, userId);
}
/**
* 统计在线用户数
*/
async countOnlineUsers(thresholdTimestamp: number): Promise<number> {
return this.redisService.zcount(
this.ONLINE_USERS_KEY,
thresholdTimestamp,
'+inf',
);
}
/**
* 获取在线用户列表
*/
async getOnlineUsers(thresholdTimestamp: number, limit?: number): Promise<string[]> {
const args: [string, number | string, number | string] = [
this.ONLINE_USERS_KEY,
thresholdTimestamp,
'+inf',
];
if (limit) {
return this.redisService.zrangebyscore(
this.ONLINE_USERS_KEY,
thresholdTimestamp,
'+inf',
'LIMIT',
0,
limit,
);
}
return this.redisService.zrangebyscore(
this.ONLINE_USERS_KEY,
thresholdTimestamp,
'+inf',
);
}
/**
* 移除过期用户
*/
async removeExpiredUsers(thresholdTimestamp: number): Promise<number> {
return this.redisService.zremrangebyscore(
this.ONLINE_USERS_KEY,
'-inf',
thresholdTimestamp,
);
}
/**
* 获取用户最后心跳时间
*/
async getUserLastHeartbeat(userId: string): Promise<number | null> {
const score = await this.redisService.zscore(this.ONLINE_USERS_KEY, userId);
return score ? Number(score) : null;
}
}
// src/infrastructure/redis/redis.service.ts
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
@Injectable()
export class RedisService implements OnModuleDestroy {
private readonly client: Redis;
constructor(private readonly configService: ConfigService) {
this.client = new Redis({
host: this.configService.get<string>('REDIS_HOST', 'localhost'),
port: this.configService.get<number>('REDIS_PORT', 6379),
password: this.configService.get<string>('REDIS_PASSWORD'),
db: this.configService.get<number>('REDIS_DB', 0),
});
}
async onModuleDestroy(): Promise<void> {
await this.client.quit();
}
// ZSET 操作
async zadd(key: string, score: number, member: string): Promise<number> {
return this.client.zadd(key, score, member);
}
async zcount(key: string, min: number | string, max: number | string): Promise<number> {
return this.client.zcount(key, min, max);
}
async zrangebyscore(
key: string,
min: number | string,
max: number | string,
...args: (string | number)[]
): Promise<string[]> {
return this.client.zrangebyscore(key, min, max, ...args);
}
async zremrangebyscore(key: string, min: number | string, max: number | string): Promise<number> {
return this.client.zremrangebyscore(key, min, max);
}
async zscore(key: string, member: string): Promise<string | null> {
return this.client.zscore(key, member);
}
// HyperLogLog 操作
async pfadd(key: string, ...elements: string[]): Promise<number> {
return this.client.pfadd(key, ...elements);
}
async pfcount(...keys: string[]): Promise<number> {
return this.client.pfcount(...keys);
}
// 通用操作
async expire(key: string, seconds: number): Promise<number> {
return this.client.expire(key, seconds);
}
async del(...keys: string[]): Promise<number> {
return this.client.del(...keys);
}
}
3.3 仓储实现
// src/infrastructure/persistence/repositories/event-log.repository.impl.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import {
IEventLogRepository,
DauQueryResult,
} from '../../../domain/repositories/event-log.repository.interface';
import { EventLog } from '../../../domain/entities/event-log.entity';
import { EventName } from '../../../domain/value-objects/event-name.vo';
import { EventLogMapper } from '../mappers/event-log.mapper';
@Injectable()
export class EventLogRepositoryImpl implements IEventLogRepository {
constructor(
private readonly prisma: PrismaService,
private readonly mapper: EventLogMapper,
) {}
async batchInsert(logs: EventLog[]): Promise<void> {
const data = logs.map((log) => this.mapper.toPersistence(log));
await this.prisma.eventLog.createMany({ data });
}
async insert(log: EventLog): Promise<EventLog> {
const data = this.mapper.toPersistence(log);
const created = await this.prisma.eventLog.create({ data });
return this.mapper.toDomain(created);
}
async queryDau(
eventName: EventName,
startTime: Date,
endTime: Date,
): Promise<DauQueryResult> {
// 使用原生 SQL 进行去重统计
const result = await this.prisma.$queryRaw<
{ total: bigint; province: string | null; city: string | null; count: bigint }[]
>`
WITH base AS (
SELECT
COALESCE(user_id::text, install_id) AS identifier,
properties->>'province' AS province,
properties->>'city' AS city
FROM analytics_event_log
WHERE event_name = ${eventName.value}
AND event_time >= ${startTime}
AND event_time < ${endTime}
),
unique_users AS (
SELECT DISTINCT identifier, province, city FROM base
)
SELECT
COUNT(DISTINCT identifier) AS total,
province,
city,
COUNT(*) AS count
FROM unique_users
GROUP BY GROUPING SETS ((), (province), (city))
`;
let total = 0;
const byProvince = new Map<string, number>();
const byCity = new Map<string, number>();
for (const row of result) {
if (row.province === null && row.city === null) {
total = Number(row.total);
} else if (row.province !== null && row.city === null) {
byProvince.set(row.province, Number(row.count));
} else if (row.city !== null && row.province === null) {
byCity.set(row.city, Number(row.count));
}
}
return { total, byProvince, byCity };
}
async findByTimeRange(
eventName: EventName,
startTime: Date,
endTime: Date,
limit?: number,
): Promise<EventLog[]> {
const records = await this.prisma.eventLog.findMany({
where: {
eventName: eventName.value,
eventTime: {
gte: startTime,
lt: endTime,
},
},
orderBy: { eventTime: 'desc' },
take: limit,
});
return records.map((r) => this.mapper.toDomain(r));
}
}
3.4 Kafka 事件发布
// src/infrastructure/kafka/event-publisher.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Kafka, Producer, logLevel } from 'kafkajs';
@Injectable()
export class EventPublisherService implements OnModuleInit, OnModuleDestroy {
private kafka: Kafka;
private producer: Producer;
private readonly topic: string;
constructor(private readonly configService: ConfigService) {
this.kafka = new Kafka({
clientId: 'analytics-presence-service',
brokers: this.configService.get<string>('KAFKA_BROKERS', 'localhost:9092').split(','),
logLevel: logLevel.WARN,
});
this.producer = this.kafka.producer();
this.topic = this.configService.get<string>('KAFKA_TOPIC_ANALYTICS', 'analytics-events');
}
async onModuleInit(): Promise<void> {
await this.producer.connect();
}
async onModuleDestroy(): Promise<void> {
await this.producer.disconnect();
}
async publish(eventType: string, payload: unknown): Promise<void> {
await this.producer.send({
topic: this.topic,
messages: [
{
key: eventType,
value: JSON.stringify({
eventType,
payload,
occurredAt: new Date().toISOString(),
}),
},
],
});
}
}
四、表现层设计
4.1 DTO 定义
// src/api/dto/request/batch-events.dto.ts
import { IsArray, IsString, IsOptional, IsNumber, ValidateNested, IsObject } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class EventItemDto {
@ApiProperty({ description: '事件名称', example: 'app_session_start' })
@IsString()
eventName: string;
@ApiPropertyOptional({ description: '用户ID (登录用户)', example: '12345' })
@IsOptional()
@IsString()
userId?: string;
@ApiProperty({ description: '安装ID', example: 'uuid-xxx-xxx' })
@IsString()
installId: string;
@ApiProperty({ description: '客户端时间戳 (秒)', example: 1732685100 })
@IsNumber()
clientTs: number;
@ApiPropertyOptional({ description: '事件属性' })
@IsOptional()
@IsObject()
properties?: Record<string, unknown>;
}
export class BatchEventsDto {
@ApiProperty({ type: [EventItemDto], description: '事件列表' })
@IsArray()
@ValidateNested({ each: true })
@Type(() => EventItemDto)
events: EventItemDto[];
}
// src/api/dto/request/heartbeat.dto.ts
import { IsString, IsNumber } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class HeartbeatDto {
@ApiProperty({ description: '安装ID', example: 'uuid-xxx-xxx' })
@IsString()
installId: string;
@ApiProperty({ description: 'App版本', example: '1.0.0' })
@IsString()
appVersion: string;
@ApiProperty({ description: '客户端时间戳 (秒)', example: 1732685100 })
@IsNumber()
clientTs: number;
}
// src/api/dto/request/query-dau.dto.ts
import { IsDateString, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class QueryDauDto {
@ApiProperty({ description: '开始日期', example: '2025-01-01' })
@IsDateString()
startDate: string;
@ApiProperty({ description: '结束日期', example: '2025-01-15' })
@IsDateString()
endDate: string;
}
// src/api/dto/response/online-count.dto.ts
import { ApiProperty } from '@nestjs/swagger';
export class OnlineCountResponseDto {
@ApiProperty({ description: '在线人数', example: 1234 })
count: number;
@ApiProperty({ description: '时间窗口(秒)', example: 180 })
windowSeconds: number;
@ApiProperty({ description: '查询时间', example: '2025-01-15T10:30:00.000Z' })
queriedAt: string;
}
// src/api/dto/response/dau-stats.dto.ts
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class DauDayItemDto {
@ApiProperty({ description: '日期', example: '2025-01-15' })
day: string;
@ApiProperty({ description: 'DAU', example: 5678 })
dauCount: number;
@ApiPropertyOptional({ description: '按省份统计' })
byProvince?: Record<string, number>;
@ApiPropertyOptional({ description: '按城市统计' })
byCity?: Record<string, number>;
}
export class DauStatsResponseDto {
@ApiProperty({ type: [DauDayItemDto], description: 'DAU数据' })
data: DauDayItemDto[];
@ApiProperty({ description: '记录数', example: 15 })
total: number;
}
4.2 控制器
// src/api/controllers/analytics.controller.ts
import { Controller, Post, Get, Body, Query, UseGuards } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { BatchEventsDto } from '../dto/request/batch-events.dto';
import { QueryDauDto } from '../dto/request/query-dau.dto';
import { DauStatsResponseDto } from '../dto/response/dau-stats.dto';
import { RecordEventsCommand } from '../../application/commands/record-events/record-events.command';
import { GetDauStatsQuery } from '../../application/queries/get-dau-stats/get-dau-stats.query';
import { Public } from '../../shared/decorators/public.decorator';
import { JwtAuthGuard } from '../../shared/guards/jwt-auth.guard';
@ApiTags('Analytics')
@Controller('analytics')
export class AnalyticsController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
@Post('events')
@Public()
@ApiOperation({ summary: '批量上报事件' })
async batchEvents(@Body() dto: BatchEventsDto) {
return this.commandBus.execute(new RecordEventsCommand(dto.events));
}
@Get('dau')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: '查询DAU统计' })
async getDauStats(@Query() dto: QueryDauDto): Promise<DauStatsResponseDto> {
return this.queryBus.execute(
new GetDauStatsQuery(new Date(dto.startDate), new Date(dto.endDate)),
);
}
}
// src/api/controllers/presence.controller.ts
import { Controller, Post, Get, Body, UseGuards, Req } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { HeartbeatDto } from '../dto/request/heartbeat.dto';
import { OnlineCountResponseDto } from '../dto/response/online-count.dto';
import { RecordHeartbeatCommand } from '../../application/commands/record-heartbeat/record-heartbeat.command';
import { GetOnlineCountQuery } from '../../application/queries/get-online-count/get-online-count.query';
import { JwtAuthGuard } from '../../shared/guards/jwt-auth.guard';
import { CurrentUser } from '../../shared/decorators/current-user.decorator';
@ApiTags('Presence')
@Controller('presence')
export class PresenceController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
@Post('heartbeat')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: '心跳上报' })
async heartbeat(
@CurrentUser('userId') userId: bigint,
@Body() dto: HeartbeatDto,
) {
return this.commandBus.execute(
new RecordHeartbeatCommand(
userId,
dto.installId,
dto.appVersion,
dto.clientTs,
),
);
}
@Get('online-count')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: '获取当前在线人数' })
async getOnlineCount(): Promise<OnlineCountResponseDto> {
const result = await this.queryBus.execute(new GetOnlineCountQuery());
return {
count: result.count,
windowSeconds: result.windowSeconds,
queriedAt: result.queriedAt.toISOString(),
};
}
}
五、共享层工具
// src/shared/utils/timezone.util.ts
import { format, startOfDay, endOfDay } from 'date-fns';
import { toZonedTime, fromZonedTime } from 'date-fns-tz';
const DEFAULT_TIMEZONE = 'Asia/Shanghai';
/**
* 获取指定时区的一天开始时间
*/
export function startOfDayInTimezone(date: Date, timezone: string = DEFAULT_TIMEZONE): Date {
const zonedDate = toZonedTime(date, timezone);
const start = startOfDay(zonedDate);
return fromZonedTime(start, timezone);
}
/**
* 获取指定时区的一天结束时间
*/
export function endOfDayInTimezone(date: Date, timezone: string = DEFAULT_TIMEZONE): Date {
const zonedDate = toZonedTime(date, timezone);
const end = endOfDay(zonedDate);
return fromZonedTime(end, timezone);
}
/**
* 格式化为日期Key (YYYY-MM-DD)
*/
export function formatToDateKey(date: Date, timezone: string = DEFAULT_TIMEZONE): string {
const zonedDate = toZonedTime(date, timezone);
return format(zonedDate, 'yyyy-MM-dd');
}
六、环境配置
# .env.example
# 应用配置
NODE_ENV=development
PORT=3001
# 数据库
DATABASE_URL=postgresql://user:password@localhost:5432/rwa_analytics?schema=public
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
# JWT (与 Identity Service 共用)
JWT_SECRET=your-jwt-secret
JWT_EXPIRES_IN=7d
# Kafka
KAFKA_BROKERS=localhost:9092
KAFKA_TOPIC_ANALYTICS=analytics-events
# 时区
TZ=Asia/Shanghai
七、领域不变式
- InstallId 不可为空 - 每个事件必须携带 InstallId
- DAU 去重逻辑 - 优先使用 userId,其次使用 installId
- 时区统一 - 所有 DAU 计算使用 Asia/Shanghai 时区
- 心跳仅限登录用户 - 未登录用户不参与在线统计
- 事件日志 append-only - 不允许修改或删除事件记录
- 在线判定窗口 - 默认 180 秒(3分钟)
- 心跳间隔 - 客户端前台状态下每 60 秒上报一次
八、与其他上下文的集成
8.1 消费 Identity Context 事件
// src/infrastructure/kafka/event-consumer.service.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import { Kafka, Consumer, EachMessagePayload } from 'kafkajs';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class EventConsumerService implements OnModuleInit {
private consumer: Consumer;
constructor(
private readonly configService: ConfigService,
private readonly commandBus: CommandBus,
) {
const kafka = new Kafka({
clientId: 'analytics-presence-consumer',
brokers: this.configService.get<string>('KAFKA_BROKERS', 'localhost:9092').split(','),
});
this.consumer = kafka.consumer({ groupId: 'analytics-presence-group' });
}
async onModuleInit(): Promise<void> {
await this.consumer.connect();
// 订阅 Identity Context 的用户创建事件
await this.consumer.subscribe({ topic: 'identity-events', fromBeginning: false });
await this.consumer.run({
eachMessage: async (payload: EachMessagePayload) => {
await this.handleMessage(payload);
},
});
}
private async handleMessage(payload: EachMessagePayload): Promise<void> {
const { message } = payload;
const key = message.key?.toString();
const value = message.value?.toString();
if (!value) return;
const event = JSON.parse(value);
// 处理用户创建事件 - 可用于初始化用户统计
if (key === 'user.account.created') {
// 可选:记录注册事件
}
}
}
8.2 为 Reporting Context 提供数据
// src/api/controllers/internal.controller.ts
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { InternalGuard } from '../../shared/guards/internal.guard';
import { GetDauStatsQuery } from '../../application/queries/get-dau-stats/get-dau-stats.query';
@ApiTags('Internal')
@Controller('internal/analytics')
@UseGuards(InternalGuard)
export class InternalController {
constructor(private readonly queryBus: QueryBus) {}
@Get('dau')
@ApiOperation({ summary: '内部接口: 获取DAU数据' })
async getDauForReporting(
@Query('startDate') startDate: string,
@Query('endDate') endDate: string,
) {
return this.queryBus.execute(
new GetDauStatsQuery(new Date(startDate), new Date(endDate)),
);
}
}
九、快速开始
1. 安装依赖
npm install
2. 配置环境变量
cp .env.example .env
# 编辑 .env 文件
3. 初始化数据库
npm run prisma:generate
npm run prisma:migrate
4. 启动服务
# 开发模式
npm run start:dev
# 生产模式
npm run build
npm run start:prod
5. Docker 部署
docker-compose up -d
API 文档
启动服务后访问: http://localhost:3001/api/docs
主要 API
| 方法 | 路径 | 说明 | 认证 |
|---|---|---|---|
| POST | /analytics/events | 批量上报事件 | 可选 |
| GET | /analytics/dau | 查询DAU统计 | 必需 |
| POST | /presence/heartbeat | 心跳上报 | 必需 |
| GET | /presence/online-count | 获取在线人数 | 必需 |
License
Proprietary