From e20c321d1285ef3697a48c2ff6bc34a9f766ce42 Mon Sep 17 00:00:00 2001 From: hailin Date: Thu, 12 Feb 2026 18:30:39 -0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=81=A5=E6=B5=8B=E4=B8=8E?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E7=AE=A1=E7=90=86=E6=8B=86=E5=88=86=E4=B8=BA?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E5=BE=AE=E6=9C=8D=E5=8A=A1=20(telemetry-serv?= =?UTF-8?q?ice=20+=20admin-service)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 架构重构: 将遥测(Telemetry)和版本管理(App Version)从 user-service 拆分为两个独立微服务, 严格遵循 DDD + Clean Architecture 四层架构。 新增服务: - telemetry-service (:3011) — 用户心跳检测、事件采集、DAU统计、Prometheus指标 - domain: 3实体 + 3值对象(EventName/InstallId/TimeWindow) + 2领域事件 - infrastructure: Redis(Sorted Set心跳) + Kafka(事件发布) + Prometheus(5指标) - 定时任务: 每分钟在线快照、每小时清理过期、凌晨DAU精确计算、滚动DAU更新 - admin-service (:3012) — APK/IPA版本管理、OTA更新、MinIO文件存储 - domain: 1实体 + 4值对象(VersionCode/VersionName/FileSha256/DownloadUrl) - infrastructure: MinIO(文件上传/下载) + APK/IPA解析器 - 移动端: 检查更新API(无认证) + 下载重定向(预签名URL) - 管理端: 版本CRUD + 上传解析 + 启禁用 user-service 清理: - 删除24个已迁移文件(4实体+4服务+4基础设施+5控制器+6DTO+1gitkeep) - 移除不再需要的依赖: @nestjs/schedule, minio, prom-client, kafkajs - 精简 user.module.ts,仅保留用户核心功能(Profile/KYC/Wallet/Message/Admin) 基础设施更新: - Kong: 遥测路由 → telemetry-service:3011, 版本路由 → admin-service:3012 - docker-compose: 新增2个服务容器 + MinIO app-releases bucket - 07开发指南: 更新为独立服务架构描述 Co-Authored-By: Claude Opus 4.6 --- backend/docker-compose.yml | 69 ++++++++++ backend/kong/kong.yml | 10 ++ backend/services/admin-service/Dockerfile | 7 + backend/services/admin-service/nest-cli.json | 5 + backend/services/admin-service/package.json | 43 ++++++ .../admin-service/src/admin.module.ts | 56 ++++++++ .../services/app-version.service.ts | 3 +- .../services/file-storage.service.ts | 0 .../src/domain/entities/app-version.entity.ts | 6 +- .../src/domain/enums/platform.enum.ts | 4 + .../domain/value-objects/download-url.vo.ts | 41 ++++++ .../domain/value-objects/file-sha256.vo.ts | 41 ++++++ .../domain/value-objects/version-code.vo.ts | 37 +++++ .../domain/value-objects/version-name.vo.ts | 55 ++++++++ .../parsers/package-parser.service.ts | 6 +- .../src/infrastructure/persistence}/.gitkeep | 0 .../controllers/admin-version.controller.ts | 4 +- .../controllers/app-version.controller.ts | 4 +- .../http/controllers/health.controller.ts | 16 +++ .../interface/http/dto/check-update.dto.ts | 0 .../interface/http/dto/create-version.dto.ts | 114 ++++++++++++++++ .../interface/http/dto/upload-version.dto.ts | 46 +++++++ backend/services/admin-service/src/main.ts | 33 +++++ backend/services/admin-service/tsconfig.json | 21 +++ backend/services/telemetry-service/Dockerfile | 7 + .../services/telemetry-service/nest-cli.json | 5 + .../services/telemetry-service/package.json | 45 ++++++ .../services/telemetry-scheduler.service.ts | 0 .../application/services/telemetry.service.ts | 0 .../entities/daily-active-stats.entity.ts | 0 .../domain/entities/online-snapshot.entity.ts | 0 .../domain/entities/telemetry-event.entity.ts | 0 .../domain/events/heartbeat-received.event.ts | 27 ++++ .../domain/events/session-started.event.ts | 44 ++++++ .../src/domain/value-objects/event-name.vo.ts | 56 ++++++++ .../src/domain/value-objects/install-id.vo.ts | 41 ++++++ .../domain/value-objects/time-window.vo.ts | 64 +++++++++ .../kafka/telemetry-producer.service.ts | 4 +- .../metrics/telemetry-metrics.service.ts | 0 .../src/infrastructure/persistence}/.gitkeep | 0 .../redis/presence-redis.service.ts | 0 .../controllers/admin-telemetry.controller.ts | 25 ++-- .../http/controllers/health.controller.ts | 16 +++ .../http/controllers/metrics.controller.ts | 0 .../http/controllers/telemetry.controller.ts | 14 +- .../interface/http/dto/batch-events.dto.ts | 0 .../src/interface/http/dto/heartbeat.dto.ts | 0 .../src/interface/http/dto/query-dau.dto.ts | 0 .../services/telemetry-service/src/main.ts | 24 ++++ .../telemetry-service/src/telemetry.module.ts | 70 ++++++++++ .../services/telemetry-service/tsconfig.json | 21 +++ backend/services/user-service/package.json | 4 - .../interface/http/dto/create-version.dto.ts | 37 ----- .../interface/http/dto/upload-version.dto.ts | 13 -- .../services/user-service/src/user.module.ts | 30 +--- .../07-遥测与版本管理开发指南.md | 128 ++++++++++++------ 56 files changed, 1130 insertions(+), 166 deletions(-) create mode 100644 backend/services/admin-service/Dockerfile create mode 100644 backend/services/admin-service/nest-cli.json create mode 100644 backend/services/admin-service/package.json create mode 100644 backend/services/admin-service/src/admin.module.ts rename backend/services/{user-service => admin-service}/src/application/services/app-version.service.ts (96%) rename backend/services/{user-service => admin-service}/src/application/services/file-storage.service.ts (100%) rename backend/services/{user-service => admin-service}/src/domain/entities/app-version.entity.ts (96%) create mode 100644 backend/services/admin-service/src/domain/enums/platform.enum.ts create mode 100644 backend/services/admin-service/src/domain/value-objects/download-url.vo.ts create mode 100644 backend/services/admin-service/src/domain/value-objects/file-sha256.vo.ts create mode 100644 backend/services/admin-service/src/domain/value-objects/version-code.vo.ts create mode 100644 backend/services/admin-service/src/domain/value-objects/version-name.vo.ts rename backend/services/{user-service => admin-service}/src/infrastructure/parsers/package-parser.service.ts (94%) rename backend/services/{user-service/src/infrastructure/kafka => admin-service/src/infrastructure/persistence}/.gitkeep (100%) rename backend/services/{user-service => admin-service}/src/interface/http/controllers/admin-version.controller.ts (98%) rename backend/services/{user-service => admin-service}/src/interface/http/controllers/app-version.controller.ts (93%) create mode 100644 backend/services/admin-service/src/interface/http/controllers/health.controller.ts rename backend/services/{user-service => admin-service}/src/interface/http/dto/check-update.dto.ts (100%) create mode 100644 backend/services/admin-service/src/interface/http/dto/create-version.dto.ts create mode 100644 backend/services/admin-service/src/interface/http/dto/upload-version.dto.ts create mode 100644 backend/services/admin-service/src/main.ts create mode 100644 backend/services/admin-service/tsconfig.json create mode 100644 backend/services/telemetry-service/Dockerfile create mode 100644 backend/services/telemetry-service/nest-cli.json create mode 100644 backend/services/telemetry-service/package.json rename backend/services/{user-service => telemetry-service}/src/application/services/telemetry-scheduler.service.ts (100%) rename backend/services/{user-service => telemetry-service}/src/application/services/telemetry.service.ts (100%) rename backend/services/{user-service => telemetry-service}/src/domain/entities/daily-active-stats.entity.ts (100%) rename backend/services/{user-service => telemetry-service}/src/domain/entities/online-snapshot.entity.ts (100%) rename backend/services/{user-service => telemetry-service}/src/domain/entities/telemetry-event.entity.ts (100%) create mode 100644 backend/services/telemetry-service/src/domain/events/heartbeat-received.event.ts create mode 100644 backend/services/telemetry-service/src/domain/events/session-started.event.ts create mode 100644 backend/services/telemetry-service/src/domain/value-objects/event-name.vo.ts create mode 100644 backend/services/telemetry-service/src/domain/value-objects/install-id.vo.ts create mode 100644 backend/services/telemetry-service/src/domain/value-objects/time-window.vo.ts rename backend/services/{user-service => telemetry-service}/src/infrastructure/kafka/telemetry-producer.service.ts (96%) rename backend/services/{user-service => telemetry-service}/src/infrastructure/metrics/telemetry-metrics.service.ts (100%) rename backend/services/{user-service/src/infrastructure/redis => telemetry-service/src/infrastructure/persistence}/.gitkeep (100%) rename backend/services/{user-service => telemetry-service}/src/infrastructure/redis/presence-redis.service.ts (100%) rename backend/services/{user-service => telemetry-service}/src/interface/http/controllers/admin-telemetry.controller.ts (77%) create mode 100644 backend/services/telemetry-service/src/interface/http/controllers/health.controller.ts rename backend/services/{user-service => telemetry-service}/src/interface/http/controllers/metrics.controller.ts (100%) rename backend/services/{user-service => telemetry-service}/src/interface/http/controllers/telemetry.controller.ts (84%) rename backend/services/{user-service => telemetry-service}/src/interface/http/dto/batch-events.dto.ts (100%) rename backend/services/{user-service => telemetry-service}/src/interface/http/dto/heartbeat.dto.ts (100%) rename backend/services/{user-service => telemetry-service}/src/interface/http/dto/query-dau.dto.ts (100%) create mode 100644 backend/services/telemetry-service/src/main.ts create mode 100644 backend/services/telemetry-service/src/telemetry.module.ts create mode 100644 backend/services/telemetry-service/tsconfig.json delete mode 100644 backend/services/user-service/src/interface/http/dto/create-version.dto.ts delete mode 100644 backend/services/user-service/src/interface/http/dto/upload-version.dto.ts diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index e9e8387..82881f7 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -118,6 +118,7 @@ services: mc mb --ignore-existing genex/sar-reports; mc mb --ignore-existing genex/avatars; mc mb --ignore-existing genex/exports; + mc mb --ignore-existing genex/app-releases; mc anonymous set download genex/coupon-images; mc anonymous set download genex/avatars; echo 'MinIO buckets initialized'; @@ -313,6 +314,74 @@ services: networks: - genex-network + # ============================================================ + # Telemetry Service (NestJS :3011) - User presence, events, DAU, Prometheus metrics + # ============================================================ + + telemetry-service: + build: + context: ./services/telemetry-service + dockerfile: Dockerfile + container_name: genex-telemetry-service + ports: + - "3011:3011" + environment: + - NODE_ENV=development + - PORT=3011 + - SERVICE_NAME=telemetry-service + - DB_HOST=postgres + - DB_PORT=5432 + - DB_USERNAME=genex + - DB_PASSWORD=genex_dev_password + - DB_NAME=genex + - REDIS_HOST=redis + - REDIS_PORT=6379 + - KAFKA_BROKERS=kafka:9092 + - JWT_ACCESS_SECRET=dev-access-secret-change-in-production + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + kafka: + condition: service_healthy + networks: + - genex-network + + # ============================================================ + # Admin Service (NestJS :3012) - App version management, OTA updates + # ============================================================ + + admin-service: + build: + context: ./services/admin-service + dockerfile: Dockerfile + container_name: genex-admin-service + ports: + - "3012:3012" + environment: + - NODE_ENV=development + - PORT=3012 + - SERVICE_NAME=admin-service + - DB_HOST=postgres + - DB_PORT=5432 + - DB_USERNAME=genex + - DB_PASSWORD=genex_dev_password + - DB_NAME=genex + - MINIO_ENDPOINT=minio + - MINIO_PORT=9000 + - MINIO_ACCESS_KEY=genex-admin + - MINIO_SECRET_KEY=genex-minio-secret + - MINIO_BUCKET=app-releases + - JWT_ACCESS_SECRET=dev-access-secret-change-in-production + depends_on: + postgres: + condition: service_healthy + minio: + condition: service_healthy + networks: + - genex-network + # ============================================================ # Go Services (3) # ============================================================ diff --git a/backend/kong/kong.yml b/backend/kong/kong.yml index 623ef94..ecd431b 100644 --- a/backend/kong/kong.yml +++ b/backend/kong/kong.yml @@ -43,6 +43,11 @@ services: paths: - /api/v1/admin/system strip_path: false + + # --- telemetry-service (NestJS :3011) --- + - name: telemetry-service + url: http://telemetry-service:3011 + routes: - name: telemetry-routes paths: - /api/v1/telemetry @@ -51,6 +56,11 @@ services: paths: - /api/v1/admin/telemetry strip_path: false + + # --- admin-service (NestJS :3012) - App version management --- + - name: admin-service + url: http://admin-service:3012 + routes: - name: app-version-routes paths: - /api/v1/app/version diff --git a/backend/services/admin-service/Dockerfile b/backend/services/admin-service/Dockerfile new file mode 100644 index 0000000..4e083f6 --- /dev/null +++ b/backend/services/admin-service/Dockerfile @@ -0,0 +1,7 @@ +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm ci --only=production +COPY dist/ ./dist/ +EXPOSE 3012 +CMD ["node", "dist/main"] diff --git a/backend/services/admin-service/nest-cli.json b/backend/services/admin-service/nest-cli.json new file mode 100644 index 0000000..2566481 --- /dev/null +++ b/backend/services/admin-service/nest-cli.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src" +} diff --git a/backend/services/admin-service/package.json b/backend/services/admin-service/package.json new file mode 100644 index 0000000..05a9f34 --- /dev/null +++ b/backend/services/admin-service/package.json @@ -0,0 +1,43 @@ +{ + "name": "@genex/admin-service", + "version": "1.0.0", + "description": "Genex Admin Service - Mobile App Version Management & OTA Updates", + "scripts": { + "start": "nest start", + "start:dev": "nest start --watch", + "start:prod": "node dist/main", + "build": "nest build", + "test": "jest", + "test:e2e": "jest --config ./test/jest-e2e.json" + }, + "dependencies": { + "@nestjs/common": "^10.3.0", + "@nestjs/core": "^10.3.0", + "@nestjs/platform-express": "^10.3.0", + "@nestjs/typeorm": "^10.0.1", + "@nestjs/jwt": "^10.2.0", + "@nestjs/passport": "^10.0.3", + "@nestjs/swagger": "^7.2.0", + "typeorm": "^0.3.19", + "pg": "^8.11.3", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "class-validator": "^0.14.0", + "class-transformer": "^0.5.1", + "minio": "^8.0.0", + "reflect-metadata": "^0.2.1", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.3.0", + "@nestjs/testing": "^10.3.0", + "@types/node": "^20.11.0", + "@types/passport-jwt": "^4.0.1", + "@types/multer": "^1.4.11", + "typescript": "^5.3.0", + "jest": "^29.7.0", + "ts-jest": "^29.1.0", + "@types/jest": "^29.5.0", + "ts-node": "^10.9.0" + } +} diff --git a/backend/services/admin-service/src/admin.module.ts b/backend/services/admin-service/src/admin.module.ts new file mode 100644 index 0000000..53d445e --- /dev/null +++ b/backend/services/admin-service/src/admin.module.ts @@ -0,0 +1,56 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; + +// Domain +import { AppVersion } from './domain/entities/app-version.entity'; + +// Application +import { AppVersionService } from './application/services/app-version.service'; +import { FileStorageService } from './application/services/file-storage.service'; + +// Infrastructure +import { PackageParserService } from './infrastructure/parsers/package-parser.service'; + +// Interface - Controllers +import { AppVersionController } from './interface/http/controllers/app-version.controller'; +import { AdminVersionController } from './interface/http/controllers/admin-version.controller'; +import { HealthController } from './interface/http/controllers/health.controller'; + +@Module({ + imports: [ + TypeOrmModule.forRoot({ + type: 'postgres', + host: process.env.DB_HOST || 'localhost', + port: parseInt(process.env.DB_PORT || '5432', 10), + username: process.env.DB_USERNAME || 'genex', + password: process.env.DB_PASSWORD || 'genex_dev_password', + database: process.env.DB_NAME || 'genex', + autoLoadEntities: true, + synchronize: false, + logging: process.env.NODE_ENV === 'development', + extra: { + max: parseInt(process.env.DB_POOL_MAX || '20', 10), + min: parseInt(process.env.DB_POOL_MIN || '5', 10), + }, + }), + TypeOrmModule.forFeature([AppVersion]), + PassportModule.register({ defaultStrategy: 'jwt' }), + JwtModule.register({ + secret: process.env.JWT_SECRET || 'genex-jwt-secret-dev', + signOptions: { expiresIn: process.env.JWT_EXPIRES_IN || '24h' }, + }), + ], + controllers: [ + HealthController, + AppVersionController, + AdminVersionController, + ], + providers: [ + AppVersionService, + FileStorageService, + PackageParserService, + ], +}) +export class AdminModule {} diff --git a/backend/services/user-service/src/application/services/app-version.service.ts b/backend/services/admin-service/src/application/services/app-version.service.ts similarity index 96% rename from backend/services/user-service/src/application/services/app-version.service.ts rename to backend/services/admin-service/src/application/services/app-version.service.ts index 543316b..d6bc18d 100644 --- a/backend/services/user-service/src/application/services/app-version.service.ts +++ b/backend/services/admin-service/src/application/services/app-version.service.ts @@ -1,7 +1,8 @@ import { Injectable, NotFoundException, ConflictException, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { AppVersion, Platform } from '../../domain/entities/app-version.entity'; +import { AppVersion } from '../../domain/entities/app-version.entity'; +import { Platform } from '../../domain/enums/platform.enum'; @Injectable() export class AppVersionService { diff --git a/backend/services/user-service/src/application/services/file-storage.service.ts b/backend/services/admin-service/src/application/services/file-storage.service.ts similarity index 100% rename from backend/services/user-service/src/application/services/file-storage.service.ts rename to backend/services/admin-service/src/application/services/file-storage.service.ts diff --git a/backend/services/user-service/src/domain/entities/app-version.entity.ts b/backend/services/admin-service/src/domain/entities/app-version.entity.ts similarity index 96% rename from backend/services/user-service/src/domain/entities/app-version.entity.ts rename to backend/services/admin-service/src/domain/entities/app-version.entity.ts index 1bd0fe2..33ed781 100644 --- a/backend/services/user-service/src/domain/entities/app-version.entity.ts +++ b/backend/services/admin-service/src/domain/entities/app-version.entity.ts @@ -2,11 +2,7 @@ import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, VersionColumn, Index, } from 'typeorm'; - -export enum Platform { - ANDROID = 'ANDROID', - IOS = 'IOS', -} +import { Platform } from '../enums/platform.enum'; @Entity('app_versions') @Index('idx_app_versions_platform', ['platform', 'isEnabled']) diff --git a/backend/services/admin-service/src/domain/enums/platform.enum.ts b/backend/services/admin-service/src/domain/enums/platform.enum.ts new file mode 100644 index 0000000..4054586 --- /dev/null +++ b/backend/services/admin-service/src/domain/enums/platform.enum.ts @@ -0,0 +1,4 @@ +export enum Platform { + ANDROID = 'ANDROID', + IOS = 'IOS', +} diff --git a/backend/services/admin-service/src/domain/value-objects/download-url.vo.ts b/backend/services/admin-service/src/domain/value-objects/download-url.vo.ts new file mode 100644 index 0000000..329b179 --- /dev/null +++ b/backend/services/admin-service/src/domain/value-objects/download-url.vo.ts @@ -0,0 +1,41 @@ +/** + * Value Object: DownloadUrl + * + * Encapsulates a download URL with basic validation. + * Ensures the URL uses http or https protocol. + */ +export class DownloadUrl { + private static readonly URL_REGEX = + /^https?:\/\/[^\s/$.?#].[^\s]*$/i; + + private readonly value: string; + + private constructor(value: string) { + this.value = value; + } + + static create(value: string): DownloadUrl { + if (!value || !DownloadUrl.URL_REGEX.test(value)) { + throw new Error( + `Invalid download URL: "${value}". Must be a valid HTTP or HTTPS URL.`, + ); + } + return new DownloadUrl(value); + } + + getValue(): string { + return this.value; + } + + isSecure(): boolean { + return this.value.startsWith('https://'); + } + + equals(other: DownloadUrl): boolean { + return this.value === other.value; + } + + toString(): string { + return this.value; + } +} diff --git a/backend/services/admin-service/src/domain/value-objects/file-sha256.vo.ts b/backend/services/admin-service/src/domain/value-objects/file-sha256.vo.ts new file mode 100644 index 0000000..48fa958 --- /dev/null +++ b/backend/services/admin-service/src/domain/value-objects/file-sha256.vo.ts @@ -0,0 +1,41 @@ +/** + * Value Object: FileSha256 + * + * Encapsulates a SHA-256 hash string for file integrity verification. + * The hash must be a valid 64-character lowercase hexadecimal string. + */ +export class FileSha256 { + private static readonly SHA256_REGEX = /^[a-f0-9]{64}$/; + + private readonly value: string; + + private constructor(value: string) { + this.value = value; + } + + static create(value: string): FileSha256 { + const normalized = value.toLowerCase(); + if (!FileSha256.SHA256_REGEX.test(normalized)) { + throw new Error( + `Invalid SHA-256 hash: "${value}". Must be a 64-character hex string.`, + ); + } + return new FileSha256(normalized); + } + + getValue(): string { + return this.value; + } + + matches(other: FileSha256): boolean { + return this.value === other.value; + } + + equals(other: FileSha256): boolean { + return this.value === other.value; + } + + toString(): string { + return this.value; + } +} diff --git a/backend/services/admin-service/src/domain/value-objects/version-code.vo.ts b/backend/services/admin-service/src/domain/value-objects/version-code.vo.ts new file mode 100644 index 0000000..77dadac --- /dev/null +++ b/backend/services/admin-service/src/domain/value-objects/version-code.vo.ts @@ -0,0 +1,37 @@ +/** + * Value Object: VersionCode + * + * Encapsulates the version code (integer build number) validation logic. + * A version code must be a positive integer, typically auto-incremented + * with each release build. + */ +export class VersionCode { + private readonly value: number; + + private constructor(value: number) { + this.value = value; + } + + static create(value: number): VersionCode { + if (!Number.isInteger(value) || value < 1) { + throw new Error(`Invalid version code: ${value}. Must be a positive integer.`); + } + return new VersionCode(value); + } + + getValue(): number { + return this.value; + } + + isNewerThan(other: VersionCode): boolean { + return this.value > other.value; + } + + equals(other: VersionCode): boolean { + return this.value === other.value; + } + + toString(): string { + return this.value.toString(); + } +} diff --git a/backend/services/admin-service/src/domain/value-objects/version-name.vo.ts b/backend/services/admin-service/src/domain/value-objects/version-name.vo.ts new file mode 100644 index 0000000..2d4f76b --- /dev/null +++ b/backend/services/admin-service/src/domain/value-objects/version-name.vo.ts @@ -0,0 +1,55 @@ +/** + * Value Object: VersionName + * + * Encapsulates semantic version string validation (e.g., "1.2.3"). + * Supports standard semver format: MAJOR.MINOR.PATCH with optional + * pre-release suffix (e.g., "1.2.3-beta.1"). + */ +export class VersionName { + private static readonly SEMVER_REGEX = + /^\d+\.\d+\.\d+(-[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*)?$/; + + private readonly value: string; + + private constructor(value: string) { + this.value = value; + } + + static create(value: string): VersionName { + if (!value || value.length > 32) { + throw new Error( + `Invalid version name: "${value}". Must be non-empty and at most 32 characters.`, + ); + } + if (!VersionName.SEMVER_REGEX.test(value)) { + throw new Error( + `Invalid version name: "${value}". Must follow semantic versioning (e.g., 1.2.3).`, + ); + } + return new VersionName(value); + } + + getValue(): string { + return this.value; + } + + getMajor(): number { + return parseInt(this.value.split('.')[0], 10); + } + + getMinor(): number { + return parseInt(this.value.split('.')[1], 10); + } + + getPatch(): number { + return parseInt(this.value.split('.')[2].split('-')[0], 10); + } + + equals(other: VersionName): boolean { + return this.value === other.value; + } + + toString(): string { + return this.value; + } +} diff --git a/backend/services/user-service/src/infrastructure/parsers/package-parser.service.ts b/backend/services/admin-service/src/infrastructure/parsers/package-parser.service.ts similarity index 94% rename from backend/services/user-service/src/infrastructure/parsers/package-parser.service.ts rename to backend/services/admin-service/src/infrastructure/parsers/package-parser.service.ts index 4aa4bcf..8e4a5bb 100644 --- a/backend/services/user-service/src/infrastructure/parsers/package-parser.service.ts +++ b/backend/services/admin-service/src/infrastructure/parsers/package-parser.service.ts @@ -32,7 +32,7 @@ export class PackageParserService { minSdkVersion: manifest.usesSdk?.minSdkVersion?.toString(), platform: 'ANDROID', }; - } catch (err) { + } catch (err: any) { this.logger.warn(`APK parse failed, using fallback: ${err.message}`); return { packageName: 'unknown', @@ -48,7 +48,7 @@ export class PackageParserService { const unzipper = await import('unzipper'); const bplistParser = await import('bplist-parser'); const directory = await unzipper.Open.buffer(buffer); - const plistEntry = directory.files.find(f => /Payload\/[^/]+\.app\/Info\.plist$/.test(f.path)); + const plistEntry = directory.files.find((f: any) => /Payload\/[^/]+\.app\/Info\.plist$/.test(f.path)); if (!plistEntry) throw new Error('Info.plist not found in IPA'); const plistBuffer = await plistEntry.buffer(); @@ -62,7 +62,7 @@ export class PackageParserService { minSdkVersion: info.MinimumOSVersion, platform: 'IOS', }; - } catch (err) { + } catch (err: any) { this.logger.warn(`IPA parse failed, using fallback: ${err.message}`); return { packageName: 'unknown', diff --git a/backend/services/user-service/src/infrastructure/kafka/.gitkeep b/backend/services/admin-service/src/infrastructure/persistence/.gitkeep similarity index 100% rename from backend/services/user-service/src/infrastructure/kafka/.gitkeep rename to backend/services/admin-service/src/infrastructure/persistence/.gitkeep diff --git a/backend/services/user-service/src/interface/http/controllers/admin-version.controller.ts b/backend/services/admin-service/src/interface/http/controllers/admin-version.controller.ts similarity index 98% rename from backend/services/user-service/src/interface/http/controllers/admin-version.controller.ts rename to backend/services/admin-service/src/interface/http/controllers/admin-version.controller.ts index e7a14be..666ae9d 100644 --- a/backend/services/user-service/src/interface/http/controllers/admin-version.controller.ts +++ b/backend/services/admin-service/src/interface/http/controllers/admin-version.controller.ts @@ -8,9 +8,9 @@ import { JwtAuthGuard, Roles, RolesGuard, UserRole } from '@genex/common'; import { AppVersionService } from '../../../application/services/app-version.service'; import { FileStorageService } from '../../../application/services/file-storage.service'; import { PackageParserService } from '../../../infrastructure/parsers/package-parser.service'; -import { Platform } from '../../../domain/entities/app-version.entity'; +import { Platform } from '../../../domain/enums/platform.enum'; -@ApiTags('Admin - App Versions') +@ApiTags('admin-versions') @Controller('admin/versions') @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) diff --git a/backend/services/user-service/src/interface/http/controllers/app-version.controller.ts b/backend/services/admin-service/src/interface/http/controllers/app-version.controller.ts similarity index 93% rename from backend/services/user-service/src/interface/http/controllers/app-version.controller.ts rename to backend/services/admin-service/src/interface/http/controllers/app-version.controller.ts index 689bb15..d44cfcb 100644 --- a/backend/services/user-service/src/interface/http/controllers/app-version.controller.ts +++ b/backend/services/admin-service/src/interface/http/controllers/app-version.controller.ts @@ -3,9 +3,9 @@ import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger'; import { Response } from 'express'; import { AppVersionService } from '../../../application/services/app-version.service'; import { FileStorageService } from '../../../application/services/file-storage.service'; -import { Platform } from '../../../domain/entities/app-version.entity'; +import { Platform } from '../../../domain/enums/platform.enum'; -@ApiTags('App Version') +@ApiTags('app-version') @Controller('app/version') export class AppVersionController { constructor( diff --git a/backend/services/admin-service/src/interface/http/controllers/health.controller.ts b/backend/services/admin-service/src/interface/http/controllers/health.controller.ts new file mode 100644 index 0000000..6b2059b --- /dev/null +++ b/backend/services/admin-service/src/interface/http/controllers/health.controller.ts @@ -0,0 +1,16 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; + +@ApiTags('health') +@Controller('health') +export class HealthController { + @Get() + @ApiOperation({ summary: 'Health check' }) + check() { + return { + status: 'ok', + service: 'admin-service', + timestamp: new Date().toISOString(), + }; + } +} diff --git a/backend/services/user-service/src/interface/http/dto/check-update.dto.ts b/backend/services/admin-service/src/interface/http/dto/check-update.dto.ts similarity index 100% rename from backend/services/user-service/src/interface/http/dto/check-update.dto.ts rename to backend/services/admin-service/src/interface/http/dto/check-update.dto.ts diff --git a/backend/services/admin-service/src/interface/http/dto/create-version.dto.ts b/backend/services/admin-service/src/interface/http/dto/create-version.dto.ts new file mode 100644 index 0000000..10e1299 --- /dev/null +++ b/backend/services/admin-service/src/interface/http/dto/create-version.dto.ts @@ -0,0 +1,114 @@ +import { + IsBoolean, + IsDateString, + IsIn, + IsInt, + IsOptional, + IsString, + IsUrl, + MaxLength, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateVersionDto { + @ApiProperty({ enum: ['android', 'ios', 'ANDROID', 'IOS'] }) + @IsIn(['android', 'ios', 'ANDROID', 'IOS']) + platform: string; + + @ApiProperty({ example: 10 }) + @IsInt() + versionCode: number; + + @ApiProperty({ example: '1.2.0', maxLength: 32 }) + @IsString() + @MaxLength(32) + versionName: string; + + @ApiProperty({ example: '20250101.1', maxLength: 64 }) + @IsString() + @MaxLength(64) + buildNumber: string; + + @ApiProperty({ example: 'https://cdn.example.com/app-1.2.0.apk' }) + @IsUrl() + downloadUrl: string; + + @ApiProperty({ example: '52428800' }) + @IsString() + fileSize: string; + + @ApiProperty({ example: 'a1b2c3d4...', maxLength: 64 }) + @IsString() + @MaxLength(64) + fileSha256: string; + + @ApiProperty({ example: 'Bug fixes and performance improvements.' }) + @IsString() + changelog: string; + + @ApiProperty({ example: false }) + @IsBoolean() + isForceUpdate: boolean; + + @ApiPropertyOptional({ maxLength: 16 }) + @IsOptional() + @IsString() + @MaxLength(16) + minOsVersion?: string; + + @ApiPropertyOptional({ example: '2025-06-01' }) + @IsOptional() + @IsDateString() + releaseDate?: string; +} + +export class UpdateVersionDto { + @ApiPropertyOptional({ example: '1.2.1', maxLength: 32 }) + @IsOptional() + @IsString() + @MaxLength(32) + versionName?: string; + + @ApiPropertyOptional({ example: '20250102.1', maxLength: 64 }) + @IsOptional() + @IsString() + @MaxLength(64) + buildNumber?: string; + + @ApiPropertyOptional({ example: 'https://cdn.example.com/app-1.2.1.apk' }) + @IsOptional() + @IsUrl() + downloadUrl?: string; + + @ApiPropertyOptional({ example: '52428800' }) + @IsOptional() + @IsString() + fileSize?: string; + + @ApiPropertyOptional({ example: 'a1b2c3d4...', maxLength: 64 }) + @IsOptional() + @IsString() + @MaxLength(64) + fileSha256?: string; + + @ApiPropertyOptional({ example: 'Updated changelog.' }) + @IsOptional() + @IsString() + changelog?: string; + + @ApiPropertyOptional({ example: false }) + @IsOptional() + @IsBoolean() + isForceUpdate?: boolean; + + @ApiPropertyOptional({ maxLength: 16 }) + @IsOptional() + @IsString() + @MaxLength(16) + minOsVersion?: string; + + @ApiPropertyOptional({ example: '2025-06-01' }) + @IsOptional() + @IsDateString() + releaseDate?: string; +} diff --git a/backend/services/admin-service/src/interface/http/dto/upload-version.dto.ts b/backend/services/admin-service/src/interface/http/dto/upload-version.dto.ts new file mode 100644 index 0000000..a6cd7af --- /dev/null +++ b/backend/services/admin-service/src/interface/http/dto/upload-version.dto.ts @@ -0,0 +1,46 @@ +import { IsDateString, IsIn, IsNumberString, IsOptional, IsString, MaxLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class UploadVersionDto { + @ApiProperty({ enum: ['android', 'ios', 'ANDROID', 'IOS'] }) + @IsIn(['android', 'ios', 'ANDROID', 'IOS']) + platform: string; + + @ApiPropertyOptional() + @IsOptional() + @IsNumberString() + versionCode?: string; + + @ApiPropertyOptional({ maxLength: 32 }) + @IsOptional() + @IsString() + @MaxLength(32) + versionName?: string; + + @ApiPropertyOptional({ maxLength: 64 }) + @IsOptional() + @IsString() + @MaxLength(64) + buildNumber?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + changelog?: string; + + @ApiPropertyOptional({ enum: ['true', 'false'] }) + @IsOptional() + @IsIn(['true', 'false']) + isForceUpdate?: string; + + @ApiPropertyOptional({ maxLength: 16 }) + @IsOptional() + @IsString() + @MaxLength(16) + minOsVersion?: string; + + @ApiPropertyOptional({ example: '2025-06-01' }) + @IsOptional() + @IsDateString() + releaseDate?: string; +} diff --git a/backend/services/admin-service/src/main.ts b/backend/services/admin-service/src/main.ts new file mode 100644 index 0000000..12efcc1 --- /dev/null +++ b/backend/services/admin-service/src/main.ts @@ -0,0 +1,33 @@ +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { AdminModule } from './admin.module'; + +async function bootstrap() { + const app = await NestFactory.create(AdminModule); + + // Global prefix for admin APIs; mobile client endpoints excluded + app.setGlobalPrefix('api/v1', { + exclude: ['api/app/version/(.*)'], + }); + + app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); + app.enableCors(); + + // Swagger documentation + const config = new DocumentBuilder() + .setTitle('Genex Admin Service') + .setDescription('Mobile app version management & OTA updates') + .setVersion('1.0.0') + .addBearerAuth() + .addTag('app-version', 'Mobile client check-update & download') + .addTag('admin-versions', 'Admin version management') + .build(); + SwaggerModule.setup('docs', app, SwaggerModule.createDocument(app, config)); + + const port = parseInt(process.env.PORT || '3012', 10); + await app.listen(port); + console.log(`[admin-service] Running on http://localhost:${port}`); + console.log(`[admin-service] Swagger docs at http://localhost:${port}/docs`); +} +bootstrap(); diff --git a/backend/services/admin-service/tsconfig.json b/backend/services/admin-service/tsconfig.json new file mode 100644 index 0000000..7d866ac --- /dev/null +++ b/backend/services/admin-service/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2021", + "lib": ["ES2021"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "declaration": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "paths": { + "@genex/common": ["../../packages/common/src"], + "@genex/kafka-client": ["../../packages/kafka-client/src"] + } + }, + "include": ["src/**/*"] +} diff --git a/backend/services/telemetry-service/Dockerfile b/backend/services/telemetry-service/Dockerfile new file mode 100644 index 0000000..1ee2547 --- /dev/null +++ b/backend/services/telemetry-service/Dockerfile @@ -0,0 +1,7 @@ +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm ci --only=production +COPY dist/ ./dist/ +EXPOSE 3011 +CMD ["node", "dist/main"] diff --git a/backend/services/telemetry-service/nest-cli.json b/backend/services/telemetry-service/nest-cli.json new file mode 100644 index 0000000..2566481 --- /dev/null +++ b/backend/services/telemetry-service/nest-cli.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src" +} diff --git a/backend/services/telemetry-service/package.json b/backend/services/telemetry-service/package.json new file mode 100644 index 0000000..6481745 --- /dev/null +++ b/backend/services/telemetry-service/package.json @@ -0,0 +1,45 @@ +{ + "name": "@genex/telemetry-service", + "version": "1.0.0", + "description": "Genex Telemetry Service - Presence Detection, Event Collection, DAU Analytics", + "scripts": { + "start": "nest start", + "start:dev": "nest start --watch", + "start:prod": "node dist/main", + "build": "nest build", + "test": "jest", + "test:e2e": "jest --config ./test/jest-e2e.json" + }, + "dependencies": { + "@nestjs/common": "^10.3.0", + "@nestjs/core": "^10.3.0", + "@nestjs/platform-express": "^10.3.0", + "@nestjs/typeorm": "^10.0.1", + "@nestjs/jwt": "^10.2.0", + "@nestjs/passport": "^10.0.3", + "@nestjs/swagger": "^7.2.0", + "@nestjs/schedule": "^4.0.0", + "typeorm": "^0.3.19", + "pg": "^8.11.3", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "class-validator": "^0.14.0", + "class-transformer": "^0.5.1", + "ioredis": "^5.3.2", + "kafkajs": "^2.2.4", + "prom-client": "^15.1.3", + "reflect-metadata": "^0.2.1", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.3.0", + "@nestjs/testing": "^10.3.0", + "@types/node": "^20.11.0", + "@types/passport-jwt": "^4.0.1", + "typescript": "^5.3.0", + "jest": "^29.7.0", + "ts-jest": "^29.1.0", + "@types/jest": "^29.5.0", + "ts-node": "^10.9.0" + } +} diff --git a/backend/services/user-service/src/application/services/telemetry-scheduler.service.ts b/backend/services/telemetry-service/src/application/services/telemetry-scheduler.service.ts similarity index 100% rename from backend/services/user-service/src/application/services/telemetry-scheduler.service.ts rename to backend/services/telemetry-service/src/application/services/telemetry-scheduler.service.ts diff --git a/backend/services/user-service/src/application/services/telemetry.service.ts b/backend/services/telemetry-service/src/application/services/telemetry.service.ts similarity index 100% rename from backend/services/user-service/src/application/services/telemetry.service.ts rename to backend/services/telemetry-service/src/application/services/telemetry.service.ts diff --git a/backend/services/user-service/src/domain/entities/daily-active-stats.entity.ts b/backend/services/telemetry-service/src/domain/entities/daily-active-stats.entity.ts similarity index 100% rename from backend/services/user-service/src/domain/entities/daily-active-stats.entity.ts rename to backend/services/telemetry-service/src/domain/entities/daily-active-stats.entity.ts diff --git a/backend/services/user-service/src/domain/entities/online-snapshot.entity.ts b/backend/services/telemetry-service/src/domain/entities/online-snapshot.entity.ts similarity index 100% rename from backend/services/user-service/src/domain/entities/online-snapshot.entity.ts rename to backend/services/telemetry-service/src/domain/entities/online-snapshot.entity.ts diff --git a/backend/services/user-service/src/domain/entities/telemetry-event.entity.ts b/backend/services/telemetry-service/src/domain/entities/telemetry-event.entity.ts similarity index 100% rename from backend/services/user-service/src/domain/entities/telemetry-event.entity.ts rename to backend/services/telemetry-service/src/domain/entities/telemetry-event.entity.ts diff --git a/backend/services/telemetry-service/src/domain/events/heartbeat-received.event.ts b/backend/services/telemetry-service/src/domain/events/heartbeat-received.event.ts new file mode 100644 index 0000000..5ff3449 --- /dev/null +++ b/backend/services/telemetry-service/src/domain/events/heartbeat-received.event.ts @@ -0,0 +1,27 @@ +/** + * Domain Event: HeartbeatReceived + * Emitted when a heartbeat is received from a client. + * Used to update presence tracking and trigger metrics updates. + */ +export class HeartbeatReceivedEvent { + readonly eventType = 'heartbeat.received' as const; + readonly occurredAt: Date; + + constructor( + public readonly userId: string, + public readonly installId: string, + public readonly appVersion: string, + ) { + this.occurredAt = new Date(); + } + + toJSON() { + return { + eventType: this.eventType, + userId: this.userId, + installId: this.installId, + appVersion: this.appVersion, + occurredAt: this.occurredAt.toISOString(), + }; + } +} diff --git a/backend/services/telemetry-service/src/domain/events/session-started.event.ts b/backend/services/telemetry-service/src/domain/events/session-started.event.ts new file mode 100644 index 0000000..785c92e --- /dev/null +++ b/backend/services/telemetry-service/src/domain/events/session-started.event.ts @@ -0,0 +1,44 @@ +/** + * Domain Event: SessionStarted + * Emitted when an `app_session_start` telemetry event is received. + * Used to trigger side effects like DAU tracking and Kafka publishing. + */ +export class SessionStartedEvent { + readonly eventType = 'session.started' as const; + readonly occurredAt: Date; + + constructor( + public readonly userId: string | null, + public readonly installId: string, + public readonly clientTimestamp: number, + public readonly properties: Record, + ) { + this.occurredAt = new Date(); + } + + /** Get the identifier for DAU tracking (userId preferred, fallback to installId) */ + getDauIdentifier(): string { + return this.userId || this.installId; + } + + /** Get the platform from properties if available */ + getPlatform(): string | undefined { + return this.properties?.platform; + } + + /** Get the region from properties if available */ + getRegion(): string | undefined { + return this.properties?.region; + } + + toJSON() { + return { + eventType: this.eventType, + userId: this.userId, + installId: this.installId, + clientTimestamp: this.clientTimestamp, + properties: this.properties, + occurredAt: this.occurredAt.toISOString(), + }; + } +} diff --git a/backend/services/telemetry-service/src/domain/value-objects/event-name.vo.ts b/backend/services/telemetry-service/src/domain/value-objects/event-name.vo.ts new file mode 100644 index 0000000..efa7054 --- /dev/null +++ b/backend/services/telemetry-service/src/domain/value-objects/event-name.vo.ts @@ -0,0 +1,56 @@ +/** + * Value Object: EventName + * Validates and encapsulates a telemetry event name. + * Event names must be non-empty, max 64 characters, and follow snake_case convention. + */ +export class EventName { + private static readonly MAX_LENGTH = 64; + private static readonly PATTERN = /^[a-z][a-z0-9_]*$/; + + private constructor(private readonly value: string) {} + + static create(name: string): EventName { + if (!name || name.trim().length === 0) { + throw new Error('Event name cannot be empty'); + } + if (name.length > EventName.MAX_LENGTH) { + throw new Error(`Event name must be at most ${EventName.MAX_LENGTH} characters, got ${name.length}`); + } + if (!EventName.PATTERN.test(name)) { + throw new Error(`Event name must be snake_case (lowercase letters, digits, underscores): "${name}"`); + } + return new EventName(name); + } + + /** + * Create an EventName without strict pattern validation (for legacy/external events). + * Still enforces max length. + */ + static createLenient(name: string): EventName { + if (!name || name.trim().length === 0) { + throw new Error('Event name cannot be empty'); + } + if (name.length > EventName.MAX_LENGTH) { + throw new Error(`Event name must be at most ${EventName.MAX_LENGTH} characters, got ${name.length}`); + } + return new EventName(name); + } + + toString(): string { + return this.value; + } + + equals(other: EventName): boolean { + return this.value === other.value; + } + + /** Check if this is a session-related event */ + isSessionEvent(): boolean { + return this.value === 'app_session_start' || this.value === 'app_session_end'; + } + + /** Check if this is a heartbeat-related event */ + isHeartbeatEvent(): boolean { + return this.value === 'heartbeat' || this.value === 'app_heartbeat'; + } +} diff --git a/backend/services/telemetry-service/src/domain/value-objects/install-id.vo.ts b/backend/services/telemetry-service/src/domain/value-objects/install-id.vo.ts new file mode 100644 index 0000000..5ada26e --- /dev/null +++ b/backend/services/telemetry-service/src/domain/value-objects/install-id.vo.ts @@ -0,0 +1,41 @@ +/** + * Value Object: InstallId + * Represents a unique installation identifier for a device/app instance. + * Used to track anonymous users before they authenticate. + * Max 128 characters, must be non-empty. + */ +export class InstallId { + private static readonly MAX_LENGTH = 128; + private static readonly MIN_LENGTH = 1; + + private constructor(private readonly value: string) {} + + static create(id: string): InstallId { + if (!id || id.trim().length === 0) { + throw new Error('Install ID cannot be empty'); + } + if (id.length < InstallId.MIN_LENGTH) { + throw new Error(`Install ID must be at least ${InstallId.MIN_LENGTH} character(s)`); + } + if (id.length > InstallId.MAX_LENGTH) { + throw new Error(`Install ID must be at most ${InstallId.MAX_LENGTH} characters, got ${id.length}`); + } + return new InstallId(id); + } + + toString(): string { + return this.value; + } + + equals(other: InstallId): boolean { + return this.value === other.value; + } + + /** Returns a masked version for logging (shows first 8 + last 4 chars) */ + toMasked(): string { + if (this.value.length <= 12) { + return this.value; + } + return `${this.value.slice(0, 8)}...${this.value.slice(-4)}`; + } +} diff --git a/backend/services/telemetry-service/src/domain/value-objects/time-window.vo.ts b/backend/services/telemetry-service/src/domain/value-objects/time-window.vo.ts new file mode 100644 index 0000000..ce5c920 --- /dev/null +++ b/backend/services/telemetry-service/src/domain/value-objects/time-window.vo.ts @@ -0,0 +1,64 @@ +/** + * Value Object: TimeWindow + * Encapsulates the concept of a time window used for presence detection. + * The default window is 180 seconds (3 minutes), meaning a user is considered + * "online" if their last heartbeat was within this window. + */ +export class TimeWindow { + /** Default presence detection window: 180 seconds (3 minutes) */ + static readonly DEFAULT_SECONDS = 180; + + /** Minimum allowed window: 30 seconds */ + static readonly MIN_SECONDS = 30; + + /** Maximum allowed window: 600 seconds (10 minutes) */ + static readonly MAX_SECONDS = 600; + + private constructor(private readonly seconds: number) {} + + static create(seconds: number = TimeWindow.DEFAULT_SECONDS): TimeWindow { + if (seconds < TimeWindow.MIN_SECONDS) { + throw new Error(`Time window must be at least ${TimeWindow.MIN_SECONDS} seconds, got ${seconds}`); + } + if (seconds > TimeWindow.MAX_SECONDS) { + throw new Error(`Time window must be at most ${TimeWindow.MAX_SECONDS} seconds, got ${seconds}`); + } + if (!Number.isInteger(seconds)) { + throw new Error('Time window must be an integer number of seconds'); + } + return new TimeWindow(seconds); + } + + /** Create the default 180-second window */ + static default(): TimeWindow { + return new TimeWindow(TimeWindow.DEFAULT_SECONDS); + } + + /** Get the window duration in seconds */ + toSeconds(): number { + return this.seconds; + } + + /** Get the window duration in milliseconds */ + toMilliseconds(): number { + return this.seconds * 1000; + } + + /** Calculate the threshold timestamp (now - window) as Unix epoch seconds */ + getThresholdEpoch(): number { + return Math.floor(Date.now() / 1000) - this.seconds; + } + + /** Calculate the threshold timestamp as a Date */ + getThresholdDate(): Date { + return new Date(Date.now() - this.toMilliseconds()); + } + + equals(other: TimeWindow): boolean { + return this.seconds === other.seconds; + } + + toString(): string { + return `${this.seconds}s`; + } +} diff --git a/backend/services/user-service/src/infrastructure/kafka/telemetry-producer.service.ts b/backend/services/telemetry-service/src/infrastructure/kafka/telemetry-producer.service.ts similarity index 96% rename from backend/services/user-service/src/infrastructure/kafka/telemetry-producer.service.ts rename to backend/services/telemetry-service/src/infrastructure/kafka/telemetry-producer.service.ts index cebfdd6..0bf6d2a 100644 --- a/backend/services/user-service/src/infrastructure/kafka/telemetry-producer.service.ts +++ b/backend/services/telemetry-service/src/infrastructure/kafka/telemetry-producer.service.ts @@ -9,7 +9,7 @@ export class TelemetryProducerService implements OnModuleInit, OnModuleDestroy { constructor() { this.kafka = new Kafka({ - clientId: 'user-service-telemetry', + clientId: 'telemetry-service', brokers: (process.env.KAFKA_BROKERS || 'localhost:9092').split(','), }); this.producer = this.kafka.producer(); @@ -46,7 +46,7 @@ export class TelemetryProducerService implements OnModuleInit, OnModuleDestroy { installId: data.installId, timestamp: data.timestamp, properties: data.properties || {}, - source: 'user-service', + source: 'telemetry-service', }), }], }); diff --git a/backend/services/user-service/src/infrastructure/metrics/telemetry-metrics.service.ts b/backend/services/telemetry-service/src/infrastructure/metrics/telemetry-metrics.service.ts similarity index 100% rename from backend/services/user-service/src/infrastructure/metrics/telemetry-metrics.service.ts rename to backend/services/telemetry-service/src/infrastructure/metrics/telemetry-metrics.service.ts diff --git a/backend/services/user-service/src/infrastructure/redis/.gitkeep b/backend/services/telemetry-service/src/infrastructure/persistence/.gitkeep similarity index 100% rename from backend/services/user-service/src/infrastructure/redis/.gitkeep rename to backend/services/telemetry-service/src/infrastructure/persistence/.gitkeep diff --git a/backend/services/user-service/src/infrastructure/redis/presence-redis.service.ts b/backend/services/telemetry-service/src/infrastructure/redis/presence-redis.service.ts similarity index 100% rename from backend/services/user-service/src/infrastructure/redis/presence-redis.service.ts rename to backend/services/telemetry-service/src/infrastructure/redis/presence-redis.service.ts diff --git a/backend/services/user-service/src/interface/http/controllers/admin-telemetry.controller.ts b/backend/services/telemetry-service/src/interface/http/controllers/admin-telemetry.controller.ts similarity index 77% rename from backend/services/user-service/src/interface/http/controllers/admin-telemetry.controller.ts rename to backend/services/telemetry-service/src/interface/http/controllers/admin-telemetry.controller.ts index 8a9308e..58f67c0 100644 --- a/backend/services/user-service/src/interface/http/controllers/admin-telemetry.controller.ts +++ b/backend/services/telemetry-service/src/interface/http/controllers/admin-telemetry.controller.ts @@ -6,6 +6,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { TelemetryEvent } from '../../../domain/entities/telemetry-event.entity'; import { PresenceRedisService } from '../../../infrastructure/redis/presence-redis.service'; +import { QueryDauDto, QueryEventsDto } from '../dto/query-dau.dto'; @ApiTags('admin-telemetry') @Controller('admin/telemetry') @@ -21,29 +22,27 @@ export class AdminTelemetryController { @Get('dau') @ApiOperation({ summary: 'Query DAU statistics' }) - async getDauStats(@Query('startDate') startDate: string, @Query('endDate') endDate: string) { - const result = await this.telemetryService.getDauStats(startDate, endDate); + async getDauStats(@Query() query: QueryDauDto) { + const result = await this.telemetryService.getDauStats(query.startDate, query.endDate); return { code: 0, data: result }; } @Get('events') @ApiOperation({ summary: 'Query telemetry events' }) - async listEvents( - @Query('page') page = 1, - @Query('limit') limit = 20, - @Query('eventName') eventName?: string, - @Query('userId') userId?: string, - ) { + async listEvents(@Query() query: QueryEventsDto) { + const page = query.page || 1; + const limit = query.limit || 20; + const qb = this.eventRepo.createQueryBuilder('e'); - if (eventName) qb.andWhere('e.event_name = :eventName', { eventName }); - if (userId) qb.andWhere('e.user_id = :userId', { userId }); + if (query.eventName) qb.andWhere('e.event_name = :eventName', { eventName: query.eventName }); + if (query.userId) qb.andWhere('e.user_id = :userId', { userId: query.userId }); qb.orderBy('e.event_time', 'DESC') - .skip((+page - 1) * +limit) - .take(+limit); + .skip((page - 1) * limit) + .take(limit); const [items, total] = await qb.getManyAndCount(); - return { code: 0, data: { items, total, page: +page, limit: +limit } }; + return { code: 0, data: { items, total, page, limit } }; } @Get('realtime') diff --git a/backend/services/telemetry-service/src/interface/http/controllers/health.controller.ts b/backend/services/telemetry-service/src/interface/http/controllers/health.controller.ts new file mode 100644 index 0000000..db9f26c --- /dev/null +++ b/backend/services/telemetry-service/src/interface/http/controllers/health.controller.ts @@ -0,0 +1,16 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; + +@ApiTags('health') +@Controller('health') +export class HealthController { + @Get() + @ApiOperation({ summary: 'Health check endpoint' }) + check() { + return { + status: 'ok', + service: 'telemetry-service', + timestamp: new Date().toISOString(), + }; + } +} diff --git a/backend/services/user-service/src/interface/http/controllers/metrics.controller.ts b/backend/services/telemetry-service/src/interface/http/controllers/metrics.controller.ts similarity index 100% rename from backend/services/user-service/src/interface/http/controllers/metrics.controller.ts rename to backend/services/telemetry-service/src/interface/http/controllers/metrics.controller.ts diff --git a/backend/services/user-service/src/interface/http/controllers/telemetry.controller.ts b/backend/services/telemetry-service/src/interface/http/controllers/telemetry.controller.ts similarity index 84% rename from backend/services/user-service/src/interface/http/controllers/telemetry.controller.ts rename to backend/services/telemetry-service/src/interface/http/controllers/telemetry.controller.ts index c62760f..fdd9375 100644 --- a/backend/services/user-service/src/interface/http/controllers/telemetry.controller.ts +++ b/backend/services/telemetry-service/src/interface/http/controllers/telemetry.controller.ts @@ -2,6 +2,8 @@ import { Controller, Post, Get, Body, Query, UseGuards, Req } from '@nestjs/comm import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { JwtAuthGuard } from '@genex/common'; import { TelemetryService } from '../../../application/services/telemetry.service'; +import { BatchEventsDto } from '../dto/batch-events.dto'; +import { HeartbeatDto } from '../dto/heartbeat.dto'; @ApiTags('telemetry') @Controller('telemetry') @@ -10,15 +12,7 @@ export class TelemetryController { @Post('events') @ApiOperation({ summary: 'Batch report telemetry events (no auth required)' }) - async batchEvents(@Body() body: { - events: Array<{ - eventName: string; - userId?: string; - installId: string; - clientTs: number; - properties?: Record; - }>; - }) { + async batchEvents(@Body() body: BatchEventsDto) { const result = await this.telemetryService.recordEvents(body.events); return { code: 0, data: result }; } @@ -27,7 +21,7 @@ export class TelemetryController { @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ summary: 'Report heartbeat for online detection' }) - async heartbeat(@Req() req: any, @Body() body: { installId: string; appVersion: string }) { + async heartbeat(@Req() req: any, @Body() body: HeartbeatDto) { await this.telemetryService.recordHeartbeat(req.user.sub, body.installId, body.appVersion); return { code: 0, data: { success: true } }; } diff --git a/backend/services/user-service/src/interface/http/dto/batch-events.dto.ts b/backend/services/telemetry-service/src/interface/http/dto/batch-events.dto.ts similarity index 100% rename from backend/services/user-service/src/interface/http/dto/batch-events.dto.ts rename to backend/services/telemetry-service/src/interface/http/dto/batch-events.dto.ts diff --git a/backend/services/user-service/src/interface/http/dto/heartbeat.dto.ts b/backend/services/telemetry-service/src/interface/http/dto/heartbeat.dto.ts similarity index 100% rename from backend/services/user-service/src/interface/http/dto/heartbeat.dto.ts rename to backend/services/telemetry-service/src/interface/http/dto/heartbeat.dto.ts diff --git a/backend/services/user-service/src/interface/http/dto/query-dau.dto.ts b/backend/services/telemetry-service/src/interface/http/dto/query-dau.dto.ts similarity index 100% rename from backend/services/user-service/src/interface/http/dto/query-dau.dto.ts rename to backend/services/telemetry-service/src/interface/http/dto/query-dau.dto.ts diff --git a/backend/services/telemetry-service/src/main.ts b/backend/services/telemetry-service/src/main.ts new file mode 100644 index 0000000..0387d5c --- /dev/null +++ b/backend/services/telemetry-service/src/main.ts @@ -0,0 +1,24 @@ +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { TelemetryModule } from './telemetry.module'; + +async function bootstrap() { + const app = await NestFactory.create(TelemetryModule); + app.setGlobalPrefix('api/v1'); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); + app.enableCors(); + + const config = new DocumentBuilder() + .setTitle('Genex Telemetry Service') + .setDescription('User presence detection, event collection, DAU analytics') + .setVersion('1.0.0') + .addBearerAuth() + .addTag('telemetry', 'Event collection & heartbeat') + .addTag('admin-telemetry', 'Admin analytics dashboard') + .build(); + SwaggerModule.setup('docs', app, SwaggerModule.createDocument(app, config)); + + await app.listen(parseInt(process.env.PORT || '3011', 10)); +} +bootstrap(); diff --git a/backend/services/telemetry-service/src/telemetry.module.ts b/backend/services/telemetry-service/src/telemetry.module.ts new file mode 100644 index 0000000..faa5dac --- /dev/null +++ b/backend/services/telemetry-service/src/telemetry.module.ts @@ -0,0 +1,70 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { ScheduleModule } from '@nestjs/schedule'; + +// Domain entities +import { TelemetryEvent } from './domain/entities/telemetry-event.entity'; +import { OnlineSnapshot } from './domain/entities/online-snapshot.entity'; +import { DailyActiveStats } from './domain/entities/daily-active-stats.entity'; + +// Infrastructure +import { PresenceRedisService } from './infrastructure/redis/presence-redis.service'; +import { TelemetryProducerService } from './infrastructure/kafka/telemetry-producer.service'; +import { TelemetryMetricsService } from './infrastructure/metrics/telemetry-metrics.service'; + +// Application services +import { TelemetryService } from './application/services/telemetry.service'; +import { TelemetrySchedulerService } from './application/services/telemetry-scheduler.service'; + +// Interface - Controllers +import { TelemetryController } from './interface/http/controllers/telemetry.controller'; +import { AdminTelemetryController } from './interface/http/controllers/admin-telemetry.controller'; +import { MetricsController } from './interface/http/controllers/metrics.controller'; +import { HealthController } from './interface/http/controllers/health.controller'; + +@Module({ + imports: [ + TypeOrmModule.forRoot({ + type: 'postgres', + host: process.env.DB_HOST || 'localhost', + port: parseInt(process.env.DB_PORT || '5432', 10), + username: process.env.DB_USERNAME || 'genex', + password: process.env.DB_PASSWORD || 'genex_dev_password', + database: process.env.DB_NAME || 'genex', + autoLoadEntities: true, + synchronize: false, + logging: process.env.NODE_ENV === 'development', + extra: { + max: parseInt(process.env.DB_POOL_MAX || '20', 10), + min: parseInt(process.env.DB_POOL_MIN || '5', 10), + }, + }), + TypeOrmModule.forFeature([ + TelemetryEvent, + OnlineSnapshot, + DailyActiveStats, + ]), + PassportModule.register({ defaultStrategy: 'jwt' }), + JwtModule.register({ + secret: process.env.JWT_ACCESS_SECRET || 'dev-access-secret', + }), + ScheduleModule.forRoot(), + ], + controllers: [ + TelemetryController, + AdminTelemetryController, + MetricsController, + HealthController, + ], + providers: [ + PresenceRedisService, + TelemetryProducerService, + TelemetryMetricsService, + TelemetryService, + TelemetrySchedulerService, + ], + exports: [TelemetryService], +}) +export class TelemetryModule {} diff --git a/backend/services/telemetry-service/tsconfig.json b/backend/services/telemetry-service/tsconfig.json new file mode 100644 index 0000000..7d866ac --- /dev/null +++ b/backend/services/telemetry-service/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2021", + "lib": ["ES2021"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "declaration": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "paths": { + "@genex/common": ["../../packages/common/src"], + "@genex/kafka-client": ["../../packages/kafka-client/src"] + } + }, + "include": ["src/**/*"] +} diff --git a/backend/services/user-service/package.json b/backend/services/user-service/package.json index fdb4f14..43315ad 100644 --- a/backend/services/user-service/package.json +++ b/backend/services/user-service/package.json @@ -16,7 +16,6 @@ "@nestjs/jwt": "^10.2.0", "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.3.0", - "@nestjs/schedule": "^4.0.0", "@nestjs/swagger": "^7.2.0", "@nestjs/throttler": "^5.1.0", "@nestjs/typeorm": "^10.0.1", @@ -24,12 +23,9 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "ioredis": "^5.3.2", - "kafkajs": "^2.2.4", - "minio": "^8.0.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "pg": "^8.11.3", - "prom-client": "^15.1.3", "reflect-metadata": "^0.2.1", "rxjs": "^7.8.1", "typeorm": "^0.3.19" diff --git a/backend/services/user-service/src/interface/http/dto/create-version.dto.ts b/backend/services/user-service/src/interface/http/dto/create-version.dto.ts deleted file mode 100644 index c50334b..0000000 --- a/backend/services/user-service/src/interface/http/dto/create-version.dto.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { - IsBoolean, - IsDateString, - IsIn, - IsInt, - IsOptional, - IsString, - IsUrl, - MaxLength, -} from 'class-validator'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; - -export class CreateVersionDto { - @ApiProperty({ enum: ['android', 'ios', 'ANDROID', 'IOS'] }) @IsIn(['android', 'ios', 'ANDROID', 'IOS']) platform: string; - @ApiProperty({ example: 10 }) @IsInt() versionCode: number; - @ApiProperty({ example: '1.2.0', maxLength: 32 }) @IsString() @MaxLength(32) versionName: string; - @ApiProperty({ example: '20250101.1', maxLength: 64 }) @IsString() @MaxLength(64) buildNumber: string; - @ApiProperty({ example: 'https://cdn.example.com/app-1.2.0.apk' }) @IsUrl() downloadUrl: string; - @ApiProperty({ example: '52428800' }) @IsString() fileSize: string; - @ApiProperty({ example: 'a1b2c3d4...', maxLength: 64 }) @IsString() @MaxLength(64) fileSha256: string; - @ApiProperty({ example: 'Bug fixes and performance improvements.' }) @IsString() changelog: string; - @ApiProperty({ example: false }) @IsBoolean() isForceUpdate: boolean; - @ApiPropertyOptional({ maxLength: 16 }) @IsOptional() @IsString() @MaxLength(16) minOsVersion?: string; - @ApiPropertyOptional({ example: '2025-06-01' }) @IsOptional() @IsDateString() releaseDate?: string; -} - -export class UpdateVersionDto { - @ApiPropertyOptional({ example: '1.2.1', maxLength: 32 }) @IsOptional() @IsString() @MaxLength(32) versionName?: string; - @ApiPropertyOptional({ example: '20250102.1', maxLength: 64 }) @IsOptional() @IsString() @MaxLength(64) buildNumber?: string; - @ApiPropertyOptional({ example: 'https://cdn.example.com/app-1.2.1.apk' }) @IsOptional() @IsUrl() downloadUrl?: string; - @ApiPropertyOptional({ example: '52428800' }) @IsOptional() @IsString() fileSize?: string; - @ApiPropertyOptional({ example: 'a1b2c3d4...', maxLength: 64 }) @IsOptional() @IsString() @MaxLength(64) fileSha256?: string; - @ApiPropertyOptional({ example: 'Updated changelog.' }) @IsOptional() @IsString() changelog?: string; - @ApiPropertyOptional({ example: false }) @IsOptional() @IsBoolean() isForceUpdate?: boolean; - @ApiPropertyOptional({ maxLength: 16 }) @IsOptional() @IsString() @MaxLength(16) minOsVersion?: string; - @ApiPropertyOptional({ example: '2025-06-01' }) @IsOptional() @IsDateString() releaseDate?: string; -} diff --git a/backend/services/user-service/src/interface/http/dto/upload-version.dto.ts b/backend/services/user-service/src/interface/http/dto/upload-version.dto.ts deleted file mode 100644 index 1013f31..0000000 --- a/backend/services/user-service/src/interface/http/dto/upload-version.dto.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { IsDateString, IsIn, IsNumberString, IsOptional, IsString, MaxLength } from 'class-validator'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; - -export class UploadVersionDto { - @ApiProperty({ enum: ['android', 'ios', 'ANDROID', 'IOS'] }) @IsIn(['android', 'ios', 'ANDROID', 'IOS']) platform: string; - @ApiPropertyOptional() @IsOptional() @IsNumberString() versionCode?: string; - @ApiPropertyOptional({ maxLength: 32 }) @IsOptional() @IsString() @MaxLength(32) versionName?: string; - @ApiPropertyOptional({ maxLength: 64 }) @IsOptional() @IsString() @MaxLength(64) buildNumber?: string; - @ApiPropertyOptional() @IsOptional() @IsString() changelog?: string; - @ApiPropertyOptional({ enum: ['true', 'false'] }) @IsOptional() @IsIn(['true', 'false']) isForceUpdate?: string; - @ApiPropertyOptional({ maxLength: 16 }) @IsOptional() @IsString() @MaxLength(16) minOsVersion?: string; - @ApiPropertyOptional({ example: '2025-06-01' }) @IsOptional() @IsDateString() releaseDate?: string; -} diff --git a/backend/services/user-service/src/user.module.ts b/backend/services/user-service/src/user.module.ts index 4639d65..e11812f 100644 --- a/backend/services/user-service/src/user.module.ts +++ b/backend/services/user-service/src/user.module.ts @@ -2,17 +2,12 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; -import { ScheduleModule } from '@nestjs/schedule'; import { User } from './domain/entities/user.entity'; import { KycSubmission } from './domain/entities/kyc-submission.entity'; import { Wallet } from './domain/entities/wallet.entity'; import { Transaction } from './domain/entities/transaction.entity'; import { Message } from './domain/entities/message.entity'; -import { AppVersion } from './domain/entities/app-version.entity'; -import { TelemetryEvent } from './domain/entities/telemetry-event.entity'; -import { OnlineSnapshot } from './domain/entities/online-snapshot.entity'; -import { DailyActiveStats } from './domain/entities/daily-active-stats.entity'; import { UserRepository } from './infrastructure/persistence/user.repository'; import { KycRepository } from './infrastructure/persistence/kyc.repository'; @@ -20,11 +15,6 @@ import { WalletRepository } from './infrastructure/persistence/wallet.repository import { TransactionRepository } from './infrastructure/persistence/transaction.repository'; import { MessageRepository } from './infrastructure/persistence/message.repository'; -import { PresenceRedisService } from './infrastructure/redis/presence-redis.service'; -import { TelemetryMetricsService } from './infrastructure/metrics/telemetry-metrics.service'; -import { TelemetryProducerService } from './infrastructure/kafka/telemetry-producer.service'; -import { PackageParserService } from './infrastructure/parsers/package-parser.service'; - import { UserProfileService } from './application/services/user-profile.service'; import { KycService } from './application/services/kyc.service'; import { WalletService } from './application/services/wallet.service'; @@ -33,10 +23,6 @@ import { AdminDashboardService } from './application/services/admin-dashboard.se import { AdminUserService } from './application/services/admin-user.service'; import { AdminSystemService } from './application/services/admin-system.service'; import { AdminAnalyticsService } from './application/services/admin-analytics.service'; -import { TelemetryService } from './application/services/telemetry.service'; -import { TelemetrySchedulerService } from './application/services/telemetry-scheduler.service'; -import { AppVersionService } from './application/services/app-version.service'; -import { FileStorageService } from './application/services/file-storage.service'; import { UserController } from './interface/http/controllers/user.controller'; import { KycController } from './interface/http/controllers/kyc.controller'; @@ -46,39 +32,25 @@ import { AdminDashboardController } from './interface/http/controllers/admin-das import { AdminUserController } from './interface/http/controllers/admin-user.controller'; import { AdminSystemController } from './interface/http/controllers/admin-system.controller'; import { AdminAnalyticsController } from './interface/http/controllers/admin-analytics.controller'; -import { TelemetryController } from './interface/http/controllers/telemetry.controller'; -import { AdminTelemetryController } from './interface/http/controllers/admin-telemetry.controller'; -import { AppVersionController } from './interface/http/controllers/app-version.controller'; -import { AdminVersionController } from './interface/http/controllers/admin-version.controller'; -import { MetricsController } from './interface/http/controllers/metrics.controller'; @Module({ imports: [ TypeOrmModule.forFeature([ - User, KycSubmission, Wallet, Transaction, Message, AppVersion, - TelemetryEvent, OnlineSnapshot, DailyActiveStats, + User, KycSubmission, Wallet, Transaction, Message, ]), PassportModule.register({ defaultStrategy: 'jwt' }), JwtModule.register({ secret: process.env.JWT_ACCESS_SECRET || 'dev-access-secret', }), - ScheduleModule.forRoot(), ], controllers: [ UserController, KycController, WalletController, MessageController, AdminDashboardController, AdminUserController, AdminSystemController, AdminAnalyticsController, - TelemetryController, AdminTelemetryController, - AppVersionController, AdminVersionController, - MetricsController, ], providers: [ UserRepository, KycRepository, WalletRepository, TransactionRepository, MessageRepository, - PresenceRedisService, UserProfileService, KycService, WalletService, MessageService, AdminDashboardService, AdminUserService, AdminSystemService, AdminAnalyticsService, - TelemetryService, TelemetrySchedulerService, - AppVersionService, FileStorageService, PackageParserService, - TelemetryMetricsService, TelemetryProducerService, ], exports: [UserProfileService, WalletService, MessageService], }) diff --git a/docs/guides/07-遥测与版本管理开发指南.md b/docs/guides/07-遥测与版本管理开发指南.md index e45c55a..7586f8d 100644 --- a/docs/guides/07-遥测与版本管理开发指南.md +++ b/docs/guides/07-遥测与版本管理开发指南.md @@ -34,22 +34,21 @@ ### 2.1 架构决策 -rwadurian 项目将遥测放在独立的 `presence-service`,版本管理放在 `admin-service`。 -Genex 项目的适配方案: +参考 rwadurian 项目的独立服务架构,Genex 同样采用独立微服务方案: -| 功能 | 归属服务 | 理由 | -|------|---------|------| -| **遥测 (Telemetry)** | **user-service (扩展)** | 遥测与用户强关联,共享用户表和 Redis 连接,减少跨服务调用 | -| **版本管理 (App Version)** | **user-service (扩展)** | 版本管理与用户设备信息紧密耦合,统一管理降低运维复杂度 | +| 功能 | 归属服务 | 端口 | 理由 | +|------|---------|------|------| +| **遥测 (Telemetry)** | **telemetry-service** | :3011 | 独立微服务,专注心跳检测、事件采集、DAU 统计、Prometheus 指标 | +| **版本管理 (App Version)** | **admin-service** | :3012 | 独立微服务,专注 APK/IPA 管理、OTA 更新、MinIO 文件存储 | ### 2.2 与参考项目的差异 -| 维度 | rwadurian | Genex 适配 | -|------|-----------|-----------| +| 维度 | rwadurian | Genex | +|------|-----------|-------| | ORM | Prisma | TypeORM (与现有一致) | -| 架构 | 独立服务 + CQRS | 扩展 user-service,DDD 四层架构 | +| 架构 | 独立服务 + CQRS | **独立服务 + DDD 四层架构** | | 文件存储 | 本地 `./uploads` | **MinIO** (已有基础设施) | -| 事件总线 | Kafka | **Kafka** (已有 @genex/kafka-client) | +| 事件总线 | Kafka | **Kafka** (KRaft 模式) | | 缓存 | Redis | **Redis** (已有) | | APK 解析 | adbkit-apkreader | 同方案 | | IPA 解析 | unzipper + bplist-parser | 同方案 | @@ -229,46 +228,67 @@ DELETE /api/v1/admin/versions/:id — 删除版本 ## 四、实现文件清单 -### 4.1 遥测模块 (在 user-service 中扩展) +### 4.1 遥测服务 (telemetry-service :3011) ``` -services/user-service/src/ -├── domain/entities/ -│ ├── telemetry-event.entity.ts # 事件日志实体 -│ ├── online-snapshot.entity.ts # 在线快照实体 -│ └── daily-active-stats.entity.ts # DAU 统计实体 +services/telemetry-service/src/ +├── domain/ +│ ├── entities/ +│ │ ├── telemetry-event.entity.ts # 事件日志实体 +│ │ ├── online-snapshot.entity.ts # 在线快照实体 +│ │ └── daily-active-stats.entity.ts # DAU 统计实体 +│ ├── value-objects/ +│ │ ├── event-name.vo.ts # 事件名值对象 +│ │ ├── install-id.vo.ts # 安装ID值对象 +│ │ └── time-window.vo.ts # 时间窗口值对象 +│ └── events/ +│ ├── session-started.event.ts # 会话开始领域事件 +│ └── heartbeat-received.event.ts # 心跳接收领域事件 ├── application/services/ -│ ├── telemetry.service.ts # 事件采集 + 心跳 + DAU -│ └── telemetry-scheduler.service.ts # 定时任务 (快照/DAU/清理) +│ ├── telemetry.service.ts # 事件采集 + 心跳 + DAU +│ └── telemetry-scheduler.service.ts # 定时任务 (快照/DAU/清理) ├── infrastructure/ -│ └── redis/ -│ └── presence-redis.service.ts # Redis 在线检测操作 +│ ├── redis/ +│ │ └── presence-redis.service.ts # Redis 在线检测操作 +│ ├── kafka/ +│ │ └── telemetry-producer.service.ts # Kafka 事件发布 +│ └── metrics/ +│ └── telemetry-metrics.service.ts # Prometheus 指标 └── interface/http/ ├── controllers/ - │ ├── telemetry.controller.ts # 遥测 API - │ └── admin-telemetry.controller.ts # Admin 遥测分析 API + │ ├── telemetry.controller.ts # 遥测 API (心跳/事件/在线) + │ ├── admin-telemetry.controller.ts # Admin 遥测分析 API + │ ├── metrics.controller.ts # GET /metrics (Prometheus) + │ └── health.controller.ts # 健康检查 └── dto/ ├── batch-events.dto.ts ├── heartbeat.dto.ts └── query-dau.dto.ts ``` -### 4.2 版本管理模块 (在 user-service 中扩展) +### 4.2 版本管理服务 (admin-service :3012) ``` -services/user-service/src/ -├── domain/entities/ -│ └── app-version.entity.ts # 版本实体 +services/admin-service/src/ +├── domain/ +│ ├── entities/ +│ │ └── app-version.entity.ts # 版本实体 + Platform 枚举 +│ └── value-objects/ +│ ├── version-code.vo.ts # 版本号值对象 +│ ├── version-name.vo.ts # 版本名值对象 +│ ├── file-sha256.vo.ts # 文件哈希值对象 +│ └── download-url.vo.ts # 下载链接值对象 ├── application/services/ -│ ├── app-version.service.ts # 版本 CRUD + 检查更新 -│ └── file-storage.service.ts # MinIO 文件上传/下载 +│ ├── app-version.service.ts # 版本 CRUD + 检查更新 +│ └── file-storage.service.ts # MinIO 文件上传/下载 ├── infrastructure/ │ └── parsers/ -│ └── package-parser.service.ts # APK/IPA 解析 +│ └── package-parser.service.ts # APK/IPA 解析 └── interface/http/ ├── controllers/ - │ ├── app-version.controller.ts # 移动端检查更新 + 下载 - │ └── admin-version.controller.ts # Admin 版本管理 + │ ├── app-version.controller.ts # 移动端检查更新 + 下载 (无认证) + │ ├── admin-version.controller.ts # Admin 版本 CRUD (JWT+ADMIN) + │ └── health.controller.ts # 健康检查 └── dto/ ├── check-update.dto.ts ├── create-version.dto.ts @@ -285,22 +305,40 @@ migrations/ └── 035_create_app_versions.sql ``` -### 4.4 Kong 路由 (新增) +### 4.4 Kong 路由 ```yaml -# user-service 新增路由 -- name: telemetry-routes - paths: - - /api/v1/telemetry - strip_path: false -- name: app-version-routes - paths: - - /api/v1/app/version - strip_path: false -- name: admin-version-routes - paths: - - /api/v1/admin/versions - strip_path: false +# telemetry-service (:3011) +- name: telemetry-service + url: http://telemetry-service:3011 + routes: + - name: telemetry-routes + paths: [/api/v1/telemetry] + - name: admin-telemetry-routes + paths: [/api/v1/admin/telemetry] + +# admin-service (:3012) +- name: admin-service + url: http://admin-service:3012 + routes: + - name: app-version-routes + paths: [/api/v1/app/version] + - name: admin-version-routes + paths: [/api/v1/admin/versions] +``` + +### 4.5 Docker Compose 服务 + +```yaml +telemetry-service: + build: ./services/telemetry-service + ports: ["3011:3011"] + depends_on: [postgres, redis, kafka] + +admin-service: + build: ./services/admin-service + ports: ["3012:3012"] + depends_on: [postgres, minio] ``` ---