2219 lines
62 KiB
Markdown
2219 lines
62 KiB
Markdown
# 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
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```typescript
|
||
// 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 值对象
|
||
|
||
```typescript
|
||
// 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;
|
||
}
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
// 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;
|
||
}
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
// 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;
|
||
}
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
// 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,
|
||
});
|
||
}
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
// 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 领域事件
|
||
|
||
```typescript
|
||
// 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;
|
||
},
|
||
) {}
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
// 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,
|
||
) {}
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
// 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 仓储接口
|
||
|
||
```typescript
|
||
// 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');
|
||
```
|
||
|
||
```typescript
|
||
// 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');
|
||
```
|
||
|
||
```typescript
|
||
// 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 领域服务
|
||
|
||
```typescript
|
||
// 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,
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```typescript
|
||
// 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[]) {}
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```typescript
|
||
// 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,
|
||
) {}
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```typescript
|
||
// src/application/commands/calculate-dau/calculate-dau.command.ts
|
||
|
||
export class CalculateDauCommand {
|
||
constructor(public readonly date: Date) {}
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```typescript
|
||
// src/application/queries/get-online-count/get-online-count.query.ts
|
||
|
||
export class GetOnlineCountQuery {
|
||
constructor() {}
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```typescript
|
||
// src/application/queries/get-dau-stats/get-dau-stats.query.ts
|
||
|
||
export class GetDauStatsQuery {
|
||
constructor(
|
||
public readonly startDate: Date,
|
||
public readonly endDate: Date,
|
||
) {}
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
// 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 定时任务
|
||
|
||
```typescript
|
||
// 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
|
||
// 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 仓储实现
|
||
|
||
```typescript
|
||
// 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;
|
||
}
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
// 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 仓储实现
|
||
|
||
```typescript
|
||
// 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 事件发布
|
||
|
||
```typescript
|
||
// 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 定义
|
||
|
||
```typescript
|
||
// 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[];
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
// 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;
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
// 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;
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
// 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;
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
// 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 控制器
|
||
|
||
```typescript
|
||
// 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)),
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
// 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(),
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 五、共享层工具
|
||
|
||
```typescript
|
||
// 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');
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 六、环境配置
|
||
|
||
```bash
|
||
# .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
|
||
```
|
||
|
||
---
|
||
|
||
## 七、领域不变式
|
||
|
||
1. **InstallId 不可为空** - 每个事件必须携带 InstallId
|
||
2. **DAU 去重逻辑** - 优先使用 userId,其次使用 installId
|
||
3. **时区统一** - 所有 DAU 计算使用 Asia/Shanghai 时区
|
||
4. **心跳仅限登录用户** - 未登录用户不参与在线统计
|
||
5. **事件日志 append-only** - 不允许修改或删除事件记录
|
||
6. **在线判定窗口** - 默认 180 秒(3分钟)
|
||
7. **心跳间隔** - 客户端前台状态下每 60 秒上报一次
|
||
|
||
---
|
||
|
||
## 八、与其他上下文的集成
|
||
|
||
### 8.1 消费 Identity Context 事件
|
||
|
||
```typescript
|
||
// 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 提供数据
|
||
|
||
```typescript
|
||
// 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. 安装依赖
|
||
|
||
```bash
|
||
npm install
|
||
```
|
||
|
||
### 2. 配置环境变量
|
||
|
||
```bash
|
||
cp .env.example .env
|
||
# 编辑 .env 文件
|
||
```
|
||
|
||
### 3. 初始化数据库
|
||
|
||
```bash
|
||
npm run prisma:generate
|
||
npm run prisma:migrate
|
||
```
|
||
|
||
### 4. 启动服务
|
||
|
||
```bash
|
||
# 开发模式
|
||
npm run start:dev
|
||
|
||
# 生产模式
|
||
npm run build
|
||
npm run start:prod
|
||
```
|
||
|
||
### 5. Docker 部署
|
||
|
||
```bash
|
||
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
|