rwadurian/backend/services/presence-service/analytics-presence-service-...

62 KiB
Raw Blame History

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

七、领域不变式

  1. InstallId 不可为空 - 每个事件必须携带 InstallId
  2. DAU 去重逻辑 - 优先使用 userId其次使用 installId
  3. 时区统一 - 所有 DAU 计算使用 Asia/Shanghai 时区
  4. 心跳仅限登录用户 - 未登录用户不参与在线统计
  5. 事件日志 append-only - 不允许修改或删除事件记录
  6. 在线判定窗口 - 默认 180 秒3分钟
  7. 心跳间隔 - 客户端前台状态下每 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