From cc06638e0ecec94caf0e7a2c1911992b64b21c37 Mon Sep 17 00:00:00 2001 From: hailin Date: Mon, 15 Dec 2025 06:55:25 -0800 Subject: [PATCH] =?UTF-8?q?feat(telemetry):=20=E5=B0=86userId=E6=94=B9?= =?UTF-8?q?=E4=B8=BAuserSerialNum=E5=AD=97=E7=AC=A6=E4=B8=B2=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E5=B9=B6=E5=AE=8C=E5=96=84=E9=81=A5=E6=B5=8B=E8=BF=BD?= =?UTF-8?q?=E8=B8=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (presence-service): - 将EventLog.userId从BigInt改为String类型,存储userSerialNum(如D25121400005) - 更新Prisma schema,userId字段改为VarChar(20)并添加索引 - 更新心跳相关命令和事件,统一使用userSerialNum字符串 - 添加数据库迁移文件 - 更新相关单元测试和集成测试 Frontend (mobile-app): - TelemetryEvent新增toServerJson()方法,格式化为后端API期望的格式 - AccountService登录/恢复时设置TelemetryService的userId - MultiAccountService切换账号时同步更新TelemetryService的userId - 退出登录时清除TelemetryService的userId - AuthProvider初始化时设置userId 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .claude/settings.local.json | 3 ++- .../migration.sql | 9 +++++++ .../presence-service/prisma/schema.prisma | 3 ++- .../api/controllers/presence.controller.ts | 2 +- .../record-events/record-events.handler.ts | 2 +- .../record-heartbeat.command.ts | 2 +- .../record-heartbeat.handler.ts | 4 +-- .../src/domain/entities/event-log.entity.ts | 12 ++++----- .../domain/events/heartbeat-received.event.ts | 2 +- .../domain/events/session-started.event.ts | 2 +- .../commands/record-heartbeat.handler.spec.ts | 25 +++++++++++++------ .../domain/entities/event-log.entity.spec.ts | 20 +++++++-------- .../lib/core/services/account_service.dart | 13 ++++++++++ .../core/services/multi_account_service.dart | 13 ++++++++++ .../telemetry/models/telemetry_event.dart | 25 +++++++++++++++++++ .../uploader/telemetry_uploader.dart | 4 +-- .../presentation/providers/auth_provider.dart | 5 ++++ 17 files changed, 111 insertions(+), 35 deletions(-) create mode 100644 backend/services/presence-service/prisma/migrations/20251215100000_change_user_id_to_string/migration.sql diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 78aad904..4d5e23a5 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -192,7 +192,8 @@ "Bash(git commit -m \"$(cat <<''EOF''\nfeat(profile): 添加我的伞下功能 - 展示下级用户树形结构\n\n- 后端新增 GET /referral/user/:accountSequence/direct-referrals API\n- 前端新增伞下树组件,支持懒加载、缓存、展开/收起\n- 使用 CustomPaint 绘制父子节点连接线\n- 超出屏幕宽度时显示省略号,点击弹出底部列表\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 \nEOF\n)\")", "Bash(git tag -a v1.0.0-beta1 -m \"$(cat <<''EOF''\nv1.0.0-beta1: 用户首次测试通过\n\n主要修复:\n- fix(reward): 修复 accountSequence 转 userId 时字母前缀导致的 BigInt 转换失败\n- fix(authorization): 修复下级团队认种数重复减去自己认种数的 BUG\n- fix(frontend): 修正权益金额显示与后端实际配置一致\n\n功能完善:\n- 社区权益激活正常\n- 市团队/省团队/市区域/省区域权益考核显示\n- 我的伞下功能 - 展示下级用户树形结构\n\n此版本可作为回滚基准点\nEOF\n)\")", "Bash(ls -la \"c:\\Users\\dong\\Desktop\\rwadurian\\frontend\\mobile-app\\assets\\images\\splash_frames\"\" 2>/dev/null || dir \"c:UsersdongDesktoprwadurianfrontendmobile-appassetsimagessplash_frames\" 2>nul || echo \"目录不存在 \")", - "Bash(ls -la \"c:\\Users\\dong\\Desktop\\rwadurian\\frontend\\mobile-app\\lib\\features\"\" | grep -E \"^d \")" + "Bash(ls -la \"c:\\Users\\dong\\Desktop\\rwadurian\\frontend\\mobile-app\\lib\\features\"\" | grep -E \"^d \")", + "Bash(git commit -m \"$(cat <<''EOF''\nfeat(telemetry): 将userId改为userSerialNum字符串格式并完善遥测追踪\n\nBackend (presence-service):\n- 将EventLog.userId从BigInt改为String类型,存储userSerialNum(如D25121400005)\n- 更新Prisma schema,userId字段改为VarChar(20)并添加索引\n- 更新心跳相关命令和事件,统一使用userSerialNum字符串\n- 添加数据库迁移文件\n- 更新相关单元测试和集成测试\n\nFrontend (mobile-app):\n- TelemetryEvent新增toServerJson()方法,格式化为后端API期望的格式\n- AccountService登录/恢复时设置TelemetryService的userId\n- MultiAccountService切换账号时同步更新TelemetryService的userId\n- 退出登录时清除TelemetryService的userId\n- AuthProvider初始化时设置userId\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude Opus 4.5 \nEOF\n)\")" ], "deny": [], "ask": [] diff --git a/backend/services/presence-service/prisma/migrations/20251215100000_change_user_id_to_string/migration.sql b/backend/services/presence-service/prisma/migrations/20251215100000_change_user_id_to_string/migration.sql new file mode 100644 index 00000000..d003427e --- /dev/null +++ b/backend/services/presence-service/prisma/migrations/20251215100000_change_user_id_to_string/migration.sql @@ -0,0 +1,9 @@ +-- Migration: Change user_id from BIGINT to VARCHAR(20) +-- Reason: user_id stores userSerialNum (e.g., "D25121400005") which is a string, not a number + +-- Alter the user_id column type from BIGINT to VARCHAR(20) +ALTER TABLE "analytics_event_log" + ALTER COLUMN "user_id" TYPE VARCHAR(20); + +-- Add index for user_id for better query performance +CREATE INDEX IF NOT EXISTS "idx_event_log_user_id" ON "analytics_event_log"("user_id"); diff --git a/backend/services/presence-service/prisma/schema.prisma b/backend/services/presence-service/prisma/schema.prisma index 846a2f1a..58d962b3 100644 --- a/backend/services/presence-service/prisma/schema.prisma +++ b/backend/services/presence-service/prisma/schema.prisma @@ -14,7 +14,7 @@ datasource db { // 事件日志表 (append-only) model EventLog { id BigInt @id @default(autoincrement()) - userId BigInt? @map("user_id") + 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() @@ -24,6 +24,7 @@ model EventLog { @@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") @@map("analytics_event_log") } diff --git a/backend/services/presence-service/src/api/controllers/presence.controller.ts b/backend/services/presence-service/src/api/controllers/presence.controller.ts index dac9bd9c..364d3549 100644 --- a/backend/services/presence-service/src/api/controllers/presence.controller.ts +++ b/backend/services/presence-service/src/api/controllers/presence.controller.ts @@ -24,7 +24,7 @@ export class PresenceController { @ApiBearerAuth() @ApiOperation({ summary: '心跳上报' }) async heartbeat( - @CurrentUser('userId') userId: bigint, + @CurrentUser('userId') userId: string, // userSerialNum, e.g. "D25121400005" @Body() dto: HeartbeatDto, ) { return this.commandBus.execute( 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 9ea7b1a4..dddc3b79 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 @@ -97,7 +97,7 @@ export class RecordEventsHandler implements ICommandHandler private toEventLog(dto: EventItemDto): EventLog { return EventLog.create({ - userId: dto.userId ? BigInt(dto.userId) : null, + userId: dto.userId ?? null, // userSerialNum string, e.g. "D25121400005" installId: InstallId.fromString(dto.installId), eventName: EventName.fromString(dto.eventName), eventTime: new Date(dto.clientTs * 1000), diff --git a/backend/services/presence-service/src/application/commands/record-heartbeat/record-heartbeat.command.ts b/backend/services/presence-service/src/application/commands/record-heartbeat/record-heartbeat.command.ts index 6888af19..71018cca 100644 --- a/backend/services/presence-service/src/application/commands/record-heartbeat/record-heartbeat.command.ts +++ b/backend/services/presence-service/src/application/commands/record-heartbeat/record-heartbeat.command.ts @@ -1,6 +1,6 @@ export class RecordHeartbeatCommand { constructor( - public readonly userId: bigint, + public readonly userId: string, // userSerialNum, e.g. "D25121400005" public readonly installId: string, public readonly appVersion: string, public readonly clientTs: number, diff --git a/backend/services/presence-service/src/application/commands/record-heartbeat/record-heartbeat.handler.ts b/backend/services/presence-service/src/application/commands/record-heartbeat/record-heartbeat.handler.ts index d7f10839..f72f40d6 100644 --- a/backend/services/presence-service/src/application/commands/record-heartbeat/record-heartbeat.handler.ts +++ b/backend/services/presence-service/src/application/commands/record-heartbeat/record-heartbeat.handler.ts @@ -25,8 +25,8 @@ export class RecordHeartbeatHandler implements ICommandHandler { @@ -22,6 +23,10 @@ describe('RecordHeartbeatHandler', () => { publish: jest.fn(), }; + const mockMetricsService = { + recordHeartbeat: jest.fn(), + }; + const module: TestingModule = await Test.createTestingModule({ providers: [ RecordHeartbeatHandler, @@ -33,6 +38,10 @@ describe('RecordHeartbeatHandler', () => { provide: EventPublisherService, useValue: mockEventPublisher, }, + { + provide: MetricsService, + useValue: mockMetricsService, + }, ], }).compile(); @@ -48,7 +57,7 @@ describe('RecordHeartbeatHandler', () => { describe('execute', () => { it('should record heartbeat and return success', async () => { const command = new RecordHeartbeatCommand( - BigInt(12345), + 'D25121400005', // userSerialNum 'install-id-123', '1.0.0', Date.now(), @@ -64,8 +73,8 @@ describe('RecordHeartbeatHandler', () => { expect(typeof result.serverTs).toBe('number'); }); - it('should update Redis presence with correct userId', async () => { - const userId = BigInt(99999); + it('should update Redis presence with correct userId (userSerialNum)', async () => { + const userId = 'D25121499999'; // userSerialNum const command = new RecordHeartbeatCommand( userId, 'install-id-456', @@ -79,14 +88,14 @@ describe('RecordHeartbeatHandler', () => { await handler.execute(command); expect(presenceRedisRepository.updateUserPresence).toHaveBeenCalledWith( - userId.toString(), + userId, // userSerialNum is passed directly as string expect.any(Number), ); }); it('should publish HeartbeatReceivedEvent', async () => { const command = new RecordHeartbeatCommand( - BigInt(12345), + 'D25121400005', 'install-id-789', '1.0.0', Date.now(), @@ -105,7 +114,7 @@ describe('RecordHeartbeatHandler', () => { it('should return server timestamp close to current time', async () => { const command = new RecordHeartbeatCommand( - BigInt(12345), + 'D25121400005', 'install-id', '1.0.0', Date.now(), @@ -124,7 +133,7 @@ describe('RecordHeartbeatHandler', () => { it('should throw error when Redis update fails', async () => { const command = new RecordHeartbeatCommand( - BigInt(12345), + 'D25121400005', 'install-id', '1.0.0', Date.now(), @@ -139,7 +148,7 @@ describe('RecordHeartbeatHandler', () => { it('should throw error when event publish fails', async () => { const command = new RecordHeartbeatCommand( - BigInt(12345), + 'D25121400005', 'install-id', '1.0.0', Date.now(), diff --git a/backend/services/presence-service/test/unit/domain/entities/event-log.entity.spec.ts b/backend/services/presence-service/test/unit/domain/entities/event-log.entity.spec.ts index 7c6fff7b..b0554de8 100644 --- a/backend/services/presence-service/test/unit/domain/entities/event-log.entity.spec.ts +++ b/backend/services/presence-service/test/unit/domain/entities/event-log.entity.spec.ts @@ -29,8 +29,8 @@ describe('EventLog Entity', () => { expect(eventLog.createdAt).toBeInstanceOf(Date); }); - it('should create EventLog with userId', () => { - const userId = BigInt(12345); + it('should create EventLog with userId (userSerialNum)', () => { + const userId = 'D25121400005'; // userSerialNum format const eventLog = EventLog.create({ userId, installId: createInstallId(), @@ -90,7 +90,7 @@ describe('EventLog Entity', () => { describe('reconstitute', () => { it('should reconstitute EventLog from persistence data', () => { const id = BigInt(999); - const userId = BigInt(123); + const userId = 'D25121400123'; // userSerialNum format const installId = createInstallId(); const eventName = createEventName(); const eventTime = createEventTime(); @@ -132,8 +132,8 @@ describe('EventLog Entity', () => { }); describe('dauIdentifier', () => { - it('should return userId as string when userId exists', () => { - const userId = BigInt(12345); + it('should return userId (userSerialNum) when userId exists', () => { + const userId = 'D25121400005'; const eventLog = EventLog.create({ userId, installId: createInstallId(), @@ -141,7 +141,7 @@ describe('EventLog Entity', () => { eventTime: createEventTime(), }); - expect(eventLog.dauIdentifier).toBe('12345'); + expect(eventLog.dauIdentifier).toBe('D25121400005'); }); it('should return installId value when userId is null', () => { @@ -156,7 +156,7 @@ describe('EventLog Entity', () => { }); it('should prefer userId over installId', () => { - const userId = BigInt(999); + const userId = 'D25121400999'; const installId = InstallId.fromString('should-not-use-this'); const eventLog = EventLog.create({ userId, @@ -165,7 +165,7 @@ describe('EventLog Entity', () => { eventTime: createEventTime(), }); - expect(eventLog.dauIdentifier).toBe('999'); + expect(eventLog.dauIdentifier).toBe('D25121400999'); }); }); @@ -175,7 +175,7 @@ describe('EventLog Entity', () => { beforeEach(() => { eventLog = EventLog.reconstitute({ id: BigInt(100), - userId: BigInt(200), + userId: 'D25121400200', installId: createInstallId(), eventName: createEventName(), eventTime: createEventTime(), @@ -189,7 +189,7 @@ describe('EventLog Entity', () => { }); it('should return correct userId', () => { - expect(eventLog.userId).toBe(BigInt(200)); + expect(eventLog.userId).toBe('D25121400200'); }); it('should return correct installId', () => { diff --git a/frontend/mobile-app/lib/core/services/account_service.dart b/frontend/mobile-app/lib/core/services/account_service.dart index 49584044..650d4783 100644 --- a/frontend/mobile-app/lib/core/services/account_service.dart +++ b/frontend/mobile-app/lib/core/services/account_service.dart @@ -8,6 +8,7 @@ import '../network/api_client.dart'; import '../storage/secure_storage.dart'; import '../storage/storage_keys.dart'; import '../errors/exceptions.dart'; +import '../telemetry/telemetry_service.dart'; /// 设备硬件信息 (存储在 deviceName 字段中) class DeviceHardwareInfo { @@ -591,6 +592,12 @@ class AccountService { value: 'true', ); + // 设置遥测服务的用户ID(使用userSerialNum,如D25121400005) + if (TelemetryService().isInitialized) { + TelemetryService().setUserId(response.userSerialNum); + debugPrint('$_tag _saveAccountData() - 设置TelemetryService userId: ${response.userSerialNum}'); + } + debugPrint('$_tag _saveAccountData() - 账号数据保存完成'); } @@ -932,6 +939,12 @@ class AccountService { value: 'true', ); + // 设置遥测服务的用户ID(使用userSerialNum,如D25121400005) + if (TelemetryService().isInitialized) { + TelemetryService().setUserId(response.userSerialNum); + debugPrint('$_tag _saveRecoverAccountData() - 设置TelemetryService userId: ${response.userSerialNum}'); + } + debugPrint('$_tag _saveRecoverAccountData() - 恢复账号数据保存完成'); } diff --git a/frontend/mobile-app/lib/core/services/multi_account_service.dart b/frontend/mobile-app/lib/core/services/multi_account_service.dart index c34c84bb..1f20d980 100644 --- a/frontend/mobile-app/lib/core/services/multi_account_service.dart +++ b/frontend/mobile-app/lib/core/services/multi_account_service.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import '../storage/secure_storage.dart'; import '../storage/storage_keys.dart'; +import '../telemetry/telemetry_service.dart'; /// 账号信息摘要(用于账号列表显示) class AccountSummary { @@ -144,6 +145,12 @@ class MultiAccountService { // 设置当前账号 await setCurrentAccountId(userSerialNum); + // 设置遥测服务的用户ID(使用userSerialNum,如D25121400005) + if (TelemetryService().isInitialized) { + TelemetryService().setUserId(userSerialNum); + debugPrint('$_tag switchToAccount() - 设置TelemetryService userId: $userSerialNum'); + } + debugPrint('$_tag switchToAccount() - 切换成功'); return true; } @@ -263,6 +270,12 @@ class MultiAccountService { await _secureStorage.delete(key: key); } + // 清除遥测服务的用户ID + if (TelemetryService().isInitialized) { + TelemetryService().clearUserId(); + debugPrint('$_tag logoutCurrentAccount() - 清除TelemetryService userId'); + } + debugPrint('$_tag logoutCurrentAccount() - 退出完成,已清除 ${keysToClear.length} 个状态'); } 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 6834b1e0..ca83a878 100644 --- a/frontend/mobile-app/lib/core/telemetry/models/telemetry_event.dart +++ b/frontend/mobile-app/lib/core/telemetry/models/telemetry_event.dart @@ -102,6 +102,7 @@ class TelemetryEvent extends Equatable { ); } + /// 转换为本地存储 JSON 格式 Map toJson() { return { 'eventId': eventId, @@ -117,6 +118,30 @@ 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) + Map toServerJson() { + return { + 'eventName': name, + 'userId': userId, + 'installId': installId, + 'clientTs': timestamp.millisecondsSinceEpoch ~/ 1000, + 'properties': { + ...?properties, + 'eventId': eventId, + 'type': type.name, + 'level': level.name, + 'sessionId': sessionId, + 'deviceContextId': deviceContextId, + }, + }; + } + TelemetryEvent copyWith({ String? eventId, EventType? type, diff --git a/frontend/mobile-app/lib/core/telemetry/uploader/telemetry_uploader.dart b/frontend/mobile-app/lib/core/telemetry/uploader/telemetry_uploader.dart index deff5c06..e7f855e5 100644 --- a/frontend/mobile-app/lib/core/telemetry/uploader/telemetry_uploader.dart +++ b/frontend/mobile-app/lib/core/telemetry/uploader/telemetry_uploader.dart @@ -64,11 +64,11 @@ class TelemetryUploader { final events = storage.dequeueEvents(batchSize); if (events.isEmpty) return true; - // 调用后端API + // 调用后端API (使用 toServerJson 格式化为服务端期望的格式) final response = await _dio.post( '/api/v1/analytics/events', data: { - 'events': events.map((e) => e.toJson()).toList(), + 'events': events.map((e) => e.toServerJson()).toList(), }, options: Options( headers: getAuthHeaders?.call(), diff --git a/frontend/mobile-app/lib/features/auth/presentation/providers/auth_provider.dart b/frontend/mobile-app/lib/features/auth/presentation/providers/auth_provider.dart index 7106d0b4..c4cc5f29 100644 --- a/frontend/mobile-app/lib/features/auth/presentation/providers/auth_provider.dart +++ b/frontend/mobile-app/lib/features/auth/presentation/providers/auth_provider.dart @@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/storage/secure_storage.dart'; import '../../../../core/storage/storage_keys.dart'; import '../../../../core/di/injection_container.dart'; +import '../../../../core/telemetry/telemetry_service.dart'; enum AuthStatus { initial, @@ -117,6 +118,10 @@ class AuthNotifier extends StateNotifier { userSerialNum: userSerialNum, referralCode: referralCode, ); + // 设置遥测服务的用户ID(使用userSerialNum,如D25121400005) + if (userSerialNum != null && TelemetryService().isInitialized) { + TelemetryService().setUserId(userSerialNum); + } } else if (isAccountCreated) { // 账号已创建但钱包未完成(可能正在生成或未备份) state = state.copyWith(