feat(telemetry): 设备字段提升为顶层结构化列(Amplitude 风格)

将 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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-05 09:50:48 -08:00
parent 482df12f91
commit 6bca65e434
9 changed files with 155 additions and 21 deletions

View File

@ -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");

View File

@ -13,18 +13,25 @@ datasource db {
// 事件日志表 (append-only) // 事件日志表 (append-only)
model EventLog { model EventLog {
id BigInt @id @default(autoincrement()) id BigInt @id @default(autoincrement())
userId String? @map("user_id") @db.VarChar(20) // userSerialNum, e.g. "D25121400005" userId String? @map("user_id") @db.VarChar(20) // userSerialNum, e.g. "D25121400005"
installId String @map("install_id") @db.VarChar(64) installId String @map("install_id") @db.VarChar(64)
eventName String @map("event_name") @db.VarChar(64) eventName String @map("event_name") @db.VarChar(64)
eventTime DateTime @map("event_time") @db.Timestamptz() eventTime DateTime @map("event_time") @db.Timestamptz()
properties Json? @db.JsonB deviceBrand String? @map("device_brand") @db.VarChar(64)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz() 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([eventTime], name: "idx_event_log_event_time")
@@index([eventName], name: "idx_event_log_event_name") @@index([eventName], name: "idx_event_log_event_name")
@@index([eventName, eventTime], name: "idx_event_log_event_name_time") @@index([eventName, eventTime], name: "idx_event_log_event_name_time")
@@index([userId], name: "idx_event_log_user_id") @@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") @@map("analytics_event_log")
} }

View File

@ -20,7 +20,32 @@ export class EventItemDto {
@IsNumber() @IsNumber()
clientTs: number; 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() @IsOptional()
@IsObject() @IsObject()
properties?: Record<string, unknown>; properties?: Record<string, unknown>;

View File

@ -3,6 +3,11 @@ export interface EventItemDto {
userId?: string; userId?: string;
installId: string; installId: string;
clientTs: number; clientTs: number;
deviceBrand?: string;
deviceModel?: string;
deviceOs?: string;
appVersion?: string;
locale?: string;
properties?: Record<string, unknown>; properties?: Record<string, unknown>;
} }

View File

@ -72,9 +72,9 @@ export class RecordEventsHandler implements ICommandHandler<RecordEventsCommand>
log.installId.value, log.installId.value,
log.eventTime, log.eventTime,
{ {
appVersion: log.properties.appVersion, appVersion: log.appVersion ?? log.properties.appVersion,
os: log.properties.os, os: log.deviceOs ?? log.properties.os,
osVersion: log.properties.osVersion, osVersion: log.deviceOs ?? log.properties.osVersion,
}, },
), ),
); );
@ -101,6 +101,11 @@ export class RecordEventsHandler implements ICommandHandler<RecordEventsCommand>
installId: InstallId.fromString(dto.installId), installId: InstallId.fromString(dto.installId),
eventName: EventName.fromString(dto.eventName), eventName: EventName.fromString(dto.eventName),
eventTime: new Date(dto.clientTs * 1000), 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 ?? {}), properties: EventProperties.fromData(dto.properties ?? {}),
}); });
} }

View File

