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:
parent
482df12f91
commit
6bca65e434
|
|
@ -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");
|
||||||
|
|
@ -18,6 +18,11 @@ model EventLog {
|
||||||
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()
|
||||||
|
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
|
properties Json? @db.JsonB
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
||||||
|
|
||||||
|
|
@ -25,6 +30,8 @@ model EventLog {
|
||||||
@@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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ?? {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue