From 6bca65e43424e2651b7de11b41ce3c0b51e3076e Mon Sep 17 00:00:00 2001 From: hailin Date: Thu, 5 Mar 2026 09:50:48 -0800 Subject: [PATCH] =?UTF-8?q?feat(telemetry):=20=E8=AE=BE=E5=A4=87=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E6=8F=90=E5=8D=87=E4=B8=BA=E9=A1=B6=E5=B1=82=E7=BB=93?= =?UTF-8?q?=E6=9E=84=E5=8C=96=E5=88=97=EF=BC=88Amplitude=20=E9=A3=8E?= =?UTF-8?q?=E6=A0=BC=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 device_brand/device_model/device_os/app_version/locale 从 JSONB properties 提升为 analytics_event_log 表的独立列,并建立索引,支持亿级数据量下的高效 按设备维度查询和分组统计。 前端 (mining-app + mobile-app): - toServerJson() 从 properties 中提取设备字段,以顶层字段发送给服务端 - 本地存储格式不变(properties 仍保留设备字段,便于离线队列完整性) 后端 (presence-service): - Prisma schema: EventLog 新增 deviceBrand/deviceModel/deviceOs/appVersion/locale 列 - Migration: ALTER TABLE 添加 5 列 + 2 个索引 - DTO/Command: EventItemDto 接收顶层设备字段 - Entity: EventLog 新增 5 个字段及 getter - Mapper: toDomain/toPersistence 映射新字段 - Handler: toEventLog 从 DTO 读取设备字段;SessionStartedEvent 优先使用顶层字段 Co-Authored-By: Claude Sonnet 4.6 --- .../migration.sql | 11 +++++ .../presence-service/prisma/schema.prisma | 21 ++++++--- .../src/api/dto/request/batch-events.dto.ts | 27 ++++++++++- .../record-events/record-events.command.ts | 5 +++ .../record-events/record-events.handler.ts | 11 +++-- .../src/domain/entities/event-log.entity.ts | 45 +++++++++++++++++++ .../persistence/mappers/event-log.mapper.ts | 10 +++++ .../telemetry/models/telemetry_event.dart | 20 ++++++++- .../telemetry/models/telemetry_event.dart | 26 +++++++---- 9 files changed, 155 insertions(+), 21 deletions(-) create mode 100644 backend/services/presence-service/prisma/migrations/20250305000000_add_device_fields_to_event_log/migration.sql diff --git a/backend/services/presence-service/prisma/migrations/20250305000000_add_device_fields_to_event_log/migration.sql b/backend/services/presence-service/prisma/migrations/20250305000000_add_device_fields_to_event_log/migration.sql new file mode 100644 index 00000000..38996c4b --- /dev/null +++ b/backend/services/presence-service/prisma/migrations/20250305000000_add_device_fields_to_event_log/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable: add device context columns to analytics_event_log +ALTER TABLE "analytics_event_log" + ADD COLUMN "device_brand" VARCHAR(64), + ADD COLUMN "device_model" VARCHAR(64), + ADD COLUMN "device_os" VARCHAR(32), + ADD COLUMN "app_version" VARCHAR(32), + ADD COLUMN "locale" VARCHAR(16); + +-- CreateIndex +CREATE INDEX "idx_event_log_device_brand" ON "analytics_event_log"("device_brand"); +CREATE INDEX "idx_event_log_app_version" ON "analytics_event_log"("app_version"); diff --git a/backend/services/presence-service/prisma/schema.prisma b/backend/services/presence-service/prisma/schema.prisma index 58d962b3..1d0a850b 100644 --- a/backend/services/presence-service/prisma/schema.prisma +++ b/backend/services/presence-service/prisma/schema.prisma @@ -13,18 +13,25 @@ datasource db { // 事件日志表 (append-only) model EventLog { - id BigInt @id @default(autoincrement()) - userId String? @map("user_id") @db.VarChar(20) // userSerialNum, e.g. "D25121400005" - 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() + id BigInt @id @default(autoincrement()) + userId String? @map("user_id") @db.VarChar(20) // userSerialNum, e.g. "D25121400005" + installId String @map("install_id") @db.VarChar(64) + eventName String @map("event_name") @db.VarChar(64) + eventTime DateTime @map("event_time") @db.Timestamptz() + deviceBrand String? @map("device_brand") @db.VarChar(64) + deviceModel String? @map("device_model") @db.VarChar(64) + deviceOs String? @map("device_os") @db.VarChar(32) + appVersion String? @map("app_version") @db.VarChar(32) + locale String? @map("locale") @db.VarChar(16) + 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") @@index([userId], name: "idx_event_log_user_id") + @@index([deviceBrand], name: "idx_event_log_device_brand") + @@index([appVersion], name: "idx_event_log_app_version") @@map("analytics_event_log") } diff --git a/backend/services/presence-service/src/api/dto/request/batch-events.dto.ts b/backend/services/presence-service/src/api/dto/request/batch-events.dto.ts index 981e8b75..50a9b1b6 100644 --- a/backend/services/presence-service/src/api/dto/request/batch-events.dto.ts +++ b/backend/services/presence-service/src/api/dto/request/batch-events.dto.ts @@ -20,7 +20,32 @@ export class EventItemDto { @IsNumber() clientTs: number; - @ApiPropertyOptional({ description: '事件属性' }) + @ApiPropertyOptional({ description: '设备品牌', example: 'Xiaomi' }) + @IsOptional() + @IsString() + deviceBrand?: string; + + @ApiPropertyOptional({ description: '设备型号', example: 'Redmi Note 12' }) + @IsOptional() + @IsString() + deviceModel?: string; + + @ApiPropertyOptional({ description: '操作系统版本', example: '13' }) + @IsOptional() + @IsString() + deviceOs?: string; + + @ApiPropertyOptional({ description: 'App版本号', example: '1.2.0' }) + @IsOptional() + @IsString() + appVersion?: string; + + @ApiPropertyOptional({ description: '语言/地区', example: 'zh_CN' }) + @IsOptional() + @IsString() + locale?: string; + + @ApiPropertyOptional({ description: '事件专属属性' }) @IsOptional() @IsObject() properties?: Record; diff --git a/backend/services/presence-service/src/application/commands/record-events/record-events.command.ts b/backend/services/presence-service/src/application/commands/record-events/record-events.command.ts index 92553b81..c4bf94bd 100644 --- a/backend/services/presence-service/src/application/commands/record-events/record-events.command.ts +++ b/backend/services/presence-service/src/application/commands/record-events/record-events.command.ts @@ -3,6 +3,11 @@ export interface EventItemDto { userId?: string; installId: string; clientTs: number; + deviceBrand?: string; + deviceModel?: string; + deviceOs?: string; + appVersion?: string; + locale?: string; properties?: Record; } diff --git a/backend/services/presence-service/src/application/commands/record-events/record-events.handler.ts b/backend/services/presence-service/src/application/commands/record-events/record-events.handler.ts index dddc3b79..40535001 100644 --- a/backend/services/presence-service/src/application/commands/record-events/record-events.handler.ts +++ b/backend/services/presence-service/src/application/commands/record-events/record-events.handler.ts @@ -72,9 +72,9 @@ export class RecordEventsHandler implements ICommandHandler log.installId.value, log.eventTime, { - appVersion: log.properties.appVersion, - os: log.properties.os, - osVersion: log.properties.osVersion, + appVersion: log.appVersion ?? log.properties.appVersion, + os: log.deviceOs ?? log.properties.os, + osVersion: log.deviceOs ?? log.properties.osVersion, }, ), ); @@ -101,6 +101,11 @@ export class RecordEventsHandler implements ICommandHandler installId: InstallId.fromString(dto.installId), eventName: EventName.fromString(dto.eventName), eventTime: new Date(dto.clientTs * 1000), + deviceBrand: dto.deviceBrand ?? null, + deviceModel: dto.deviceModel ?? null, + deviceOs: dto.deviceOs ?? null, + appVersion: dto.appVersion ?? null, + locale: dto.locale ?? null, properties: EventProperties.fromData(dto.properties ?? {}), }); } diff --git a/backend/services/presence-service/src/domain/entities/event-log.entity.ts b/backend/services/presence-service/src/domain/entities/event-log.entity.ts index dabeb63e..1917a0e7 100644 --- a/backend/services/presence-service/src/domain/entities/event-log.entity.ts +++ b/backend/services/presence-service/src/domain/entities/event-log.entity.ts @@ -8,6 +8,11 @@ export class EventLog { private _installId: InstallId; private _eventName: EventName; private _eventTime: Date; + private _deviceBrand: string | null; + private _deviceModel: string | null; + private _deviceOs: string | null; + private _appVersion: string | null; + private _locale: string | null; private _properties: EventProperties; private _createdAt: Date; @@ -34,6 +39,26 @@ export class EventLog { return this._eventTime; } + get deviceBrand(): string | null { + return this._deviceBrand; + } + + get deviceModel(): string | null { + return this._deviceModel; + } + + get deviceOs(): string | null { + return this._deviceOs; + } + + get appVersion(): string | null { + return this._appVersion; + } + + get locale(): string | null { + return this._locale; + } + get properties(): EventProperties { return this._properties; } @@ -56,6 +81,11 @@ export class EventLog { installId: InstallId; eventName: EventName; eventTime: Date; + deviceBrand?: string | null; + deviceModel?: string | null; + deviceOs?: string | null; + appVersion?: string | null; + locale?: string | null; properties?: EventProperties; }): EventLog { const log = new EventLog(); @@ -64,6 +94,11 @@ export class EventLog { log._installId = props.installId; log._eventName = props.eventName; log._eventTime = props.eventTime; + log._deviceBrand = props.deviceBrand ?? null; + log._deviceModel = props.deviceModel ?? null; + log._deviceOs = props.deviceOs ?? null; + log._appVersion = props.appVersion ?? null; + log._locale = props.locale ?? null; log._properties = props.properties ?? EventProperties.empty(); log._createdAt = new Date(); return log; @@ -76,6 +111,11 @@ export class EventLog { installId: InstallId; eventName: EventName; eventTime: Date; + deviceBrand: string | null; + deviceModel: string | null; + deviceOs: string | null; + appVersion: string | null; + locale: string | null; properties: EventProperties; createdAt: Date; }): EventLog { @@ -85,6 +125,11 @@ export class EventLog { log._installId = props.installId; log._eventName = props.eventName; log._eventTime = props.eventTime; + log._deviceBrand = props.deviceBrand; + log._deviceModel = props.deviceModel; + log._deviceOs = props.deviceOs; + log._appVersion = props.appVersion; + log._locale = props.locale; log._properties = props.properties; log._createdAt = props.createdAt; return log; diff --git a/backend/services/presence-service/src/infrastructure/persistence/mappers/event-log.mapper.ts b/backend/services/presence-service/src/infrastructure/persistence/mappers/event-log.mapper.ts index 2e0a0dda..640a5dc4 100644 --- a/backend/services/presence-service/src/infrastructure/persistence/mappers/event-log.mapper.ts +++ b/backend/services/presence-service/src/infrastructure/persistence/mappers/event-log.mapper.ts @@ -16,6 +16,11 @@ export class EventLogMapper { installId: InstallId.fromString(prisma.installId), eventName: EventName.fromString(prisma.eventName), eventTime: prisma.eventTime, + deviceBrand: prisma.deviceBrand, + deviceModel: prisma.deviceModel, + deviceOs: prisma.deviceOs, + appVersion: prisma.appVersion, + locale: prisma.locale, properties: EventProperties.fromData((prisma.properties as EventPropertiesData) ?? {}), createdAt: prisma.createdAt, }); @@ -27,6 +32,11 @@ export class EventLogMapper { installId: domain.installId.value, eventName: domain.eventName.value, eventTime: domain.eventTime, + deviceBrand: domain.deviceBrand, + deviceModel: domain.deviceModel, + deviceOs: domain.deviceOs, + appVersion: domain.appVersion, + locale: domain.locale, properties: domain.properties.toJSON() as Prisma.InputJsonValue, }; } diff --git a/frontend/mining-app/lib/core/telemetry/models/telemetry_event.dart b/frontend/mining-app/lib/core/telemetry/models/telemetry_event.dart index a2695209..bb280eda 100644 --- a/frontend/mining-app/lib/core/telemetry/models/telemetry_event.dart +++ b/frontend/mining-app/lib/core/telemetry/models/telemetry_event.dart @@ -118,15 +118,31 @@ class TelemetryEvent extends Equatable { }; } - /// 转换为服务端 API 格式 + /// 转换为服务端 API 格式(Amplitude 风格:设备字段为顶层独立列,方便服务端索引) + /// 顶层字段: eventName, userId, installId, clientTs, deviceBrand, deviceModel, + /// deviceOs, appVersion, locale + /// properties: 仅保留事件专属数据(页面名、金额等) Map toServerJson() { + // 从 properties 中提取设备字段(提升为顶层),剩余为事件专属数据 + final props = Map.from(properties ?? {}); + final deviceBrand = props.remove('device_brand'); + final deviceModel = props.remove('device_model'); + final deviceOs = props.remove('device_os'); + final appVersion = props.remove('app_version'); + final locale = props.remove('locale'); + return { 'eventName': name, 'userId': userId, 'installId': installId, 'clientTs': timestamp.millisecondsSinceEpoch ~/ 1000, + 'deviceBrand': deviceBrand, + 'deviceModel': deviceModel, + 'deviceOs': deviceOs, + 'appVersion': appVersion, + 'locale': locale, 'properties': { - ...?properties, + ...props, 'eventId': eventId, 'type': type.name, 'level': level.name, diff --git a/frontend/mobile-app/lib/core/telemetry/models/telemetry_event.dart b/frontend/mobile-app/lib/core/telemetry/models/telemetry_event.dart index ca83a878..e7bd1bcb 100644 --- a/frontend/mobile-app/lib/core/telemetry/models/telemetry_event.dart +++ b/frontend/mobile-app/lib/core/telemetry/models/telemetry_event.dart @@ -118,21 +118,31 @@ class TelemetryEvent extends Equatable { }; } - /// 转换为服务端 API 格式 - /// 后端 presence-service 期望的格式: - /// - eventName: string (required) - /// - userId: string (optional) - userSerialNum, e.g. "D25121400005" - /// - installId: string (required) - /// - clientTs: number (required) - Unix timestamp in seconds - /// - properties: object (optional) + /// 转换为服务端 API 格式(Amplitude 风格:设备字段为顶层独立列,方便服务端索引) + /// 顶层字段: eventName, userId, installId, clientTs, deviceBrand, deviceModel, + /// deviceOs, appVersion, locale + /// properties: 仅保留事件专属数据(页面名、金额等) Map toServerJson() { + // 从 properties 中提取设备字段(提升为顶层),剩余为事件专属数据 + final props = Map.from(properties ?? {}); + final deviceBrand = props.remove('device_brand'); + final deviceModel = props.remove('device_model'); + final deviceOs = props.remove('device_os'); + final appVersion = props.remove('app_version'); + final locale = props.remove('locale'); + return { 'eventName': name, 'userId': userId, 'installId': installId, 'clientTs': timestamp.millisecondsSinceEpoch ~/ 1000, + 'deviceBrand': deviceBrand, + 'deviceModel': deviceModel, + 'deviceOs': deviceOs, + 'appVersion': appVersion, + 'locale': locale, 'properties': { - ...?properties, + ...props, 'eventId': eventId, 'type': type.name, 'level': level.name,