@ -8,6 +8,11 @@ export class EventLog {
private _installId: InstallId; private _installId: InstallId;
private _eventName: EventName; private _eventName: EventName;
private _eventTime: Date; 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 _properties: EventProperties;
private _createdAt: Date; private _createdAt: Date;
@ -34,6 +39,26 @@ export class EventLog {
return this._eventTime; 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 { get properties(): EventProperties {
return this._properties; return this._properties;
} }
@ -56,6 +81,11 @@ export class EventLog {
installId: InstallId; installId: InstallId;
eventName: EventName; eventName: EventName;
eventTime: Date; eventTime: Date;
deviceBrand?: string | null;
deviceModel?: string | null;
deviceOs?: string | null;
appVersion?: string | null;
locale?: string | null;
properties?: EventProperties; properties?: EventProperties;
}): EventLog { }): EventLog {
const log = new EventLog(); const log = new EventLog();
@ -64,6 +94,11 @@ export class EventLog {
log._installId = props.installId; log._installId = props.installId;
log._eventName = props.eventName; log._eventName = props.eventName;
log._eventTime = props.eventTime; 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._properties = props.properties ?? EventProperties.empty();
log._createdAt = new Date(); log._createdAt = new Date();
return log; return log;
@ -76,6 +111,11 @@ export class EventLog {
installId: InstallId; installId: InstallId;
eventName: EventName; eventName: EventName;
eventTime: Date; eventTime: Date;
deviceBrand: string | null;
deviceModel: string | null;
deviceOs: string | null;
appVersion: string | null;
locale: string | null;
properties: EventProperties; properties: EventProperties;
createdAt: Date; createdAt: Date;
}): EventLog { }): EventLog {
@ -85,6 +125,11 @@ export class EventLog {
log._installId = props.installId; log._installId = props.installId;
log._eventName = props.eventName; log._eventName = props.eventName;
log._eventTime = props.eventTime; 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._properties = props.properties;
log._createdAt = props.createdAt; log._createdAt = props.createdAt;
return log; return log;

View File

@ -16,6 +16,11 @@ export class EventLogMapper {
installId: InstallId.fromString(prisma.installId), installId: InstallId.fromString(prisma.installId),
eventName: EventName.fromString(prisma.eventName), eventName: EventName.fromString(prisma.eventName),
eventTime: prisma.eventTime, 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) ?? {}), properties: EventProperties.fromData((prisma.properties as EventPropertiesData) ?? {}),
createdAt: prisma.createdAt, createdAt: prisma.createdAt,
}); });
@ -27,6 +32,11 @@ export class EventLogMapper {
installId: domain.installId.value, installId: domain.installId.value,
eventName: domain.eventName.value, eventName: domain.eventName.value,
eventTime: domain.eventTime, 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, properties: domain.properties.toJSON() as Prisma.InputJsonValue,
}; };
} }

View File

@ -118,15 +118,31 @@ class TelemetryEvent extends Equatable {
}; };
} }
/// API /// API Amplitude 便
/// : eventName, userId, installId, clientTs, deviceBrand, deviceModel,
/// deviceOs, appVersion, locale
/// properties:
Map<String, dynamic> toServerJson() { Map<String, dynamic> toServerJson() {
// properties
final props = Map<String, dynamic>.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 { return {
'eventName': name, 'eventName': name,
'userId': userId, 'userId': userId,
'installId': installId, 'installId': installId,
'clientTs': timestamp.millisecondsSinceEpoch ~/ 1000, 'clientTs': timestamp.millisecondsSinceEpoch ~/ 1000,
'deviceBrand': deviceBrand,
'deviceModel': deviceModel,
'deviceOs': deviceOs,
'appVersion': appVersion,
'locale': locale,
'properties': { 'properties': {
...?properties, ...props,
'eventId': eventId, 'eventId': eventId,
'type': type.name, 'type': type.name,
'level': level.name, 'level': level.name,

View File

@ -118,21 +118,31 @@ class TelemetryEvent extends Equatable {
}; };
} }
/// API /// API Amplitude 便
/// presence-service : /// : eventName, userId, installId, clientTs, deviceBrand, deviceModel,
/// - eventName: string (required) /// deviceOs, appVersion, locale
/// - userId: string (optional) - userSerialNum, e.g. "D25121400005" /// properties:
/// - installId: string (required)
/// - clientTs: number (required) - Unix timestamp in seconds
/// - properties: object (optional)
Map<String, dynamic> toServerJson() { Map<String, dynamic> toServerJson() {
// properties
final props = Map<String, dynamic>.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 { return {
'eventName': name, 'eventName': name,
'userId': userId, 'userId': userId,
'installId': installId, 'installId': installId,
'clientTs': timestamp.millisecondsSinceEpoch ~/ 1000, 'clientTs': timestamp.millisecondsSinceEpoch ~/ 1000,
'deviceBrand': deviceBrand,
'deviceModel': deviceModel,
'deviceOs': deviceOs,
'appVersion': appVersion,
'locale': locale,
'properties': { 'properties': {
...?properties, ...props,
'eventId': eventId, 'eventId': eventId,
'type': type.name, 'type': type.name,
'level': level.name, 'level': level.name,